事件循环
JavaScript是单线程的语言,也就是说,同一个时间只能做一件事。如下事例一:
console.log('同步1');
console.log('同步2');
console.log('同步3');
...
以上是一个简单的按顺序执行的任务,一眼就能看出按照任务1,2,3…依次一个个执行。如果任务再复杂一点,如下事例二:
console.log('1')
setTimeout(() => {
console.log('2')
})
new Promise((resolve, rejects) => {
console.log('3')
resolve()
}).then(() => {
console.log('4')
})
console.log(5)
此时该怎么执行?会先打印1,再依次打印2,3,4,5这样执行?如若在setTimeout部分花费的时间较长,后面的Promise岂不是要一直挂起等待。这只是稍稍多一点任务,如若再涉及其他事件、用户交互、脚本、UI 渲染和网络处理等行为,那就会相当的卡顿了。
事实上不会这样。JavaScript是分为同步任务和异步任务来解决上面的问题的。如下图:
-
同步任务都在主线程上执行,形成一个执行栈。主线程之外,异步进入Event Table并注册函数。
-
只要异步任务有了运行结果时,Event Table会将这个函数移入Event Queue。
-
主线程内的任务执行完毕为空,会去Event Queue按先进先出的原则读取对应的函数,进入主线程执行。
那什么时候主线程执行栈为空呢? js引擎有一个monitoring process进程,会持续不断的检查主线程执行栈是否为空, 一旦执行栈中的所有同步任务执行完毕(此时引擎空闲), 就会去Event Queue那里检查是否有等待被调用的函数。
-
上述过程会不断重复,也就是常说的Event Loop(事件循环)。
事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(Task queue),一个任务队列便是一系列有序任务(Task)的集合;每个任务都有一个任务源(Task Source),源自同一个任务源的 Task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。setTimeout/Promise等API便是任务源,而进入任务队列的是他们指定的具体执行任务。
在事件循环中,每进行一次循环操作称为 Tick,每一次 Tick 的任务处理模型是比较复杂的,但关键步骤如下:
- 在此次 Tick 中选择最先进入队列的任务(Oldest Task),如果有则执行(一次)。
- 检查是否存在 micro-task,如果存在则不停地执行,直至清空 micro-task Queue。
- 更新 render
- 主线程重复执行上述步骤
上面事例二中的setTimeout和Promise部分都为异步任务,但它们在异步任务中不属于同一队列,一个属于macro-task Queue(宏任务队列),一个属于micro-task Queue(微任务队列)。
宏任务(macro-task)
宏任务主要有:
- script(整体代码)
- setTimeout
- setInterval
- I/O
- UI交互事件
- postMessage
- MessageChannel
- setImmediate(Node.js 环境)
可以把每次执行栈执行的代码理解为一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得JS内部macro-task与DOM任务能够有序的执行,会在一个macro-task执行结束后,在下一个macro-task 执行开始前,对页面进行重新渲染,流程如下:
macro-task->渲染->macro-task->...
微任务(micro-task)
微任务主要有:
- Promise.then
- Object.observe
- MutaionObserver
- process.nextTick(Node.js 环境)
micro-task,可以理解是在当前 Task 执行结束后立即执行的任务。也就是说,在当前Task任务后,下一个Task之前,在渲染之前。
宏任务和微任务运行机制如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
上面事例二中的script代码整体作为一个宏任务进入当前执行栈,先打印出 1;setTimeout为一个宏任务,加入宏任务队列;创建一个Promise的实例,立即执行传给Promise的回调,打印出 3,它的then为一个微任务,加入微任务队列;打印出 5;主线程执行完后开始检查微任务队列,打印出 4,微任务队列执行完;在检查宏任务队列,打印出 2。
async/await 如下事例三:
async function async1() {
console.log(1);
const result = await async2();
console.log(3);
}
async function async2() {
console.log(2);
}
Promise.resolve().then(() => {
console.log(4);
});
setTimeout(() => {
console.log(5);
});
async1();
console.log(6);
// 1 2 6 4 3 5
async/await本质上还是基于Promise的一些封装,而Promise是属于微任务的一种。所以在使用await关键字与Promise.then效果类似。async函数在await之前的代码都是同步执行的,await之后的所有代码类似于在Promise.then中的回调。先打印 1,再调用async2函数,打印 2,再打印出 6,主线程执行完毕。执行微任务打印出 4,再打印出异步 3,最后执行宏任务,打印出 5。
process.nextTick()
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
// 1 7 6 8 2 4 3 5 9 11 10 12
此事例中注意,遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。
process.nextTick()可参考理解 Node.js 里的 process.nextTick()
参考: js中的宏任务与微任务