理解Event Loop
网页加载后,浏览器会解析 html、执行 js、渲染 css,这些工作都是在 Event Loop 里完成的,理解了 Event Loop 就能理解网页的运行流程。
利用Performance工具我们可以看到整个Event Loop 执行的流程。
首先我们需要一个网页,我这里用的是 react 测试 fiber 用的网页:claudiopro.github.io/react-fiber 点击 Performance 面板的 reload,录制 3 s 的数据: 录制结束就是这样子的: 其中 Main 这部分就是网页的主线程,也就是执行 Event Loop 的部分: 这块区域包含了所有 task 执行的流程,每个 task 的调用栈,因为像燃烧的火焰,所以也叫做火焰图。 鼠标划到想看的部分,向下拖动,就可以放大那个区域,左右上下拖动可以调整看的位置。 展示的信息中很多种颜色,这些颜色代表着不同的含义: 灰色就代表宏任务 task: 蓝色的是 html 的 parse,橙色的是浏览器内部的 JS: 紫色是样式的 reflow、repaint,绿色的部分就是渲染: 其余的颜色都是用户 JS 的执行了,那些可以不用区分。 怎么从 Performance 中看出 Event Loop 执行的流程呢? 从图中我们可以看出,每隔一段时间就会有一个这种任务(Task): 放大其中一个,是这样的: 执行了 Animation Frame 的回调,然后执行了回流重绘,最后执行渲染。 从上面的Frames中可以看到,这种任务每隔 16.7 ms 就会执行一次。为什么是16.7ms呢? 我们的电脑都有一个刷新率的东西,我这里的是60赫兹: 那么60HZ 的刷新率,也就是一秒能刷新 60 次,换算一下,刷新一次需要: 1s / 60 = 16.7ms。 另外可能电脑性能比较高的,这里可以选择ProMotion,这样可以自动调整自己电脑的刷新率。 上面就是网页如何依次渲染的一个过程。
可以看到requestAnimationFrame 的回调是在渲染前执行的(因为在前面),requestAnimationFrame 和渲染共同构成了一个宏任务。 为什么有时候会网页会卡顿、掉帧,就是因为有阻塞渲染的宏任务的执行, 在 Performance 中宽度代表时间,超过 50ms 就被认为是 Long Task,会被标红。为什么是50ms被认为就是一个Long Task呢? 我们通过使用 RAIL 模型衡量性能(建议仔细去看看,这里只做了一个大概的总结)了解到:
- 在 100 毫秒内完成由用户输入发起的转换,让用户感觉交互是即时的。
- 为了确保在 100 毫秒内产生可见响应,需要在 50 毫秒内处理用户输入事件。
- 这是因为除输入处理外,通常还有需要执行其他工作,而且这些工作会占用可接受输入响应的部分可用时间。如果应用程序在空闲时间以推荐的 50 毫秒区块执行工作,这就意味着,如果输入在这些工作区块之一中发生,它最多可能会排队 50 毫秒。考虑到这一点,假设只有剩余的 50 毫秒可用于实际输入处理才是安全地做法。
阻塞主线程达 50 毫秒或以上的任务会导致以下问题:
- 可交互时间 延迟
- 严重不稳定的交互行为 (轻击、单击、滚动、滚轮等) 延迟(High/variable input latency)
- 严重不稳定的事件回调延迟(High/variable event handling latency)
- 紊乱的动画和滚动(Janky animations and scrolling)
任何连续不间断的且主 UI 线程繁忙 50 毫秒及以上的时间区间。比如以下常规场景:
- 长耗时的事件回调(long running event handlers)
- 代价高昂的回流和其他重绘(expensive reflows and other re-renders)
- 浏览器在超过 50 毫秒的事件循环的相邻循环之间所做的工作(work the browser does between different turns of the event loop that exceeds 50 ms)
知道了什么是Long Task,那么就需要针对这些Long Task进行性能优化。 那除了 rAF 和渲染,还有哪些是宏任务呢? 从分析结果可以看到 requestIdleCallback 的回调是宏任务: 垃圾回收 GC 是宏任务: requestAnimationFrame 的回调是宏任务: html 中直接执行的 script 也是宏任务: 在 rAF 调用栈末尾有个 requestAnimationFrame 的调用,是橙色的,也就是浏览器的 api,会把下次 rAF 的回调加入 Event Loop。 大家可能知道 requestIdleCallback 是在空闲时执行代码,那什么时候算空闲呢? 这些渲染任务之间的没有执行 task 的时间就是空闲,或者执行完了任务,离渲染任务执行还有一段时间的时候。可以通过参数 deadline 的 timeRemaining 的 api 来获取剩余的空闲时间:
javascript
requestIdleCallback((deadline) => {
if(deadline.timeRemaining() > 某段时间) {
// 执行任务
}
})
如果一直得不到执行,还可以指定个过期时间,到了时间会插队执行,这时候会再传一个 didTimeout 的参数为 true,代表是过期了执行的:
javascript
requestIdleCallback((deadline) => {
if(deadline.timeRemaining() > 某段时间 || deadline.didTimeout) {
// 执行任务
}
}, { timeout: 1000} )
那微任务是怎么执行的呢?
参考文章: web性能优化(Lighthouse和performance):从实际项目入手,如何监测性能问题、如何解决。
性能优化
首先,我们准备这样一段代码:
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>worker performance optimization</title>
</head>
<body>
<script>
function a() {
console.log('a');
}
function b() {
let total = 0;
for(let i = 0; i< 10*10000*10000; i++) {
total += i;
}
console.log('b:', total);
}
a()
b()
</script>
<script>
function c() {
console.log('c');
}
function d() {
let total = 0;
for(let i = 0; i< 1*10000*10000; i++) {
total += i;
}
console.log('d:', total);
}
c()
d()
</script>
</body>
</html>
很明显,两个 script 标签是两个宏任务,第一个宏任务的调用栈是 a、b,第二个宏任务的调用栈是 c、d。 我们用 Performance 来看一下是不是这样。 首先用无痕模式打开 chrome,无痕模式下没有插件,分析性能不会受插件影响。 打开 chrome devtools 的 Performance 面板,点击 reload 按钮,会重新加载页面并开始记录耗时,等待结束,这时候界面就会展示出记录的信息: 图中标出的 Main 就是主线程。其余的 Frames、Network 等是浏览器的其他线程。 主线程是不断执行 Event Loop 的,可以看到有两个 Task(宏任务),调用栈分别是 a、b 和 c、d,和我们分析的对上了。(当然,还有一些浏览器内部的函数,比如 parseHtml、evaluateScript 等,这些可以忽略) Performance 工具最重要的是分析主线程的 Event Loop,分析每个 Task 的耗时、调用栈等信息。 当你点击某个宏任务的时候,在下面的面板会显示调用栈的详情(选择 bottom-up 是列表展示, call tree 是树形展示) 每个函数的耗时也都显示在左侧,右侧有源码地址,点击就可以跳到 Sources 对应的代码。 很明显, b 和 d 两个函数的循环累加耗时太高了。 在 Performance 中也可以看到 Task 被标红了,下面的 summary 面板也显示了 long task 的警告。 我们就要针对标红的Long Task进行优化。 因为渲染和 JS 执行都在主线程,在一个 Event Loop 中,会相互阻塞,如果 JS 有长时间执行的 Task,就会阻塞渲染,导致页面卡顿。所以,性能分析主要的目的是找到 long task,之后消除它。 找到了要优化的代码,也知道了优化的目标(消除 long task),那么就开始优化吧。
我们优化的目标是把两个 long task 中的耗时逻辑(循环累加)给去掉或者拆分成多个 task。 关于拆分 task 这点,可以参考 React 从递归渲染 vdom 转为链表的可打断的渲染 vdom 的优化,也就是 fiber 的架构,它的目的也是为了拆分 long task。 但明显我们这里的逻辑没啥好拆分的,它就是一个大循环。
封装这样一个函数,传入 url 和数字,函数会创建一个 worker 线程,通过 postMessage 传递 num 过去,并且监听 message 事件来接收返回的数据。
javascript
function runWorker(url, num) {
return new Promise((resolve, reject) => {
const worker = new Worker(url)
worker.postMessage(num)
worker.addEventListener('message', function(evt) {
resolve(evt.data)
})
worker.onerror = reject
})
}
然后 b 和 c 函数就可以改成这样了:
javascript
<script>
function a() {
console.log('a');
}
function b() {
let total = 0;
runWorker('./worker2.js', 10*10000*10000).then(res => {
console.log('b:', res);
})
}
a();
b();
</script>
<script>
function c() {
console.log('c');
}
function d() {
runWorker('./worker.js', 1*10000*10000).then(res => {
console.log('d:', res);
})
}
c();
d();
</script>
耗时逻辑移到了 worker 线程:
javascript
addEventListener('message', function(evt) {
let total = 0
let num = evt.data
for (let i = 0; i < num; i++) {
total += i;
}
postMessage(total)
})
然后我们再reload试试 可以看到,现在long task 一个都没有了! Main 线程下面多了两个 Worker 线程: 这样,我们通过把计算量拆分到 worker 线程,充分利用了多核 cpu 的能力,解决了主线程的 long task 问题,界面交互会很流畅。 我们再看下 Sources 面板: 可以看到,跟之前的相比, 我们的性能得到了质的飞跃。 在性能优化的时候,主要是是优化Long Task,看看对应的代码逻辑,然后进行优化。