摘要: 页面内一次性插入十万条数据,常规做法会生成Long Task阻塞主线程,导致页面渲染等任务在消息队列中淤积,表现形式就是页面暂时性卡顿假死。本文将介绍通过时间切片的方式,把Long Task切分成多个小Task来解决这个问题。
关键词(例): 性能优化、长任务、时间切片
一、背景
如果要在页面中一次性插入10万条数据,页面的表现会怎样呢,让我们用最小规模的代码来模拟一下。
<style>
.ball {
position: relative;
width: 100px;
height: 100px;
border-radius: 50%;
background-color: red;
animation: move 2s linear infinite alternate;
}
@keyframes move {
0% {
left: 0;
}
100% {
left: calc(100% - 100px);
}
}
</style>
<div class="ball"></div>
<button>插入100000个元素</button>
<div class="container"></div>
<script src="./index.js"></script>
const container = document.querySelector('.container')
const tasks = Array(100000).fill(0).map((_, i) => () => {
const div = document.createElement('div')
div.innerText = i
container.appendChild(div)
})
const btn = document.querySelector('button')
btn.onclick = () => {
for (const task of tasks) {
task()
}
}
上面的代码为按钮注册点击事件,向页面中插入十万个div。红色小球有一个横向的往返动画,主要用来方便后续我们观察页面是否会卡顿。
这个时候如果点击按钮,小球动画会停止,页面无法响应用户的交互(滚动页面、点击事件等),直到大约1秒后10万个div渲染完毕,卡顿才恢复正常。我这是最小化规模代码的演示,实际开发中的DOM节点远比这个复杂,时间肯定会更长。
我们通过Chrome的Performance面板录制下这个过程的性能表现。
可以看到Event:click对应了一个1.02s的Long Task,就是这个长任务导致了页面渲染的暂时卡顿。要解释清楚这一点,我们需要先简单了解一下浏览器的渲染原理。
以现代浏览器Chrome为例,它是一个多进程、多线程的应用程序。我们平时接触最多的JavaScript代码就是运行在浏览器的渲染进程的渲染主线程上的。渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:
- 解析 HTML
- 解析 CSS
- 计算样式
- 布局
- 处理图层
- 每秒把⻚⾯画 60 次
- 执⾏全局 JS 代码
- 执⾏事件处理函数
- 执⾏计时器的回调函数
- ...
事件循环其实就是一个不断从消息队列中取任务放到渲染主线程执行的过程,由于本文的重点不在事件循环,所以这里简要带过了。
现在我们已经知道了,渲染主线程是如此的繁忙,一旦它被阻塞了,就会导致消息队列中的许多任务淤积不能被及时执行,而这其中就包含渲染任务!
所以让我们回到开始,我们在Performance面板中看到有一个1.02s的长任务,在这个长任务执行完之前,消息队列中的所有任务都得乖乖排队,这其中就包括渲染任务,这就是为什么点击按钮后,小球动画会暂停。其实不仅仅小球动画,如果页面有滚动条,滚动条也会暂时无法滚动。如果页面有input框,点击input框它暂时也不会focus。总之就是用户交互都因为长任务的阻塞而暂时无法响应,这种体验实在是太糟糕了。
说个题外话,其实上面的场景,小球的动画可以使用transform: translate()代替left来进行优化。优化后,尽管渲染主线程被阻塞,依然丝毫不影响动画,这是因为transform发生在合成线程,所以渲染主线程的阻塞几乎不会影响到transform。另外回流和重绘也是发生在渲染主线程,tranform也不会引起回流重绘,所以日常开发尽量使用transform代替left,top等属性。点到为止,这个点也不展开。
二、解决过程
现在我们知道渲染卡顿的原因是长任务导致的了,该怎么优化呢?比较容易想到的是,长任务在渲染主线程中耗时较多,可以利用额外的线程减少执行时间,Web Worker可以做到这一点,然而Web Worker无法操作DOM,所以这条路是堵死了。
换个思路,如果长任务整体时间无法减少,那能不能把长任务拆分成多个更小的任务呢?这样每个任务执行完的间隙,控制权会重新回到渲染主线程的手中,它就可以调度消息队列中的任务,让渲染任务能更早的执行,用户交互就能更快地被响应。这种方案被称为时间切片或任务切片。
那么具体要如何实现时间切片呢?
setTimeout配合async...await
比较容易想到的是,利用setTimout(() => {}, 0)来把其它任务推入消息队列中,在事件循环机制的作用下,一个任务执行完后,渲染主线程将有机会取出消息队列中的用户交互任务和渲染任务来执行。
function yieldToMain() {
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
btn.onclick = async () => {
let i = 0
let now = performance.now()
while (i < tasks.length) {
tasks[i++]()
await yieldToMain()
console.log('耗时', performance.now() - now, i)
}
}
这个时候点击按钮,DOM节点会立即分批次渲染,小球动画也没有出现被卡住的现象了。
通过Performance面板录制一下这个过程的前5s,可以看到没有长任务了,长任务被拆分成了很多个小任务,渲染主线程在这些小任务的间隙得以喘息,有机会来调度执行消息队列中的用户交互任务和渲染任务,所以整个过程显得很丝滑,用户也没有感受到交互的卡顿。
这一切似乎很完美,然而真的没有问题吗,注意我上面代码中打印了每个任务执行完距离任务开始前的耗时和当前执行的任务索引。
可以看到当执行到索引14630对应的任务时,耗时已经达到了惊人的431522毫秒左右,而我们的目标是10万个任务!
为什么会这样呢?究其原因,我们把时间切片切得太碎了。每一轮事件循环里我们只执行了一个任务,如果一轮事件循环的时间本来是足够执行100条任务的,那么我们就浪费了执行99条任务的时间,虽然长任务被分割了,但所有小任务执行的总时长大大增加了。所以问题关键在于如何确定时间切片的颗粒度。
控制时间切片的颗粒度
让我们先回到开始,搞清楚什么是长任务。长任务是指运行时长超过50毫秒的任务,而为什么是50毫秒呢?它来自Google的RAIL模型,这个模型专注于提升用户体验,而 50 毫秒正是为了保持用户界面的流畅交互而精心选定的时间阈值。
在上面的示例中,我们在一轮事件循环中只处理了一个任务,时间切片切的太碎了。那就试试控制一轮循环多执行一些任务,具体执行多少个任务呢?上面提到50毫秒是长任务的临界点,可以设置一个低于50毫秒的时间,每轮循环不管执行了多少个任务,执行时间超过这个值,就把控制权交还给主线程。我的代码中用了16毫秒,算是一个经验值。
const btn = document.querySelector('button')
btn.onclick = async () => {
let i = 0
let start = performance.now()
let prev = start
while (i < tasks.length) {
tasks[i++]()
if (performance.now() - prev > 16) {
prev = performance.now()
await yieldToMain()
console.log('耗时', performance.now() - start, i)
}
}
console.log(performance.now() - start, i)
}
function yieldToMain() {
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
仍然是取这段代码点击按钮的前5秒来录制Performance,表现如下图,仍然有长任务,但是基本不会阻塞渲染和用户交互了。
schduler.yield()
上面用setTimeout来做时间切片实际是存在一些问题的:当我们使用setTimeout来让出主线程的控制权时,被延迟的任务实际上是被添加到了消息队列的末尾。如果页面中有其它第三方脚本,可能这些脚本也会往消息队列中添加任务,那我们原本的长任务,在被切片后,后续的任务就可能排在第三方脚本的任务之后,造成任务执行不连贯。
自 Chrome 115 版起,可以使用实验性APIscheduler.yield()来解决这个问题,这个API就是被设计用来让出主线程的,scheduler.yield()返回一个Promise,它会在让出主线程后继续以相同的顺序执行后续的任务,而不会被其它第三方脚本抢占执行时机。
由于是实验性API,需要在Chrome地址栏输入chrome://flags/,搜索并开启Experimental Web Platform features特性,否则这个API是不可用的。
开启这个实验特性后,用法也很简单,使用await scheduler.yield()替换await yieldToMain()即可:
const btn = document.querySelector('button')
btn.onclick = async () => {
let i = 0
let start = performance.now()
let prev = start
while (i < tasks.length) {
tasks[i++]()
if (performance.now() - prev > 10) {
prev = performance.now()
await scheduler.yield()
console.log('耗时', performance.now() - start, i)
}
}
console.log(performance.now() - start, i)
}
三、总结
3.1 技术经验:
本文介绍了如何使用时间切片的方式来优化长任务导致的主线程阻塞带来的渲染卡顿。通过setTimeout、控制切片的颗粒度、scheduler.yield()等方式,原本需要1秒多才能完成渲染及响应用户交互的场景,在优化后可以立即渲染并响应用户交互(考虑到文中采用的是最小化规模的代码,实际场景的代码应是这个时间的数倍)。
3.2 有待提升:
应用时间切片后,任务总执行时长会增加。如果切片太碎,则执行时间会大大增加。如果切片较粗犷,则仍然会有长任务,渲染仍然会有稍许卡顿,用户交互也会有稍许延迟,当然肯定比切片前的效果还是要好的。如何在实际业务开发中把握切片的颗粒度会是一个难点。
另外,本文的代码场景比较简单,实际的业务场景复杂,加上引用现代Vue、React等框架,掌握好在何时、如何应用时间切片也会是一个比较大的挑战。
最后,就本文的场景来说,时间切片解决的是DOM插入时的即时渲染问题,当所有DOM全部插入文档后,这些DOM占据的大量内存可能仍然会导致页面卡顿。这个问题时间切片就无能为力了,可能需要别的方案,比如虚拟列表。