event loop
首先我们要明确一点,JS 是一门单线程语言,执行起来是自上而下一条条语句执行的,而所有的异步都是 JS 用单线程模拟出来的。
当 JS 语句开始执行时,自然是一条条语句往下执行,然而遇到一些耗时长的操作,比如 Ajax 请求,如果一直停留在这里等待,就会阻塞进程。所以 JS 将任务分为了 同步任务以及异步任务。
同步任务就是常规的 JS 语句,比如变量声明、赋值等等,这些语句会一条条依序执行
let a
a = 2
a++
JS 还定义了一些异步 API,常见的有 setTimeout、setInterval、Promise、async 等,执行这些 API 的时候,会将事件注册到 事件队列 中,待同步任务执行完后,就会查看是否有异步任务需要执行。
setTimeout
执行 setTimeout 语句时,会将 setTimeout 注册到 event table 中,等到延时时间到了之后,再将 setTimeout 的回调函数,注册到 event queue 中。待同步任务执行完后,再从任务队列中执行 setTimeout 的回调函数。
DANGER
并不是在延时时间到达之后,就会开始执行回调函数
console.time("timer")
setTimeout(function() {
console.timeEnd("timer") // 远远大于 100
}, 100)
for (let i = 0; i < 2e9; i++) {} // 耗时很长
这段代码的执行的顺序是:
执行到 setTimeout,将 setTimeout 放入 event table 中,继续执行下面的同步代码
执行到了 for 语句,执行的时间很长
100 ms 到了,for 语句还没完成。这边 setTimeout 的延时时间到了,将 setTimeout 的回调函数注册到 event queue 中。
for 语句花了远超 100ms 的时间终于执行完了,至此所有的同步代码都执行完成。开始检查 event queue 中是否有任务需要处理
发现 event queue 中存在任务,开始执行,第三行打印出结果
setInterval
setInterval 的执行顺序跟 setTimeout 其实差不太多,执行到 setInterval 的时候,会将 setInterval 注册到 event table 中,每隔一定的时间间隔(setInterval 的 delay),就会将回调函数注册到 event queue 中,而不是每隔一定时间将回调函数执行一次。举个例子
setInterval(function() {
// 耗时超过 1s,比如 10s
}, 1000)
- 将 setInterval 注册到 event table 中, 等待同步任务处理完毕
- 1s 后,将回调函数推进 event queue 中,开始执行
- 回调函数执行 1s 后,没有执行完成,此时 event table 又将一个回调函数推进 event queue 中
- 第一个回调函数终于执行完了,此时 event queue 中已经堆积了好几个事件了
- 于是,继续执行执行 event queue 中的下一个事件,而无需等待 delay 时间,看起来就像是回调函数在不间断执行
promise
promise 的 then、catch、finally 以及 Promise.resolve 和 Promise.reject 都会将回调函数推入 event queue 中,等待同步任务处理完毕之后,再从 event queue 中依次执行事件
console.log("start")
new Promise((resolve, reject) => {
console.log("promise")
resolve()
})
.then(() => {
console.log("promise1")
})
.finally(() => {
console.log("promise end")
})
console.log("end")
// 打印顺序:start => promise => end => promise1 => promise end
我们来看一下这段代码的执行顺序
- 执行第一行,打印 start
- 执行到第三行 promise 主体,打印 promise,同时将第一个 then 方法的回调函数推入到 event queue 中,继续执行下面的同步代码
- 同步代码只有第 14 行的 end 了,执行完后开始检查任务队列中,是否存在事件需要处理
- 此时,then 的回调函数正在事件队列中,执行打印 promise1,再将 finally 的回调函数放入事件队列中
- 试图执行同步任务,发现没有,于是执行任务队列中的函数
- 打印 promise end
promise.resolve
promise.resolve 允许接受常规值或者一个 promise。
- 当接受一个 常规值时,比如 字符串、数字、数组等
let a = Promise.resolve(1).then((res) => {
console.log(res) // 1
})
都会返回一个 fulfilled 状态的 promise,此时如果在后面接一个 then 方法,then 中回调的参数就为 resolve 中的值,在上面的例子中就为 1。当然 then 方法中的回调函数则会推入到 event queue 中。
- 当接受一个 promise 时,则会直接返回这个 promise
let p1 = new Promise(() => {})
let p2 = Promise.resolve(p1)
console.log(p1 === p2) // true
async
async 函数会隐式 return 出来一个 promise,如果显示的 return 出来一个常规值,则会将返回值用 Promise.resolve()包裹起来,如果 return 出来一个 promise,则会返回 promise 。
async function fn() {
return 11
}
console.log(fn()) // Promise{<fulfilled>: 11}
// 如果没有return,则会打印 Promise{<fulfilled>: undefined}
async 函数中,允许存在 await 关键字,在最新的 ES 标准上,await 跟 Promise.resolve 语义保持一致。
async function fn() {
console.log(1)
let result = await 2
console.log(result)
}
console.log(3)
将上面代码解释为下面的样子,应该就清晰多了
async function fn() {
console.log(1)
Promise.resolve(2).then((res) => {
let result = res
console.log(result)
})
}
fn()
console.log(3)
接下来,再看看 await 一个函数的情况
async function fn() {
console.log(1)
let result = await fn2()
console.log(2)
}
fn()
console.log("end")
function fn2() {
console.log(3)
return 4
}
// 打印顺序:1 => 3 => end => 2
- 声明函数后,执行第 6 行,先打印 1,执行到 await fn2()时,会先执行 fn2 函数,打印出 3
- await 让出线程,继续往下执行同步任务,打印 end
- 同步任务执行完毕后,检查异步队列
- 回到 await 的位置,将 fn2 函数返回的值赋值给 result,并继续执行下面的语句
总结
JS 执行语句可以分为同步任务和异步任务,其中异步任务又可以分为 宏任务(task),以及微任务(jobs)
宏任务:script、setTimeout、setInterval
微任务:promise
JS 的执行顺序是,
- 先执行一遍 script(宏任务),在这过程中,会将一些异步事件到 事件队列中。
- script 执行完毕,开始检查微任务队列中是否存在事件需要执行
- 如果有,则执行完所有的微任务队列。如果没有或者是执行完了所有的微任务之后,则开始检查宏任务队列
- 执行一个宏任务之后,继续执行微任务队列
- 循环...
看下面这段代码
setTimeout(function() {
console.log(1)
Promise.resolve(5).then((res) => {
console.log(5)
})
}, 0)
setTimeout(function() {
console.log(2)
}, 4)
new Promise((resolve, reject) => {
console.log(3)
resolve()
})
.then((res) => {
console.log(4)
})
.finally(() => {
console.log(6)
})
console.log("end")
// 打印顺序:3 => end => 4 => 6 => 1 => 5 => 2
让我们来看一看它是如何执行的
遇到 setTimeout,将两个 setTimeout 都注册到 event table 中,其中第一个直接将回调函数推入到事件队列中,第二个 setTimeout 在 4ms 后将回调推入队列
继续往下走,执行到 promise,打印 3 后,将 then 的回调函数也就是第 15 行 push 到微任务队列中,
打印 end,至此 script 执行完毕,执行完了一次宏任务
开始检查微任务队列,发现有一个事件,也就是第 15 行注册的回调函数,执行它打印出 4,同时又将 finally 的回调函数第 18 行推入微任务队列中,于是继续执行新的微任务,打印 6
微任务队列都执行完了,开始检查宏任务队列,发现宏任务队列中有两个 setTimeout 注册的函数,执行第一个函数。打印出 1,并把 then 的回调推入微任务队列中
第一个宏任务执行完毕,检查微任务队列,发现第三行刚推入微任务队列函数,执行它打印 5
微任务队列执行完毕,继续执行宏任务队列,找到了第二个 setTimeout 注册的函数,执行它打印 2
宏任务队列和微任务队列都清空了,代码执行结束
再来一个例子
先看这段代码
async function async1() {
let res = await async2()
console.log("async1 end")
}
async function async2() {
console.log("async2 end")
return 2
}
async1()
new Promise((resolve) => {
console.log("Promise")
resolve()
})
.then(function() {
console.log("promise1")
})
.then(function() {
console.log("promise2")
})
console.log("script end")
// 打印顺序:async2 end => Promise => script end => async1 end => promise1 => promise2
这段代码应该很快就可以看出它的打印顺序,那我们将这个例子改动一下, async2 中的 return 2 改为 return await 2,即改为以下代码
async function async1() {
let res = await async2()
console.log("async1 end")
}
async function async2() {
console.log("async2 end")
return await 2
}
async1()
new Promise((resolve) => {
console.log("Promise")
resolve()
})
.then(function() {
console.log("promise1")
})
.then(function() {
console.log("promise2")
})
console.log("script end")
// 打印顺序:async2 end => Promise => script end => promise1 => async1 end => promise2
让我们来分析一哈,
- 先声明两个函数 async1 和 async2,执行到第 9 行执行 async1 函数,调用 async2,打印 async2 end
- 执行到 async2 中 await 关键字,也就是第 7 行,让出线程,执行外部代码
- 执行到 12 行,打印 Promise,并将第一个 then 的回调推入到事件队列中,接着执行同步代码
- 执行 22 行,打印 script end,宏任务执行完毕,开始执行微任务
- 回到第 7 行的 await 这里,async2 终于将 2 return 了出去,但是第 2 行还有一个 await,又得让出线程执行同步代码。可以理解成将一个事件推入了事件队列,事件包含了 await 回来之后的后续操作
- 继续执行咯,打印 promise1,同时将下一个 then 也就是第 18 行的函数推入事件队列
- 这时,微任务队列中,就有两个事件了,按照队列的执行顺序依次打印就是 async1 end => promise2
- 完结 🎉