异步的思考
event loops
隐藏得比较深,很多人对它很陌生。但提起异步,相信每个人都知道。异步背后的“靠山”就是event loops
。这里的异步准确的说应该叫浏览器的event loops
或者说是javaScript
运行环境的event loops
,因为ECMAScript中没有event loops
,event loops
是在HTML Standard定义的。
event loops
规范中定义了浏览器何时进行渲染更新,了解它有助于性能优化。
思考下边的代码运行顺序:
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(function () {
console.log('promise1');
})
.then(function () {
console.log('promise2');
});
console.log('script end');
上面的顺序是在chrome
运行得出的,有趣的是在safari 9.1.2
中测试,promise1 promise2
会在setTimeout
的后边,而在safari 10.0.1
中得到了和chrome
一样的结果。为何浏览器有不同的表现,了解tasks, microtasks
队列就可以解答这个问题。
很多框架和库都会使用类似下面函数:
function flush() {
...
}
function useMutationObserver() {
var iterations = 0;
var observer = new MutationObserver(flush);
var node = document.createTextNode('');
observer.observe(node, { characterData: true });
return function () {
node.data = iterations = ++iterations % 2;
};
}
初次看这个useMutationObserver
函数总会很有疑惑,MutationObserver
不是用来观察dom
的变化的吗,这样凭空造出一个节点来反复修改它的内容,来触发观察的回调函数有何意义?
答案就是使用Mutation事件
可以异步执行操作(例子中的flush函数
),一是可以尽快响应变化,二是可以去除重复的计算。但是setTimeout(flush, 0)
同样也可以执行异步操作,要知道其中的差异和选择哪种异步方法,就得了解event loop
。
定义
先看看它们在规范中的定义。
Note:本文的引用部分,就是对规范的翻译,有的部分会概括或者省略的翻译,有误请指正。
事件循环
event loop
翻译出来就是事件循环,可以理解为实现异步的一种方式,我们来看看event loop在HTML Standard中的定义章节:
第一句话:
为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的
event loop
。
事件,用户交互,脚本,渲染,网络这些都是我们所熟悉的东西,他们都是由event loop
协调的。触发一个click
事件,进行一次ajax
请求,背后都有event loop
在运作。
Task queues are sets , not queues , because step one of the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.
任务队列是集合,而不是队列,因为事件循环处理模型的第一步从选定的队列中获取第一个可运行任务,而不是使第一个任务出队。
task
一个event loop有一个或者多个task队列。当用户代理安排一个任务,必须将该任务增加到相应的event loop的一个tsak队列中。
每一个task都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个task队列,其他事件又是一个单独的队列。可以为鼠标、键盘事件分配更多的时间,保证交互的流畅。
task
也被称为macrotask
,task
队列还是比较好理解的,就是一个先进先出的队列,由指定的任务源去提供任务。
哪些是task任务源呢?
规范在Generic task sources中有提及:
DOM操作任务源: 此任务源被用来相应dom操作,例如一个元素以非阻塞的方式 插入文档。用户交互任务源: 此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用task队列。
网络任务源: 网络任务源被用来响应网络活动。
history traversal任务源: 当调用history.back()等类似的api时,将任务插进task队列。
task
任务源非常宽泛,比如ajax
的onload
,click
事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB )
,需要注意的是setTimeout
、setInterval
、setImmediate
也是task
任务源。总结来说task
任务源:
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
HTML parser是一个典型的task
用chrome
的Developer tools
的Timeline
查看各部分运行的时间点。 当我们点击这个div
的时候,下图截取了部分时间线,黄色部分是脚本运行,紫色部分是更新render树、计算布局,绿色部分是绘制。
绿色和紫色部分可以认为是Update the rendering
。
在这一轮事件循环中,setTimeout1
是作为task
运行的,可以看到paint
确实是在task
运行完后才进行的。
例子2
现在换成一个microtask
任务,看看有什么变化
this is con
和上一个例子很像,不同的是这一轮事件循环的task
是click
的回调函数,Promise1
则是microtask
,paint
同样是在他们之后完成。
标准就是那么定义的,答案似乎显而易见,我们把例子变得稍微复杂一些。
例子3
this is con
当点击后,一共产生3个task
,分别是click1、setTimeout1、setTimeout2
,所以会分别在3次event loop
中进行。 下面截取的是setTimeout1、setTimeout2
的部分。
我们修改了两次textContent
,奇怪的是setTimeout1、setTimeout2
之间没有paint
,浏览器只绘制了textContent=1
,难道setTimeout1、setTimeout2
在同一次event loop
中吗?
例子4
在两个setTimeout
中增加microtask
。
this is con
从run microtasks
中可以看出来,setTimeout1、setTimeout2
应该运行在两次event loop
中,textContent = 0
的修改被跳过了。
setTimeout1、setTimeout2
的运行间隔很短,在setTimeout1
完成之后,setTimeout2
马上就开始执行了,我们知道浏览器会尽量保持每秒60帧的刷新频率(大约16.7ms每帧),是不是只有两次event loop
间隔大于16.7ms才会进行绘制呢?
例子5
将时间间隔加大一些。
this is con
两块黄色的区域就是 setTimeout
,在1224ms处绿色部分,浏览器对con.textContent = 0
的变动进行了绘制。在1234ms处绿色部分,绘制了con.textContent = 1
。
可否认为相邻的两次event loop
的间隔很短,浏览器就不会去更新渲染了呢?继续我们的实验
例子6
我们在同一时间执行多个setTimeout
来模拟执行间隔很短的task。
this is con
图中一共绘制了两帧,第一帧4.4ms,第二帧9.3ms,都远远高于每秒60HZ(16.7ms)的频率,第一帧绘制的是con.textContent = 4
,第二帧绘制的是 con.textContent = 6
。所以两次event loop
的间隔很短同样会进行绘制。
例子7
有说法是一轮event loop
执行的microtask
有数量限制(可能是1000),多余的microtask
会放到下一轮执行。下面例子将microtask
的数量增加到25000。
this is con
可以看到一大块黄色区域,上半部分有一根绿线就是点击后的第一次绘制,脚本的运行耗费大量的时间,并且阻塞了渲染。
看看setTimeout2
的运行情况。 [](https://github.com/aooy/aooy....setTimeout2
这轮event loop
没有run microtasks,microtasks
在setTimeout1
被全部执行完了。
25000个microtasks
不能说明event loop
对microtasks
数量没有限制,有可能这个限制数很高,远超25000,但日常使用基本不会使用那么多了。
对microtasks增加数量限制,一个很大的作用是防止脚本运行时间过长,阻塞渲染。
例子8
使用requestAnimationFrame
。
this is con
总体的Timeline
: 点击后绘制了3帧,把每次变动都绘制了。
看看单个requestAnimationFrame
的Timeline
:
和setTimeout
很相似,可以看出requestAnimationFrame
也是一个task
,在它完成之后会运行run microtasks
。
例子9
验证postMessage
是否是task
setTimeout(function setTimeout1(){
console.log('setTimeout1')
}, 0)
var channel = new MessageChannel();
channel.port1.onmessage = function onmessage1 (){
console.log('postMessage')
Promise.resolve().then(function promise1 (){
console.log('promise1')
})
};
channel.port2.postMessage(0);
setTimeout(function setTimeout2(){
console.log('setTimeout2')
}, 0)
console.log('sync')
执行顺序:
sync
postMessage
promise1
setTimeout1
setTimeout2
timelime:
第一个黄块是onmessage1
,第二个是setTimeout1
,第三个是setTimeout2
。显而易见,postMessage
属于task
,因为setTimeout
的4ms标准化了,所以这里的postMessage
会优先setTimeout
运行。
小结
上边的例子可以得出一些结论:
- 在一轮
event loop
中多次修改同一dom
,只有最后一次会进行绘制。 - 渲染更新(
Update the rendering
)会在event loop
中的tasks
和microtasks
完成后进行,但并不是每轮event loop
都会更新渲染,这取决于是否修改了dom
和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处dom
,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。 - 如果希望在每轮
event loop
都即时呈现变动,可以使用requestAnimationFrame
。
应用
event loop
的大致循环过程,可以用下边的图表示:
假设现在执行到currently running task
,我们对批量的dom
进行异步修改,我们将此任务插进task
:
可以看到如果task
队列如果有大量的任务等待执行时,将dom
的变动作为microtasks
而不是task
能更快的将变化呈现给用户。
同步简简单单就可以完成了,为啥要异步去做这些事?
对于一些简单的场景,同步完全可以胜任,如果得对dom
反复修改或者进行大量计算时,使用异步可以作为缓冲,优化性能。
举个小例子:
现在有一个简单的元素,用它展示我们的计算结果:
this is result
有一个计算平方的函数,并且会将结果响应到对应的元素
function bar (num, id) {
var product = num * num;
var resultEle = document.getElementById( id );
resultEle.textContent = product;
}
现在我们制造些问题,假设现在很多同步函数引用了bar
,在一轮event loop
里,可能bar
会被调用多次,并且其中有几个是对id='result'
的元素进行操作。就像下边一样:
...
bar( 2, 'result' )
...
bar( 4, 'result' )
...
bar( 5, 'result' )
...
似乎这样的问题也不大,但是当计算变得复杂,操作很多dom
的时候,这个问题就不容忽视了。
用我们上边讲的event loop
知识,修改一下bar
。
var store = {}, flag = false;
function bar (num, id) {
store[ id ] = num;
if(!flag){
Promise.resolve().then(function () {
for( var k in store ){
var num = store[k];
var product = num * num;
var resultEle = document.getElementById( k );
resultEle.textContent = product;
}
});
flag = true;
}
}
现在我们用一个store
去存储参数,统一在microtasks
阶段执行,过滤了多余的计算,即使同步过程中多次对一个元素修改,也只会响应最后一次。
写了个简单插件asyncHelper,可以帮助我们异步的插入task
和microtask
。
例如:
//生成task
var myTask = asyncHelper.task(function () {
console.log('this is task')
});
//生成microtask
var myMicrotask = asyncHelper.mtask(function () {
console.log('this is microtask')
});
//插入task
myTask()
//插入microtask
myMicrotask();
对之前的例子的使用asyncHelper:
var store = {};
//生成一个microtask
var foo = asyncHelper.mtask(function () {
for( var k in store ){
var num = store[k];
var product = num * num;
var resultEle = document.getElementById( k );
resultEle.textContent = product;
}
}, {callMode: 'last'});
function bar (num, id) {
store[ id ] = num;
foo();
}
如果不支持microtask
将回退成task
。
参考
https://github.com/aooy/blog/issues/5
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
https://html.spec.whatwg.org/multipage/webappapis.html#event-loop