了解JS中的Event Loop这篇挺足的
序
在开始Event Loop之前,我们先来介绍下JS线程方面。
JS是一门单线程语言,在最新的 HTML5 中提出了Web-Worker,但JS是单线程这一核心仍未改变。所以一切Javascript版的"多线程"都是用单线程模拟出来的,一切JS多线程都是纸老虎!
也就是说只有一个主线程,主线程有一个栈,每一个函数执行的时候,都会生成新的execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。
故JS任务也得一个一个顺序执行,如果遇到耗时比较久,那往后的任务是不是要一直等待下去?在此,我们将任务分为:同步任务、异步任务。
同步与异步任务
主线程类似一个加工厂,它只有一条流水线,待执行的任务就是流水线上的原料,只有前一个加工完,后一个才能进行。Event Loops就是把原料放上流水线的工人。 只要已经放在流水线上的,它们会被依次处理,称为同步任务。一些待处理的原料,工人会按照它们的种类排序,在适当的时机放上流水线,这些称为异步任务。
导图的意图:
任务分为同步或异步,两者进入不同的环境。同步进入主线程,异步进入Event Table并注册函数。
当指定的事件完成时,Event Table会将这个函数移入Event Queue。
主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
上述过程会不断重复,也就是常说的Event Loop(事件循环)。
示例代码:
$.ajax({
...
success:() => {
console.log('b!');
}
})
console.log('a');
ajax进入Event Table,注册回调函数success。
执行`console.log('代码执行结束')``。
ajax事件完成,回调函数success进入Event Queue。
主线程从Event Queue读取回调函数 success 并执行。
上例只是简单的介绍事件循环,我们来看看更加详细的介绍。
Event Loop 的处理过程
event loop翻译出来就是事件循环,可以理解为实现异步的一种方式。
事件,用户交互,脚本,渲染,网络这些都是我们所熟悉的东西,他们都是由event loop协调的。触发一个click事件,进行一次ajax请求,背后都有event loop在运作。
(task 另外一个名称为:macrotask)
在规范的Processing model定义了event loop的循环过程: 一个 event loop 只要存在,就会不断执行下边的步骤:
1.在tasks队列中选择最老的一个task,如果没有任务,则跳到下边的microtasks步骤。
2.将上边选择的task设置为正在运行的task。
3.Run: 运行被选择的task。
4.将event loop的currently running task变为null。
5.从task队列里移除前边运行的task。
6.Microtasks: 执行microtasks任务检查点。(也就是执行microtasks队列里的任务)
7.更新渲染(Update the rendering)...
8.如果这是一个worker event loop,但是没有任务在task队列中,并且WorkerGlobalScope对象的closing标识为true,则销毁event loop,中止这些步骤,然后进行定义在Web workers章节的run a worker。
9.返回到第一步。
event loop会不断循环的去取tasks队列的中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务。
执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图)。
microtasks 检查点
event loop运行的第 6 步,执行了一个microtask checkpoint,看看规范如何描述microtask checkpoint: 当用户代理去执行一个 microtask checkpoint,如果 microtask checkpoint 的 flag(标识)为 false,用户代理必须运行下面的步骤:
1.将microtask checkpoint的flag设为true。
2.Microtask queue handling: 如果event loop的microtask队列为空,直接跳到第八步(Done)。
3.在microtask队列中选择最老的一个任务。
4.将上一步选择的任务设为event loop的currently running task。
5.运行选择的任务。
6.将event loop的currently running task变为null。
7.将前面运行的microtask从microtask队列中删除,然后返回到第二步(Microtask queue handling)。
8.Done: 每一个environment settings object它们的 responsible event loop就是当前的event loop,会给environment settings object发一个rejected promises 的通知。
9.清理IndexedDB的事务。
10.将microtask checkpoint的flag设为flase。
microtask checkpoint所做的就是执行microtask队列里的任务。什么时候会调用microtask checkpoint呢?
当上下文执行栈为空时,执行一个microtask checkpoint。 在event loop的第六步(Microtasks: Perform a microtask checkpoint)执行checkpoint,也就是在运行task之后,更新渲染之前。
macro-task(宏任务):
setTimeout
setInterval
setImmediate
I/O
UI rendering
micro-task(微任务):
process.nextTick
promises
Object.observe
MutationObserver
我们来看看示例代码:
Promise.resolve().then(function promise1 () {
console.log('promise1');
})
setTimeout(function setTimeout1 (){
console.log('setTimeout1')
Promise.resolve().then(function promise2 () {
console.log('promise2');
})
}, 0)
setTimeout(function setTimeout2 (){
console.log('setTimeout2')
}, 0)
运行过程: script里的代码被列为一个task,放入task队列。
循环 1:
macrotask
microtask
script
1.从macrotask队列中取出script任务,推入栈中执行。
2.promise1列为microtask,setTimeout1列为task,setTimeout2列为macrotask。
macrotask
microtask
setTimeout1 setTimeout2
promise1
3.script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise1执行。
循环 2:
macrotask
microtask
setTimeout1 setTimeout2
4.从macrotask队列中取出setTimeout1,推入栈中执行,将promise2列为microtask。
macrotask
microtask
setTimeout2
promise2
5.执行microtask checkpoint取出microtask列的promise2行。
循环 3
macrotask
microtask
setTimeout2
6.acrotask列中取出setTimeout2推入栈中执行。
7.Timeout2务执行完毕,执行microtask checkpoint。
macrotask
microtask
每次执行完microtask,视图就会更新吗?在我们日常开发中,好像并不是,让我们看看具体原因吧。
event loop 中的 Update the rendering(更新渲染)
这是Event Loop中很重要部分,在第 7 步会进行Update the rendering(更新渲染),规范允许浏览器自己选择是否更新视图。也就是说可能不是每轮事件循环都去更新视图,只在有必要的时候才更新视图。
这篇文章较详细的讲解了渲染机制。
验证更新渲染(Update the rendering)的时机
不同机子测试可能会得到不同的结果,这取决于浏览器,CPU、GPU性能以及它们当时的状态。
例 1:
<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function click1() {
setTimeout(function setTimeout1() {
con.textContent = 0;
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1;
}, 0)
};
</script>
当点击后,一共产生 3 个macrotask,分别是click1、setTimeout1、setTimeout2,所以会分别在 3 次event loop中进行。
当点击后,一共产生 3 个macrotask,分别是click1、setTimeout1、setTimeout2,所以会分别在 3 次event loop中进行。
我们修改了两次textContent,奇怪的是setTimeout1、setTimeout2之间没有paint,浏览器只绘制了textContent=1,难道setTimeout1、setTimeout2在同一次event loop中吗?
例 2:
<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = 0;
Promise.resolve().then(function Promise1 () {
console.log('Promise1')
})
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1;
Promise.resolve().then(function Promise2 () {
console.log('Promise2')
})
}, 0)
};
</script>
从run microtasks中可以看出来,setTimeout1、setTimeout2应该运行在两次event loop中,textContent = 0的修改被跳过了。
setTimeout1、setTimeout2的运行间隔很短,在setTimeout1完成之后,setTimeout2马上就开始执行了,我们知道浏览器会尽量保持每秒 60 帧的刷新频率(大约 16.7ms 每帧),是不是只有两次event loop间隔大于 16.7ms 才会进行绘制呢?
例 3:
var con = document.getElementById('con');
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = 0;
}, 0);
setTimeout(function setTimeout2() {
con.textContent = 1;
}, 16.7);
};
两块黄色的区域就是 setTimeout,在 1342ms 处绿色部分,浏览器对 con.textContent = 0 的变动进行了绘制。在 1357ms 处绿色部分,绘制了 con.textContent = 1。
可否认为相邻的两次 event loop 的间隔很短,浏览器就不会去更新渲染了呢?继续我们的实验
例 4: 我们在同一时间执行多个setTimeout来模拟执行间隔很短的task。
<script>
var con = document.getElementById('con');
con.onclick = function () {
setTimeout(function(){
con.textContent = 0;
},0)
setTimeout(function(){
con.textContent = 1;
},0)
setTimeout(function(){
con.textContent = 2;
},0)
setTimeout(function(){
con.textContent = 3;
},0)
setTimeout(function(){
con.textContent = 4;
},0)
setTimeout(function(){
con.textContent = 5;
},0)
setTimeout(function(){
con.textContent = 6;
},0)
};
</script>
图中一共绘制了两帧,第一帧 910ms,第二帧 920ms,都远远高于每秒 60HZ(16.7ms)的频率,所以两次 event loop 的间隔很短同样会进行绘制。
例 5:
有说法是一轮event loop执行的microtask有数量限制(可能是 1000),多余的microtask会放到下一轮执行。下面例子将microtask的数量增加到 25000。
<script>
var con = document.getElementById('con');
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = 'task1';
for(var i = 0; i < 250000; i++){
Promise.resolve().then(function(){
con.textContent = i;
});
}
}, 0);
setTimeout(function setTimeout2() {
con.textContent = 'task2';
}, 0);
};
</script>
可以看到一大块黄色区域,上半部分有一根绿线就是点击后的第一次绘制,脚本的运行耗费大量的时间,并且阻塞了渲染。
看看setTimeout2的运行情况。
可以看到setTimeout2这轮event loop没有run microtasks,microtasks在setTimeout1被全部执行完了。
25000 个microtasks不能说明event loop对microtasks数量没有限制,有可能这个限制数很高,远超 25000,但日常使用基本不会使用那么多了。
对 microtasks 增加数量限制,一个很大的作用是防止脚本运行时间过长,阻塞渲染。
例 6:
<script>
var con = document.getElementById('con');
var i = 0;
var raf = function(){
requestAnimationFrame(function() {
con.textContent = i;
Promise.resolve().then(function(){
i++;
if(i < 30) raf();
});
});
}
con.onclick = function () {
raf();
};
</script>
总体的Timeline:
看看单个 requestAnimationFrame的Timeline:
和setTimeout很相似,可以看出requestAnimationFrame也是一个task,在它完成之后会运行run microtasks。
小结 上边的例子可以得出一些结论:
在一轮event loop 中多次修改同一dom,只有最后一次会进行绘制。
渲染更新(Update the rendering)会在event loop中的tasks和microtasks完成后进行,但并不是每轮event loop都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms 只是估算并不准确)修改了多处dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame。
作者:前端安然
来源:掘金