优化耗时较长的任务
2024年11月18日 • ☕️ 4 min read
将长任务拆分成多个短任务
在任何长度的任务运行期间,浏览器都会阻止发生互动,但只要任务运行时间不太长,用户就不会察觉到这一点。如果有许多耗时任务正在运行,当用户尝试与网页互动时,界面会感觉无响应,如果主线程被阻塞很长时间,界面甚至可能会崩溃。
根据 Google 的 RAIL (Response, Animation, Idel, Load ) 模型,为了不让用户感受到卡顿,我们应该把每个任务控制在 50 ms。用时超过 50ms 的任务被称为长任务,任务的总时间减去 50 毫秒称为任务的阻塞时段。
Chrome 性能分析器中显示的耗时较长的任务。长任务的角落会显示一个红色三角形,任务的阻塞部分会填充红色对角线条纹图案。
为了防止主线阻塞时间过长,导致出现用户能明显感知的卡顿现象,我们需要考虑将长任务拆分成多个较小的短任务。
(一个长任务与将该任务拆分为五个较短任务的可视化效果)
怎么做
demo 请看 这个仓库
假设有一个这样的任务:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
里面的每个小任务都需要花费 30ms,所以 saveSettings 总耗时 150ms,是一个长任务
Performance 面板火焰图:
它会在一个长任务内执行这五个子任务
setTimeout
通过 setTimeout 执行的任务虽然也是宏任务,但是使用 setTimeout 可以将任务延迟到下一个任务中执行
function saveSettings () {
validateForm();
showSpinner();
updateUI();
setTimeout(() => {
sendAnalytics();
saveToDatabase();
})
}
Performance 面板火焰图:
可以看到通过 setTimeout 将一个长任务分成了两个任务(忽略这两个任务还是长任务哈哈)
仔细看上面的两个火焰图可以发现,我使用黄色框标注了一个 otherTask 任务。这个任务在 saveSettings 任务后面执行,并且预期希望在 saveSettings 的所有子任务执行结束之后才执行 otherTask。
然后再第二个图里可以发现使用 setTimeout 拆分任务的弊端:任务的执行顺序会受到影响
通过嵌套 setTimeout 可以解决这个问题,或者使用更优雅的 Promise await:
async function yieldToMain() {
return new Promise(setTimeout)
}
async function saveSettings() {
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
for (const task of tasks) {
task()
await yieldToMain();
}
}
Performance 面板火焰图:
可以看到 otherTask 已经符合预期的执行顺序了,并且所有子任务都被拆分到了单独的任务中(这次的所有任务都不是长任务了)
scheduler
接下来是真正推荐的做法
scheduler.postTask
https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask
调度器 API 提供 postTask() 函数,用于更精细地调度任务,是帮助浏览器确定工作优先级、将低优先级任务让给主线程的一种方法。postTask() 使用 promise,并接受以下三种 priority 设置之一:
background
(最低优先级任务)。user-visible
表示中等优先级任务。如果未设置 priority,则这是默认值。user-blocking
- 适用于需要以高优先级运行的关键任务。
通过 scheduler.postTask
可以实现长任务的拆分,并且还能控制拆分后任务的优先级
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
对于 saveSettings 的五个子任务,saveToDatabase
和 sendAnalytics
是用户无需感知的任务,所以优先级应该是最低的。
Performance 面板火焰图:
可以看到,长任务被拆分成了五个短任务,并且被设置为低优先级的 saveToDatabase
任务被放到了 updateUI
任务后面执行
scheduler.yield()
https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
scheduler.yield() 是一个专门用于让出浏览器中主线程的 API。它的用法类似于前面演示的 yieldToMain()
async function saveSettings() {
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
for (const task of tasks) {
task()
await scheduler.yield();
}
}
Performance 面板火焰图:
scheduler.yield 和前面的 yieldToMain 有什么区别?
(web.dev
上关于这部分的描述)[https://web.dev/articles/optimize-long-tasks?hl=zh-cn#scheduler-dot-yield] 我暂时还没理解,所以通过以下方式感受一下两者的区别:
浏览器开发者工具 > 设置 > 实验 > 开启 Performance panel: show all events
之后,scheduler.yield 和 yieldToMain
Performance 上的火焰图分别如下:
观察火焰图可以发现,使用 scheduler.yield 时,otherTask 和前面的五个子任务是在同一个 ThreadController
中的,而使用 yieldToMain 时 otherTask 是在另一个 ThreadController
中执行的。
这似乎也能帮助理解 web.dev 中的说法:
scheduler.yield() 的好处在于能够继续执行,这意味着,如果您在一组任务中途让出,其他已安排的任务将在让出点之后按相同的顺序继续执行。这样可以避免来自第三方脚本的代码中断代码的执行顺序。
兼容性问题
Scheduler API 目前兼容性还不是很好,可以通过 https://github.com/GoogleChromeLabs/scheduler-polyfill 库实现在不支持的浏览器上使用该 API
通过 npm 安装
npm install scheduler-polyfill
import 'scheduler-polyfill'
通过 script 引入
<script src="https://unpkg.com/scheduler-polyfill"></script>
附录
浏览器每一帧的渲染过程顺序
- 用户事件。
- 一个宏任务。
- 队列中全部微任务。
- requestAnimationFrame。
- 浏览器重排/重绘。
- requestIdleCallback。
https://web.dev/articles/optimize-long-tasks?hl=zh-cn#scheduler-api