Async 专题 01 —— 事件循环
也许我们都听说过JavaScript是事件驱动的这种说法。各种异步任务通过事件的形式和主线程通信,保证网页流畅的用户体验。而异步可以说是JavaScript最伟大的特性之一(也许没有之一)。
现在我们就从Chrome浏览器的主要进程入手,深入的理解这个机制是如何运行的。
Chrome浏览器的主要进程
我们看一下Chrome浏览器都有哪些主要进程。
Browser进程。这是浏览器的主进程。
第三方插件进程。
GPU进程。
Renderer进程。
大家都说Chrome浏览器是内存怪兽,因为它的每一个页面都是一个Renderer进程,其实这种说法是不对的。实际上,Chrome支持好几种进程模型。
Process-per-site-instance
。每打开一个网站,然后从这个网站链开的一系列网站都属于一个进程。这也是Chrome的默认进程模型。Process-per-site
。同域名范畴的网站属于一个进程。Process-per-tab
。每一个页面都是一个独立的进程。这就是外界盛传的进程模型。Single Process
。传统浏览器的单进程模型。
浏览器内核
现在我们知道,除了相关联的页面可能会合并为一个进程外,我们可以简单的认为每个页面都会开启一个新的Renderer进程。那么这个进程里跑的程序又是什么呢?就是我们常常说的浏览器内核,或者说渲染引擎。确切的说,是浏览器内核的一个实例。Chrome浏览器的渲染引擎叫Blink
。
由于浏览器主要是用来浏览网页的,所以虽然Browser进程是浏览器的主进程,但它充当的只是一个管家的角色,真正的一线业务大拿还得看Renderer进程。这也是跑在Renderer进程里的程序被称为浏览器内核(实例)的原因。
介绍Chrome浏览器的进程系统只是为了引出Renderer进程,接下来我们只需要关注浏览器内核与Renderer进程就可以了。
Renderer进程的主要线程
Renderer进程手下又有好多线程,它们各司其职。
GUI渲染线程。
JavaScript引擎线程。对于Chrome浏览器而言,这个线程上跑的就是威震海内的V8引擎。
事件触发线程。
定时器线程。
异步HTTP请求线程。
调用栈
进入主题之前,我们先引入调用栈(call stack)的概念,调用栈是JavaScript引擎执行程序的一种机制。为什么要有调用栈呢?我们举个例子。
const str = 'biu';
console.log('1');
function a() {
console.log('2');
b();
console.log('3');
}
function b() {
console.log('4');
}
a();
我们都知道打印的顺序是1 2 4 3
。
问题在于,当执行到b函数
的时候,我需要记住b函数
的调用位置信息,也就是执行上下文。否则执行完b函数
之后,引擎可能就忘了执行console.log('3')
了。调用栈就是用来干这个的,每调用一层函数,引擎就会生成它的栈帧,栈帧里保存了执行上下文,然后将它压入调用栈中。栈是一个后进先出的结构,直到最里层的函数调用完,引擎才开始将最后进入的栈帧从栈中弹出。
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|
- | - | - | - | console.log('4') | - | - | - |
- | - | console.log('2') | b() | b() | b() | console.log('3') | - |
console.log('1') | a() | a() | a() | a() | a() | a() | a() |
可以看到,当有嵌套函数调用的时候,栈帧会经历逐渐叠加又逐渐消失的过程,这就是所谓的后进先出。
同时也要注意,诸如const str = 'biu'
的变量声明是不会入栈的。
调用栈也要占用内存,所以如果调用栈过深,浏览器会报Uncaught RangeError: Maximum call stack size exceeded
错误。
webAPI
现在我们进入主题。
JavaScript引擎将代码从头执行到尾,不断的进行压栈和出栈操作。除了ECMAScript语法组成的代码之外,我们还会写哪些代码呢?不错,还有JavaScript运行时给我们提供的各种webAPI。运行时(runtime)简单讲就是JavaScript运行所在的环境。
我们重点讨论三种webAPI。
const url = 'https://api.github.com/users/veedrin/repos';
fetch(url).then(res => res.json()).then(console.log);
const url = 'https://api.github.com/users/veedrin/repos';
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = () => {
if (xhr.status === 200) {
console.log(xhr.response);
}
}
xhr.send();
发起异步的HTTP请求,这几乎是一个网页必要的模块。我们知道HTTP请求的速度和结果取决于当前网络环境和服务器的状态,JavaScript引擎无法原地等待,所以浏览器得另开一个线程来处理HTTP请求,这就是之前提到的异步HTTP请求线程
。
const timeoutId = setTimeout(() => {
console.log(Date.now());
clearTimeout(timeoutId);
}, 5000);
const intervalId = setInterval(() => {
console.log(Date.now());
}, 1000);
const immediateId = setImmediate(() => {
console.log(Date.now());
clearImmediate(immediateId);
});
定时器也是一个棘手的问题。首先,JavaScript引擎同样无法原地等待;其次,即便不等待,JavaScript引擎也得执行后面的代码,根本无暇给定时器定时。所以于情于理,都得为定时器单独开一个线程,这就是之前提到的定时器线程
。
const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);
按道理来讲,DOM事件没什么异步动作,直接绑定就行了,不会影响后面代码的执行。
别急,我们来看一个例子。
const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);
const timeoutId = setTimeout(() => {
for (let i = 0; i < 10000; i++) {
console.log('biu');
}
clearTimeout(timeoutId);
}, 5000);
运行代码,先绑定DOM事件,大约5秒钟后开启一个循环。注意,如果在循环结束之前点击按钮,浏览器控制台会打印什么呢?
结果是先打印10000个biu
,接着会打印Event
对象。
试想一下,你点击按钮的时候,JavaScript引擎还在处理该死的循环,根本没空理你。那为什么点击事件能够被响应呢(虽然有延时)?肯定是有另外一个线程在监听DOM事件。这就是之前提到的事件触发线程
。
任务队列
好的,现在我们知道有几类webAPI是单独的线程在处理。但是,处理完之后的回调总归是要由JavaScript引擎线程
来执行的吧?这些线程是如何与JavaScript引擎线程
通信的呢?
这就要提到大名鼎鼎的任务队列(Task Queue)。
其实无论是HTTP请求还是定时器还是DOM事件,我们都可以统称它们为事件。很好,各自的线程把各自的webAPI处理完,完成之后怎么办呢?它要把相应的回调函数放入一个叫做任务队列的数据结构里。队列和栈不一样,队列是先进先出的,讲究一个先来后到的顺序。
有很多文章认为
任务队列
是由JavaScript引擎线程
维护的,也有很多文章认为任务队列
是由事件触发线程
维护的。根据上文的描述,
事件触发线程
是专门用来处理DOM事件的。然后我们来论证,为什么
任务队列
不是由JavaScript引擎线程
维护的。假如JavaScript引擎线程
在执行代码的同时,其他线程要给任务队列添加事件,这时候它哪忙得过来呢?所以根据我的理解,任务队列应该是由一个专门的线程维护的。我们就叫它
任务队列线程
吧。
事件循环
等JavaScript引擎线程
把所有的代码执行完了一遍,现在它可以歇着了吗?也许吧,接下来它还有一个任务,就是不停的去轮询任务队列,如果任务队列是空的,它就可以歇一会,如果任务队列中有回调,它就要立即执行这些回调。
这个过程会一直进行,它就是事件循环(Event Loop)。
我们总结一下这个过程:
- 第一阶段,
JavaScript引擎线程
从头到尾把脚本代码执行一遍,碰到需要其他线程处理的代码则交给其他线程处理。 - 第二阶段,
JavaScript引擎线程
专注于处理事件。它会不断的去轮询任务队列,执行任务队列中的事件。这个过程又可以分解为轮询任务队列-执行任务队列中的事件-更新页面视图
的无限往复。对,别忘了更新页面视图(如果需要的话),虽然更新页面视图是GUI渲染线程
处理的。
这些事件,在任务队列里面也被称为任务。但是事情没这么简单,任务还分优先级,这就是我们常听说的宏任务和微任务。
宏任务
既然任务分为宏任务和微任务,那是不是得有两个任务队列呢?
此言差矣。
首先我们得知道,事件循环可不止一个。除了window event loop之外,还有worker event loop。并且同源的页面会共享一个window event loop。
A window event loop is the event loop used by similar-origin window agents. User agents may share an event loop across similar-origin window agents.
其次我们要区分任务和任务源。什么叫任务源呢?就是这个任务是从哪里来的。是从addEventListener
来的呢,还是从setTimeout
来的。为什么要这么区分呢?比如键盘和鼠标事件,就要把它的响应优先级提高,以便尽可能的提高网页浏览的用户体验。虽然都是任务,命可分贵贱呢!
所以不同任务源的任务会放入不同的任务队列里,浏览器根据自己的算法来决定先取哪个队列里的任务。
总结起来,宏任务有至少一个任务队列,微任务只有一个任务队列。
微任务
哪些异步事件是微任务?Promise的回调、MutationObserver的回调以及nodejs中process.nextTick的回调。
<div id="outer">
<div id="inner">请点击</div>
</div>
const $outer = document.getElementById('outer');
const $inner = document.getElementById('inner');
new MutationObserver(() => {
console.log('mutate');
}).observe($inner, {
childList: true,
});
function onClick() {
console.log('click');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
$inner.innerHTML = '已点击';
}
$inner.addEventListener('click', onClick);
$outer.addEventListener('click', onClick);
我们先来看执行顺序。
click
promise
mutate
click
promise
mutate
timeout
timeout
整个执行过程是怎样的呢?
- 从头到尾初始执行脚本代码。给DOM元素添加事件监听。
- 用户触发内元素的DOM事件,同时冒泡触发外元素的DOM事件。将内元素和外元素的DOM事件回调添加到宏任务队列中。
- 因为此时调用栈中是空闲的,所以将内元素的DOM事件回调放入调用栈。
- 执行回调,此时打印
click
。同时将setTimeout的回调放入宏任务队列,将Promise的回调放入微任务队列。因为修改了DOM元素,触发MutationObserver事件,将MutationObserver的回调放入微任务队列。回顾一下,现在宏任务队列里有两个回调,分别是外元素的DOM事件回调
和setTimeout的回调
;微任务队列里也有两个回调,分别是Promise的回调
和MutationObserver的回调
。 - 依次将微任务队列中的回调放入调用栈,此时打印
promise
和mutate
。 - 将外元素的DOM事件回调放入调用栈。执行回调,此时打印
click
。因为两个DOM事件回调是一样的,过程不再重复。再次回顾一下,现在宏任务队列里有两个回调,分别是两个setTimeout的回调
;微任务队列里也有两个回调,分别是Promise的回调
和MutationObserver的回调
。 - 依次将微任务队列中的回调放入调用栈,此时打印
promise
和mutate
。 - 最后依次将setTimeout的回调放入调用栈执行,此时打印两次
timeout
。
规律是什么呢?宏任务与宏任务之间,积压的所有微任务会一次性执行完毕。这就好比超市排队结账,轮到你结账的时候,你突然想顺手买一盒冈本。难道超市会要求你先把之前的账结完,然后重新排队吗?不会,超市会顺便帮你把冈本的账也结了。这样效率更高不是么?虽然不知道内部的处理细节,但是我觉得标准区分两种任务类型也是出于性能的考虑吧。
$inner.click();
如果DOM事件不是用户触发的,而是程序触发的,会有什么不一样吗?
click
click
promise
mutate
promise
timeout
timeout
严格的说,这时候并没有触发事件,而是直接执行onClick
函数。翻译一下就是下面这样的效果。
onClick();
onClick();
这样就解释了为什么会先打印两次click
。而MutationObserver会合并多个事件,所以只打印一次mutate
。所有微任务依然会在下一个宏任务之前执行,所以最后才打印两次timeout
。
更新页面视图
我们再来看一个例子。
const $btn = document.getElementById('btn');
function onClick() {
setTimeout(() => {
new Promise(resolve => resolve('promise 1')).then(console.log);
new Promise(resolve => resolve('promise 2')).then(console.log);
console.log('timeout 1');
$btn.style.color = '#f00';
}, 1000);
setTimeout(() => {
new Promise(resolve => resolve('promise 1')).then(console.log);
new Promise(resolve => resolve('promise 2')).then(console.log);
console.log('timeout 2');
}, 1000);
setTimeout(() => {
new Promise(resolve => resolve('promise 1')).then(console.log);
new Promise(resolve => resolve('promise 2')).then(console.log);
console.log('timeout 3');
}, 1000);
setTimeout(() => {
new Promise(resolve => resolve('promise 1')).then(console.log);
new Promise(resolve => resolve('promise 2')).then(console.log);
console.log('timeout 4');
// alert(1);
}, 1000);
setTimeout(() => {
new Promise(resolve => resolve('promise 1')).then(console.log);
new Promise(resolve => resolve('promise 2')).then(console.log);
console.log('timeout 5');
// alert(1);
}, 1000);
setTimeout(() => {
new Promise(resolve => resolve('promise 1')).then(console.log);
new Promise(resolve => resolve('promise 2')).then(console.log);
console.log('timeout 6');
}, 1000);
new MutationObserver(() => {
console.log('mutate');
}).observe($btn, {
attributes: true,
});
}
$btn.addEventListener('click', onClick);
当我在第4个setTimeout添加alert,浏览器被阻断时,样式还没有生效。
有很多人说,每一个宏任务执行完并附带执行完累计的微任务(我们称它为一个宏任务周期),这时会有一个更新页面视图的窗口期,给更新页面视图预留一段时间。
但是我们的例子也看到了,每一个setTimeout都是一个宏任务,浏览器被阻断时事件循环都好几轮了,但样式依然没有生效。可见这种说法是不准确的。
而当我在第5个setTimeout添加alert,浏览器被阻断时,有很大的概率(并不是一定)样式会生效。这说明什么时候更新页面视图是由浏览器决定的,并没有一个准确的时机。
总结
JavaScript引擎首先从头到尾初始执行脚本代码,不必多言。
如果初始执行完毕后有微任务,则执行微任务(为什么这里不属于事件循环?后面会讲到)。
之后就是不断的事件循环。
首先到宏任务队列里找宏任务,宏任务队列又分好多种,浏览器自己决定优先级。
被放入调用栈的某个宏任务,如果它的代码中又包含微任务,则执行所有微任务。
更新页面视图没有一个准确的时机,是每个宏任务周期后更新还是几个宏任务周期后更新,由浏览器决定。
也有一种说法认为:从头到尾初始执行脚本代码也是一个任务。
如果我们认可这种说法,则整个代码执行过程都属于事件循环。
初始执行就是一个宏任务,这个宏任务里面如果有微任务,则执行所有微任务。
浏览器自己决定更新页面视图的时机。
不断的往复这个过程,只不过之后的宏任务是事件回调。
第二种解释好像更说得通。因为第一种解释会有一段微任务的执行不在事件循环里,这显然是不对的。