我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:千寻
什么是事件循环?
我们为什么需要事件循环?对于 JavaScript 是一门单线程语言我们是肯定的,JavaScript 单线程的特性保证了渲染和 JavaScript 的正常运行,但同时也存在一定的限制。理想情况下我们希望所有任务是串行执行的,假设串行中存在一个耗时很多的任务时,会阻塞后续任务的运行,这种情况我们怎么去解决呢?这个时候就需要我们的事件循环来处理了。
![](https://img.haomeiwen.com/i26021660/0f6a26c1ab6ba82c.png)
让人意外的setTimeout
菜鸟教程:setTimeout() :在指定的毫秒数后调用函数或计算表达式
console.log(1);
setTimeout(()=>{
console.log(2);
},0)
for (let i = 0; i < 5000; i++) {
let sum = 0;
sum += i;
}
console.log(3);
猜猜上面这段代码执行结果是多少呢?根据 Event Loop 机制我们知道答案是1、3、2。但是针对这段代码中有一个疑问点,0ms 是指 0ms 后执行 callback 吗?答案是否定的,定时器任务被维护在定时器线程中,添加一个定时器时开始计时这个任务,0ms 后会将 callback 添加到事件队列中,<script> 宏任务中循环计算导致耗时长,阻塞定时器的 callback 执行,查看 Performance 执行过程验证猜想。
![](https://img.haomeiwen.com/i26021660/d2284d210840e3f3.png)
宏任务与微任务
我们先回顾一下之前 Event Loop 图,在图中描述了函数调用栈、Web Api、一个消息队列,并没有提到宏任务与微任务,那么宏任务与微任务是什么呢?为什么要有微任务呢?我们先来看一个例子:
function timerCallback2(){
console.log(2);
}
function timerCallback(){
console.log(1);
setTimeout(timerCallback2,0);
}
setTimeout(timerCallback,0);
我们希望通过 setTimeout 按照顺序执行 callback,通过 Performance 发现,在两个任务中间插入了其他任务,如果插入任务是 long task,会影响后续任务的执行。宏任务是浏览器提供给我们的Web Api,时间颗粒度较大,针对像 DOM 等高实时性操作是不太符合的。
![](https://img.haomeiwen.com/i26021660/8829dee408c3a527.png)
为了满足这种高优先级的任务,V8 引擎在创建全局执行上下文时会在内部创建一个微任务队列,在当前宏任务执行完成时去检查微任务队列,我们把执行微任务的时间点叫检查点。了解了微任务队列后,我们丰富一下之前的 Event Loop。
![](https://img.haomeiwen.com/i26021660/414766ebdaba35df.png)
console.log(1);
new Promise(function (resolve) {
console.log(2);
resolve()
}).then(function () {
console.log(3);
})
console.log(4);
事件循环与渲染
浏览器按照帧渲染方式一帧一帧渲染网页,但并不是每一帧都会经历管道每个部分的处理。当脚本执行阻塞时会导致后续渲染流畅阻塞,页面卡顿。
![](https://img.haomeiwen.com/i26021660/4806662053d6171a.png)
浏览器何时渲染对于我们来说就是一个黑盒,浏览器自身会去判断当前是否需要进行渲染,因此性能优化的是管道帧的流水过程,比如减少脚本执行时长,避免重绘、重排。如果你希望在每轮事件循环中都能变动,你需要去了解一下 requestAnimationFrame。
![](https://img.haomeiwen.com/i26021660/956f5285bfbb5a8d.png)
<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>
当两个宏任务耗时不足一帧时,会发生渲染合并现象:
![](https://img.haomeiwen.com/i26021660/cab265995b3759e2.png)
我们修改上诉代码如下,延长第二个宏任务执行时机:
<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')
})
}, 17) };
</script>
两个宏任务执行时间间隔 17ms ,按照代码逻辑宏任务执行完毕就进行渲染,并未发生渲染合并现象。
![](https://img.haomeiwen.com/i26021660/7acf6f43e404903f.png)
事件循环之任务拆分
作为提供数据中台服务的公司,我们不可避免会涉及到一些复杂数据到计算。下面这个例子我们需要计算从1到10 000 000 000 数据加起来到合,计算完毕展示我们到弹框。通过 Performance 我们可以看见这个同步任务耗时快 4s,浏览器每帧需达到 60fps/s,也就是16.7ms 每帧,在这个计算结束之前其他任务均得不到执行,导致后续渲染任务的延迟造成卡顿现象。
let i = 0;
let start = Date.now();
function count() {
// long Task
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
![](https://img.haomeiwen.com/i26021660/3136bb8aca25c83a.png)
我们希望这个 long Task 拆分成一个个小的任务,解决长时间阻塞造成的卡顿现象。我们可以利用 setTimeout 拆分我们的任务,修改代码如下,这个计算确实被拆分成了一个个小任务。React Firber架构中也使用了任务拆分这种思想将递归渲染 vdom 转为了链表可中断渲染 vdom,笔者对 Fiber了解并不多,这部分就不展开细说了。
let i = 0;
let start = Date.now();
function count() {
// long Task
do{
i++;
}while(i % 1e6 != 0)
if(i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count);
} }
count();
![](https://img.haomeiwen.com/i26021660/f2d43e7e688e0036.png)
setTimeout 确实将任务进行了拆分处理,但仍占用了主线程但资源,我们知道主线程保证了页面的渲染、脚本交互、布局等操作,上诉这种单纯的数据计算放在主线程处理是没有意义的,我们可以将耗时计算放在 Web Worker 中处理。
事件循环优化之Web Worker
什么是 Web Worker?
💡 当在 HTML 页面中执行脚本时,页面的状态是不可响应的,直到脚本已完成。web worker 是运行在后台的 JavaScript,独立于其他脚本,不会影响页面的性能。您可以继续做任何愿意做的事情:点击、选取内容等等,而此时 web worker 在后台运行。
Web Worker 工作原理
![](https://img.haomeiwen.com/i26021660/ecb246c74317e08f.png)
Web Worker 改变了 JavaScript 单线程执行这一本质了吗?
并没有改变 JavaScript 是单线程执行这一本质。JavaScript 是一门没有定义线程模型的原型,Web Worker 并不是 JavaScript 的一部分,它是浏览器提供的一种创建线程的方式,所以在使用 Web Worker 时不能操作 DOM,这也就意味着我们不能使用 Web Worker 进行 UI 更新这种操作,但如果把 Web Worker 理解成一个计算器,处理繁重的计算任务,会让我们的主线程执行更加流畅。
// worker.js
self.onmessage = (e=>{
const { startNum } = e.data;
let sum = startNum;
(function count() {
// long Task
for (let j = 0; j < 1e9; j++) {
sum++;
}
})();
self.postMessage(sum);
})
// test.html
let start = Date.now();
let worker = new Worker('worker.js');
worker.postMessage({startNum: 0});
worker.onmessage = (e) => {
alert("Done in " + (Date.now() - start) + 'ms');
}
![](https://img.haomeiwen.com/i26021660/8e9651dcde62872f.png)
总结
笔者最初学习事件循环时只会判断一段简单代码片段的输出结果,查阅网上资料发现大部分资料也是这样介绍事件循环的,这导致笔者思维长时间聚焦在一段脚本的输出结果,接触 Web Worker 时针对复杂计算开一个线程的必要性也持有怀疑。通过这段时间的学习,让我理解到事件循环的本质是保证用户交互、脚本、UI 渲染有序进行的基石,脚本长时间的执行会导致后续任务阻塞,页面呈现卡顿现象,这也是为什么 Web Worker 采用开一个线程进行复杂计算的原因。
最后
欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star
网友评论