EventLoop实践
我们用工具去测量一下,实际运行时是什么情况。
准备工作
- 目标:测试
setTimeout、Promise.then为代表的异步任务对渲染和计算的影响 - 对比:直接调用渲染、
setTimeout渲染、Promise.then渲染、组合渲染(宏任务和微任务交叉) - 工具:
Chrome Devtools Performance
开始测试
1. 直接渲染
<body>
<div id="message"></div>
<script>
let msg = document.querySelector('#message');
function appendMsg() {
let p = document.createElement('p');
p.innerHTML = Math.random().toString(16).substring(2);
msg.appendChild(p);
}
function main() {
// 同步渲染
for(let i = 0; i < 10; i++) {
appendMsg();
}
}
main();
</script>
</body>
直接循环操作dom,给元素添加子元素。
渲染结果如下
后面的图,不再重复标注图例
我们得到这些结论:
- 在解析脚本的过程中发现了我们这个
script,这个script被同步解析,并添加到任务队列,属于第一个任务队列 - 解析脚本分为两段:编译脚本和执行脚本,
chrome把这两步合称为评估脚本 main函数里面的循环,所有操作都是同步执行的,所有操作执行完毕才返回- 执行宏任务完成后,评估脚本阶段结束,才进入
render过程,因此一个任务队列的最后阶段才是render(深紫色段) render过程也分为两:重新计算样式和布局。- 第一个任务队列完毕后还有一些浏览器自身的任务队列进入调度,中间有间隔时间
上述结论如果是通用的,下面的结论也不再重复提示
2. 使用setTimeout
<body>
<div id="message"></div>
<script>
let msg = document.querySelector('#message');
function appendMsg() {
let p = document.createElement('p');
p.innerHTML = Math.random().toString(16).substring(2);
msg.appendChild(p);
}
function main() {
// 同步添加任务渲染,异步定时器触发
for(let i = 0; i < 10; i++) {
setTimeout(() => {
appendMsg();
}, 0)
}
}
main();
</script>
</body>
在循环中不断设置定时器,往任务队列里面添加任务,每个任务的内容为操作dom执行appendChild
结果如下
我们可以得到这些结论
setTimeout时间设置为0,并不是立即触发任务,而是定时器在下一个任务队列调度时立即触发任务添加到任务队列的意思。- 循环中不断异步添加的任务,会生成对应多个任务队列,这些任务队列的间隔时间比较短。
- 每个任务队列里面的任务仍然是同步运行的
- 由于十个任务队列调度的间隔时间比较短,来不及渲染,因此这些
appendChild操作被合并到最后一个任务队列里面执行了,该任务队列就只有render dom操作只是提交了样式变更,具体渲染还是在render过程。整个过程还是同步的,只是因为render可以合并,在两个任务队列做完同一个方法的操作,看起来是异步的。
3. 使用Promise.then
<body>
<div id="message"></div>
<script>
let msg = document.querySelector('#message');
function appendMsg() {
let p = document.createElement('p');
p.innerHTML = Math.random().toString(16).substring(2);
msg.appendChild(p);
}
function main() {
// 同步添加微任务渲染,当前任务结束取出执行
for(let i = 0; i < 10; i++) {
Promise.resolve().then(() => appendMsg())
}
}
main();
</script>
</body>
上述代码执行了同步的的微任务添加,我们要关注微任务什么时候被调度。
结果如下
这个就很明显阐释了微任务的调度和消费时间,我们可以得到这些结论
- 宏任务执行过程中添加微任务,这些微任务都会在本次任务队列的中间被调度
- 微任务的执行时机是在宏任务调度完之后,
render执行前 - 几十个微任务排队被消费,这个消费过程,仍然是同步的
- 每一个
dom操作都有独立的蓝色竖条,表明dom操作确实是消耗比较高的。而且dom操作仍然是同步的 - 注意那个
DCL的位置,由于我们的微任务都是同步调度和消费的,整个DCL时间是等到所有任务结束之后的;而上一节我们用setTimeout由于生成了新的任务队列,DCL是更早的,具体的dom操作是在DCL之后的。 DCL:DocumentContentLoaded,文档加载完成,这时会触发window.onload
4. 交叉使用setTimeout和Promise.then
<body>
<div id="message"></div>
<script>
let msg = document.querySelector('#message');
function appendMsg() {
let p = document.createElement('p');
p.innerHTML = Math.random().toString(16).substring(2);
msg.appendChild(p);
}
function main() {
for(let i = 0; i < 10; i++) {
setTimeout(() => {
Promise.resolve().then(() => appendMsg())
Promise.resolve().then(() => console.log(i));
}, 0);
}
}
main();
</script>
</body>
上述代码在同步调用中生成十个定时器,每个定时器内部添加两个微任务
结果如下:
结论如下:
- 每个任务队列生成的微任务都会在本次队列结束前被检查和消费
- 合并的
render任务可能在中间调用,任务很多的情况下,UI就是渐进的,看起来不会掉帧,这是render的策略(渲染器)
5. 其他情况
深层嵌套模拟
<body>
<div id="message"></div>
<script>
let msg = document.querySelector('#message');
function appendMsg() {
let p = document.createElement('p');
p.innerHTML = Math.random().toString(16).substring(2);
msg.appendChild(p);
}
function main() {
for(let i = 0; i < 10; i++) {
setTimeout(() => {
Promise.resolve().then(() => appendMsg())
Promise.resolve().then(() => {
setTimeout(() => {
console.log(i);
}, 0);
});
}, 50 * i);
}
}
main();
</script>
</body>
把setTimeout时间间隔拉大,会得到一个更稀疏的任务队列调度图。
一些感悟
之前写代码很喜欢把异步写到Promise.then里面,总觉得它实现了异步,又能够比setTimeout的回调函数先执行,页面性能会蹭蹭往上涨。这两天写完EventLoop,完全改变了我的想法:你在一个宏任务里面写一百个Promise.then,它在事件循环里面仍然是同步调用的!这种卡起来你连原因也找不到。
因此,总结一下
- 微任务只是把一段代码放到了本次事件循环的末尾,而不会缩短一次事件循环的总时间
- 真正起作用的是宏任务们,各种原生提供的回调函数,撑起了
web性能优化的半边天。
说到这里,又想起了最近研究的React Fiber技术,同样的,如果React Fiber采用Promise.then来实现调度的话,页面仍然会卡,React团队巧妙地利用requestAnimationFrame|requestIdleCallback等生成宏任务的方法,充分榨干了浏览器提供的能力,才实现了性能的飞跃~
不过要注意,我们这里套路的是同步代码的情况(比如Promise.then(() => console.log(1)),回调中用到的仍然是同步代码,才造成了阻塞),一般我们的Promise会搭配IO、回调等特性使用,性能也是可以的,而且写法很优雅。不过要清楚的是,真正生效的是那些回调函数生成的宏任务。