前言
最近一直在看React的源码,发现React并发模式下的更新都使用了异步的方式渲染,刚好自己也一直想总结一篇关于event loop的文章,来巩固一下对event loop的理解。
关于浏览器的进程
浏览器是多进程的,每个tab页面是独立的浏览器进程。
浏览器中也包含一个类似于任务管理器的工具, 在Chrome中可以通过菜单->更多工具->任务管理器打开.
根据图片可以看到,每一个tab页面都是独立的浏览器进程。
浏览器包含的进程:
可以看到,浏览器主要包括四个进程:
- 浏览器进程:浏览器的主进程,负责协调和控制浏览器,只有一个。
- 负责浏览器界面显示,与用户交互,如前进,后退等
- 负责各个页面的管理,创建和销毁其他进程
- 将Renderer进程得到的内存中的图片绘制到用户界面上
- 网络资源的管理,下载等
- 第三方插件进程:使用第三方插件时对应创建的进程。
- GPU进程:最多一个,负责3D图像的绘制。
- 渲染进程:也就是前端接触最密切的进程,js的执行,界面的渲染等都在这个进程中执行。
渲染进程
可以看到上图,渲染进程是多线程的:
- GUI线程:
- 只有一个,负责渲染页面,解析HTML、CSS构建render树,布局和绘制等
- 页面重绘或回流时,该线程都会执行
- GUI线程与js引擎线程是互斥的,当js引擎线程正在执行时,GUI线程则会被挂起,GUI的更新任务会被保存在一个队列中,等到js引擎线程执行完所有任务时,则会从队列中取出更新任务来执行。
- js引擎线程:
- 也称js内核,负责解析执行js脚本,例如:v8引擎。
- 在一个Tab页面中js引擎线程只有一个,也就是我们经常所说的js单线程。
- GUI线程与js引擎线程是互斥的,当js引擎线程正在执行时,GUI线程则会被挂起,GUI的更新任务会被保存在一个队列中,等到js引擎线程执行完所有任务时,则会从队列中取出更新任务来执行。
- 定时器线程:
- 也就是setTimeout和setInterval所在的线程
- 浏览器的定时器不是由js引擎来计数的,因为js引擎是单线程,如果在定时器前有大量任务在执行,那么需要等待前面所有任务执行完毕才能开始计时,就会导致无法准确的计时。
- 当计时完毕后,并不会执行定时器的回调事件,而是会将回调事件添加到事件循环的任务队列中,等待js引擎空闲的时候才会去任务队列中取出执行。
- 事件触发线程:
- 事件触发线程归属于浏览器,而不属于 JS 引擎,JS 引擎处理的事务过多,需要浏览器开线程来进行协助,主要负责控制事件循环
- JS 是采用事件驱动机制来响应用户操作的,事件线程是通过维护事件循环和事件队列等方式,来响应和处理事件。
- 当处理异步代码的时候,比如声明了一个点击事件,当用户点击的时候,则会将点击事件添加到事件队列的末端,等js引擎空闲了,则会从事件队列中取出执行。
- HTTP请求线程:
- 存在多个
- 通过创建XMLHttpRequest实例,开启一个新的线程进行请求。
- 检测到状态变更时,如果设置有回调函数,异步 HTTP 请求线程就会产生状态变更事件,将回调函数放入事件队列中,等待 JS 引擎空闲后执行。
js调用栈
在js中当一个函数执行时,会生成一个执行上下文,执行上下文中包含了函数的参数,变量等,javascript任何代码的运行都是在执行上下文中进行的,然后会将执行上下文压入调用栈中,又可以叫作执行上下文栈。当函数执行完毕,则又会将该函数的执行上下文从调用栈中删除。
js是一个单线程的语言,这意味着它只有一个调用栈,所以js一次只能做一件事。
举个例子:
function funA() {
console.log('A');
}
function funB() {
funA();
}
funB();
复制代码
执行这段代码会产生如下步骤:
注:执行上下文只要在函数被调用时才会产生,每次调用都会生成新的执行上下文。
- 第一步调用了funB,生成一个执行上下文,压入调用栈中。
- 开始执行funB,进入funB遇到了funA函数,那么则会生成funA的执行上下文压入调用栈中。
- 开始执行funA,进入funA遇到console.log函数,那么则会生成console.log的执行上下文压入栈中。
- 开始执行console.log,执行完毕后,将console.log的执行上下文从调用栈中删除。
- funA中没有代码可执行了,执行完毕,将funA的执行上下文从调用栈中删除。
- funB中没有代码可执行了,执行完毕,将funB的执行上下文从调用栈中删除。
调用栈中是否有执行上下文,代表着js引擎是否正在执行,也就是是否是空闲的状态。
事件循环(Event Loop)
上面我们说到,js是在js引擎线程上执行的,一次只能做一件事。如果有一些特别耗时的操作,例如网络请求,I/O等,那么则需要等待上一个任务完成才能做下一个任务,效率十分低,为了解决这个问题,于是就有了事件循环。
事件循环是异步事件执行的一种机制。不同的线程间可以通过一个公共的区域进行通信,这个区域就是事件队列,又叫做任务队列。
在执行js代码时,有的代码是同步执行的,有的则是异步执行的。
例如:
console.log('hello'); // 同步代码,执行到这段代码,控制台会打印出:hello
setTimeout(function() {
console.log('setTimeout');
}, 1000); // 异步代码,会在1秒后再控制台打印出: setTimeout
复制代码
那么遇到异步代码时,js是如何执行的呢?
当遇到异步代码时,会先将异步事件添加到事件队列中,当调用栈中所有事件都执行完成后,事件循环会从事件队列中取出事件,放入调用栈开始执行,这个过程是循环不断的,所以叫做事件循环。
事件循环在其中主要的两个作用:
- 监听调用栈,检查调用栈是否为空
- 当调用栈为空,从事件队列中取出事件放入调用栈中执行
定时器
关于定时器,其实定时器并不是异步,只是在调用定时器,如:setTimeout时,会调用定时器线程开始倒计时,当倒计时结束,定时器线程会将回调函数放入事件队列中,当调用栈为空时,才会执行定时器回调函数。如果在倒计时结束时,事件队列或者调用栈中还有很多任务没有执行,那么则会导致定时器回调函数不会立即执行,无法保证定时器计时的准确性。
宏任务(MacroTask)和微任务(MicroTask)
在异步代码中,又分为宏任务和微任务,因此事件循环中则又会存在相应任务的任务队列:宏任务队列和微任务队列。
宏任务可以理解为每次在执行栈中所执行的代码,在一个宏任务结束后,下一个宏任务开始执行前,浏览器会对页面进行渲染。宏任务包括:
- script(可以理解为同步代码)
- setTimeout/setInterval
- UI交互事件
- UI渲染
- I/O
- postMassage
- MassageChannel
- setImmediate
微任务可以理解为,当前宏任务执行完成后,在页面渲染前所执行的任务。微任务包括:
- Promise
- Object.observe
- MutaionObserver
- process.nextTick
宏任务与微任务的执行顺序:
- 首先执行同步代码,遇到微任务则将任务放入微任务队列中,本次事件循环遇到的微任务都会在这次循环中被执行,遇到宏任务的话,会添加到宏任务队列中,在下一次事件循环执行。
- 检查微任务队列中是否有任务,有的话会将微任务依次取出放入调用栈执行,直到微任务队列清空为止。
- 当微任务全部执行完毕,浏览器会检查是否有页面更新,有的话则会进行页面渲染。
- 渲染完成后,检查宏任务队列中是否有任务,有的话则取出一个放入调用栈中执行
- 当取出的宏任务执行完成后,则又从步骤2开始进行循环执行。
例子
下面,我们通过代码来验证上面所说的:
console.log('1===========');
// setTimeout1
setTimeout(function timeout1() {
console.log('2===========');
new Promise(function promiseTest(resolve) {
console.log('3===========');
resolve();
}).then(function promiseThenTest() {
console.log('4===========');
});
}, 0);
// setTimeout2
setTimeout(function timeout2() {
console.log('5===========');
}, 0);
new Promise(function promiseTest(resolve) {
console.log('6===========');
resolve();
}).then(function promiseThenTest() {
console.log('7===========');
});
console.log('8===========');
复制代码
执行结果为:
1===========
6===========
8===========
7===========
2===========
3===========
4===========
5===========
复制代码
我们来分析一下:
- 首先执行代码遇到
console.log('1===========');
,这段代码为同步代码,所以执行后控制台打印出:1===========
- 然后接着向下执行,遇到
setTimeout1
,setTimeout
是宏任务,放入宏任务队列 - 接着向下执行,遇到
setTimeout2
,也放入宏任务队列 - 继续向下执行,遇到
Promise
,Promise
实例化的回调函数为同步代码,所以执行后控制台打印出:6===========
,then
方法则会放入微任务队列中 - 最后遇到
console.log('8===========');
,这段代码为同步代码,所以执行后控制台打印出:8===========
。到这,同步代码执行完毕 - 然后开始从微任务队列中取出任务执行,也就是会执行
Promise.then
方法,控制台打印出:7===========
- 此时已经没有微任务了,浏览器会判断是否需要渲染界面,完成后,然后进行下一次的事件循环
- 从宏任务队列中取出
setTimeout1
,执行回调函数,遇到console.log('2===========');
,控制台打印:2===========
,然后遇到Promise
,Promise
实例化的回调函数为同步代码,所以执行后控制台打印出:3===========
,then
方法放入微任务队列中. - 此时,
setTimeout1
的回调函数执行完毕,也就是宏任务执行完毕,则又会去微任务队列中拿出任务执行,也就是上一步中setTimeout1
中的Promise.then
方法,控制台打印出:4===========
setTimeout1
中的Promise.then
方法执行完成后,微任务队列为空,浏览器并不需要渲染,则直接开启下一次事件循环。- 从宏任务队列中拿出
setTimeout2
,执行回调函数,控制台打印:5===========
注意:在一次事件循环中,如果遇到新的宏任务,那么它会在下一次事件循环才会执行,如上面例子中的setTimeout1
和setTimeout2
。
我们再举一个例子,来验证:在一次事件循环中,如果遇到新的宏任务,那么它会在下一次事件循环才会执行:
<div id="outer" style="width: 100px; height: 100px; background: yellow;">
<div id="inner" style="width: 100px; height: 100px; background: red;"/>
</div>
var inner = document.getElementById('inner');
var outer = document.getElementById('outer');
outer.onclick = function () {
console.log('outer=========');
setTimeout(() => {
console.log('outer=========setTimeout');
}, 0);
new Promise(function outerPromise(resolve) {
console.log('outerPromise===========');
resolve();
}).then(function promiseThenTest() {
console.log('outerPromise===========then');
});
}
inner.onclick = function () {
console.log('1===========');
setTimeout(function timeout2() {
console.log('2===========');
}, 0);
new Promise(function promiseTest(resolve) {
console.log('3===========');
resolve();
}).then(function promiseThenTest() {
console.log('4===========');
});
console.log('5===========');
};
复制代码
这是一个事件冒泡的例子,最后的结果是:
1===========
3===========
5===========
4===========
outer=========
outerPromise===========
outerPromise===========then
2===========
outer=========setTimeout
复制代码
我们来详细的分析一下:
- 在点击inner元素后,首先进行了事件冒泡,事件触发线程依次将点击事件添加至宏任务队列中。
- 然后取出inner元素的点击事件执行,开始执行,然后遇到setTimeout(宏任务)和Promise(微任务),放入各自的任务队列,执行同步代码并打印出:1,3,5,点击事件执行完毕后(宏任务),然后执行微任务,打印出:4。
- 此时微任务清空,浏览器也不需要渲染,则进行下一次事件循环。
- 从宏任务队列中取出outer的点击事件执行,开始执行,然后遇到setTimeout(宏任务)和Promise(微任务),放入各自的任务队列,执行同步代码并打印出:
outer=========,outerPromise===========
,点击事件执行完毕后(宏任务),然后执行微任务,打印出:outerPromise===========then
。 - 此时微任务清空,浏览器也不需要渲染,则进行下一次事件循环。
- 根据上面的顺序,最前面的宏任务,是inner点击事件中的setTimeout,取出并执行,打印出:2
- 宏任务执行完毕,检查微任务队列中是否有任务,现在并没有任务,浏览器也不需要渲染,则进行下一次事件循环。
- 从宏任务队列中取出outer点击事件中的setTimeout执行,打印出:
outer=========setTimeout