我们都知道,Event Loop
即事件循环,是指浏览器或Node
的一种解决JavaScript
单线程运行时不会阻塞的一种机制,即我们经常使用异步的原理。
而浏览器中的事件循环与Node.js
事件循环机制各不相同。
1. 浏览器端Event Loop
1.1 我们为什么需要Event Loop
在介绍Event Loop
原理前,我们必须得要搞清楚为什么需要Event Loop
?JavaScript
是一门单线程语言,当一个函数执行时,它不会被抢占,只有在它运行完毕后才会去运行任何其他的代码,才能去修改这个函数操作的数据。然而当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互,例如点击或滚动;又或者是浏览新闻时图片加载过慢,网页不可能一直卡着直到图片完全显示出来。为了解决这些问题,我们将任务分为了两类:同步任务与异步任务。
在异步任务中,同一时间按照代码顺序等待执行。通常我们在遇到异步代码时将其挂起并略过,等待同步代码执行完毕后按照特定顺序执行异步代码。接下来我们深入了解一下。
1.2 JavaScript的运行模型
如图所示,当遇到同步代码时会立即执行;而遇到异步代码时将其加入到工作线程中,等异步代码所需时间到达后将其加入到任务队列当中。当执行栈为空时,被处理的消息被移除队列,并作为输入参数来调用与之关联的函数。函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息。
说了这么多,还不如来一个实例更容易理解:
const s = new Date().getSeconds();
setTimeout(function() {
// 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500);
while(true) {
if(new Date().getSeconds() - s >= 2) {
console.log("looped for 2 seconds");
break;
}
}
输出结果如下:
:::info
looped for 2 seconds
Ran after 2 seconds
:::
上述的代码执行流程如下:
- 先将异步代码挂起,放在工作线程中;
- 然后在运行同步代码
while
语句,等待间隔2秒; - 在500ms时,将异步任务加入任务队列中;到2秒时执行
console.log("looped for 2 seconds")
; - 此时执行栈为空,任务队列推送任务
setTimeout
给执行栈,开始执行。
相信通过上述的代码,大家对Event Loop
有了基本的了解。同时我们从代码中也可以发现,函数**setTimeout**
中的时间值参数它代表的是消息被实际加入队列的最小延迟时间,而不是确切的等待时间。如果任务队列中没有其他消息且栈为空,在该延迟时间过后消息会被立马处理;但如果有其他消息,setTimeout
消息必须等待其他消息处理完。
1.3 宏任务与微任务
在ECMA标准升级后,将异步任务分为了微任务和宏任务。
宏任务:是JS中原始的异步任务,包括setTimeout
、setInterval
、AJAX
等,在代码执行环境中按照同步代码的顺序,逐个进入工作线程挂起,再按照异步任务到达的时间节点,逐个进入异步任务队列,最终按照队列中的顺序进入函数执行栈执行。
微任务:每一个宏任务执行前,程序先检测是否有当次事件循环未执行的微任务,优先清空本次的微任务后,在执行下一个宏任务。每个宏任务内部可注册当次任务的微任务队列,在下一个宏任务执行前运行,微任务也是按照进入队列的顺序执行。包括Promise
、MutationObserve
等。
让我们先来看看两者的执行顺序:
执行栈的执行完同步任务后,判断执行栈是否为空。若执行栈为空,则去检查微任务队列是否为空,如果为空则去执行宏任务,否则一次性执行完所有的微任务。
当宏任务执行完毕后,检查是否存在微任务队列是否为空。如果为空则继续执行下一个宏任务;否则去执行完所有的微任务,然后再去执行宏任务,如此循环。
举个例子:
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
输出结果如下:
:::info
script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout
:::
在讲解之前,我们必须得要理解async/await
在底层是转换成promise
和then
的回调函数。当我们使用await
,解释器创建一个promise
对象,然后把剩下的async函数操作放到then
回调函数中。我们需要知道,在同一个上下文当中,总的执行顺序为同步代码 ——> 微任务 ——> 宏任务。
我相信,根据上面的讲述大家应该都能正确理解,这里就不过多讲解了。
2. Node.js环境Event Loop
2.1 执行流程
Node.js
的Event Loop
分为六个阶段,它们按照顺序反复执行,在每个阶段后面都会运行微任务队列。
- `timers`:执行`setTimeout()` 和 `setInterval()`中到期的callback。
- `I/O callbacks`:上一轮循环中有少数的`I/Ocallback`会被延迟到这一轮的这一阶段执行
- `idle, prepare`:队列的移动,仅内部使用
- `poll`:最为重要的阶段,执行`I/O callback`,在适当的条件下会阻塞在这个阶段
- `check`:执行`setImmediate`的callback
- `close callbacks`:执行close事件的callback,例如s`ocket.on("close",func)`
2.2 setTimeout/setImmediate
在运行过程中,如果timers
阶段执行时创建了setImmediate
,则会在此轮循环的check
阶段中执行;如果timers
阶段创建了setTimeout
,此时由于timers
已取出完毕,则会进入到下一轮循环。check
阶段创建timers
任务同理。
来个代码演示一下:
const fs = require('fs');
// 此时处在 I/O 周期
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
运行结果一定为`setImmediate > setTimeout`, 因为在`I/O`阶段读取文件后进行到了`poll`阶段,然后到`check`阶段,此时会立刻执行`setImmediate`,等到进入`timers`阶段采取执行`setTimeout`。
2.3 Process.nextTick()
process.nextTick()
将callback
添加到next tick
队列。
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
Promise.resolve().then(() => console.log(3));
process.nextTick(() => console.log(4));
// 输出结果:4 3 1 2或者4 3 2 1
从输出结果可以看到,微任务比宏任务先运行,而在Node
中process.nextTick
比Promise
更为优先,所以输出结果4 —> 3;但在Node
中没有绝对意义上的0ms,所以setTimeout
和setImmediate
顺序不固定。
2.4 浏览器与Node执行顺序比较
setTimeout(()=>{
console.log('timeout1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timeout2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
输出结果如下:
:::info
在浏览器中:timeout1 —> promise1 —> timeout2 —> promise2
在Node环境中: timeout1 —> timeout2 —> promise1 —> promise2
:::
下面来解释一下Node环境中的逻辑:
开始时进入timers
阶段,执行timeout1
的回调,打印出timeout1
后将promise。then()
放入微任务队列中,相同步骤执行 timeout2
。在timers
阶段结束后进入下一个阶段前,执行微任务队列中的所有任务,依次打印出promise1
、promise2
.
浏览器的执行顺序就不过多讲述了。
3、总结
总体而言,浏览器端与Node.js的执行顺序大有不同。最大的差异在于浏览器端按照同步代码 —>微任务 —>宏任务的顺序执行;而Node.js环境中按照六个阶段顺序执行,且在每个阶段结束后都会执行微任务队列里的所有任务。
本文就写到这里,如有错误,敬请指正!