您可能听过“不要阻塞主线程”和“拆分长任务”等建议,但这些建议具体意味着什么?
发布时间:2022 年 9 月 30 日,最后更新时间:2024 年 12 月 19 日
关于如何保持 JavaScript 应用快速运行的常见建议往往可以归结为以下几点:
- “避免阻塞主线程。”
- “拆分长任务。”
这个建议很好,但具体需要做哪些工作呢?提供更少的 JavaScript 是件好事,但这是否会自动带来响应速度更快的界面?也许是,也许不是。
如需了解如何在 JavaScript 中优化任务,您首先需要了解什么是任务,以及浏览器如何处理任务。
什么是任务?
任务是指浏览器执行的任何离散工作。这些工作包括渲染、解析 HTML 和 CSS、运行 JavaScript,以及您可能无法直接控制的其他类型的工作。在所有这些任务中,您编写的 JavaScript 可能是最大的任务来源。
click 事件处理脚本启动的任务,显示在 Chrome 开发者工具的性能分析器中。
与 JavaScript 相关的任务会通过以下几种方式影响性能:
- 当浏览器在启动期间下载 JavaScript 文件时,会将解析和编译该 JavaScript 的任务加入队列,以便稍后执行。
- 在网页生命周期的其他时间,当 JavaScript 执行工作(例如通过事件处理脚本响应互动、JavaScript 驱动的动画以及分析收集等后台活动)时,任务会排入队列。
所有这些操作(Web Worker 和类似 API 除外)都在主线程上进行。
什么是主线程?
主线程是浏览器中大多数任务运行的位置,也是您编写的几乎所有 JavaScript 执行的位置。
主线程一次只能处理一个任务。任何耗时超过 50 毫秒的任务都是长任务。对于超过 50 毫秒的任务,任务的总时间减去 50 毫秒称为任务的阻塞期。
在任何长度的任务运行期间,浏览器都会阻止互动发生,但只要任务运行时间不太长,用户就不会察觉到这一点。不过,当存在许多长任务时,如果用户尝试与网页互动,界面会感觉无响应,如果主线程长时间被阻塞,甚至可能会损坏。
为防止主线程被阻塞太久,您可以将一个长任务拆分为多个较小的任务。
这一点很重要,因为当任务被拆分后,浏览器可以更快地响应优先级较高的工作,包括用户互动。之后,剩余任务会运行至完成,确保您最初排队的工作得以完成。
在上图顶部,由用户互动排队的事件处理脚本必须等待一个长任务完成才能开始,这会延迟互动的发生。在这种情况下,用户可能会注意到卡顿。在底部,事件处理脚本可以更快地开始运行,互动可能会让人感觉即时。
现在,您已经了解了拆分任务的重要性,接下来可以学习如何在 JavaScript 中实现这一点。
任务管理策略
软件架构中的一个常见建议是将工作拆分为较小的函数:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在此示例中,有一个名为 saveSettings() 的函数,它会调用五个函数来验证表单、显示旋转图标、向应用后端发送数据、更新界面以及发送分析数据。
从概念上讲,saveSettings() 架构良好。如果您需要调试其中一个函数,可以遍历项目树,了解每个函数的作用。这样拆分工作可以让项目更易于浏览和维护。
不过,这里存在一个潜在问题:JavaScript 不会将这些函数作为单独的任务运行,因为它们是在 saveSettings() 函数中执行的。这意味着所有五个函数都将作为一个任务运行。
saveSettings()。该工作作为一项长而单一的任务运行,在所有五个函数完成之前,会阻止任何视觉响应。
在最理想的情况下,即使只有一个这样的函数,也会使任务的总时长增加 50 毫秒或更长时间。在最坏的情况下,更多此类任务的运行时间可能会长得多,尤其是在资源受限的设备上。
在这种情况下,saveSettings() 由用户点击触发,并且由于浏览器在整个函数运行完毕之前无法显示响应,因此这个长任务会导致界面缓慢且无响应,并会被衡量为Interaction to Next Paint (INP) 较差。
手动推迟代码执行
为确保重要的面向用户的任务和界面响应在低优先级任务之前发生,您可以让出主线程,方法是短暂中断工作,让浏览器有机会运行更重要的任务。
开发者用来将任务拆分为较小任务的一种方法涉及 setTimeout()。使用此方法时,您需要将函数传递给 setTimeout()。这会将回调的执行推迟到单独的任务中,即使您指定了 0 的超时时间也是如此。
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
这称为“让出”,最适合需要按顺序运行的一系列函数。
不过,您的代码可能并不总是以这种方式组织。例如,您可能有大量数据需要在循环中处理,如果迭代次数很多,该任务可能需要很长时间。
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
由于开发者人体工程学的原因,在此处使用 setTimeout() 会出现问题,并且在经过五轮嵌套 setTimeout() 后,浏览器将开始对每个额外的 setTimeout() 施加至少 5 毫秒的延迟。
在让出主线程方面,setTimeout 还有另一个缺点:当您通过使用 setTimeout 延迟代码在后续任务中运行来让出主线程时,该任务会被添加到队列的末尾。如果有其他任务在等待,它们将在您的延迟代码之前运行。
专用让出 API:scheduler.yield()
scheduler.yield() 是一个专门用于让出浏览器主线程的 API。
它不是语言级语法或特殊构造;scheduler.yield() 只是一个返回 Promise 的函数,该函数将在未来的任务中解析。在 解析后,任何链接到该 Promise 的代码(在显式 .then() 链中或在异步函数中 await 之后)都将在该未来任务中运行。
在实践中:插入 await scheduler.yield(),函数将在该点暂停执行并让出主线程。该函数的其余部分(称为该函数的延续)将安排在新的事件循环任务中运行。当该任务启动时,等待的 promise 将得到解决,函数将从上次停止的位置继续执行。
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}
saveSettings() 的执行现在分为两个任务。因此,布局和绘制可以在任务之间运行,从而为用户提供更快的视觉响应,这可以通过现在更短的指针互动来衡量。
不过,与其他让出方法相比,scheduler.yield() 的真正优势在于其延续任务会优先执行,这意味着,如果您在任务执行过程中让出,当前任务的延续任务将在任何其他类似任务启动之前运行。
这样可以避免来自其他任务源(例如来自第三方脚本的任务)的代码中断代码的执行顺序。
scheduler.yield() 时,延续任务会从上次中断的地方继续执行,然后再继续执行其他任务。
跨浏览器支持
并非所有浏览器都支持 scheduler.yield(),因此需要提供后备方案。
一种解决方案是将 scheduler-polyfill 放入 build 中,然后直接使用 scheduler.yield();polyfill 将处理回退到其他任务调度函数,以便在不同浏览器中以类似方式运行。
或者,也可以用几行代码编写一个不太复杂的版本,仅使用封装在 Promise 中的 setTimeout 作为后备方案(如果 scheduler.yield() 不可用)。
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
虽然不支持 scheduler.yield() 的浏览器不会获得优先继续执行,但它们仍会暂停执行,以使浏览器保持响应。
最后,在某些情况下,如果代码的后续执行没有优先级,则可能无法让出主线程(例如,在已知繁忙的页面上,让出主线程可能会导致一段时间内无法完成工作)。在这种情况下,scheduler.yield() 可以被视为一种渐进增强:在支持 scheduler.yield() 的浏览器中让出控制权,否则继续执行。
这可以通过以下方式实现:检测功能,并回退到等待单个微任务,只需一行代码即可:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
使用 scheduler.yield() 拆分长时间运行的工作
使用上述任一方法来使用 scheduler.yield() 的好处是,您可以在任何 async 函数中 await 它。
例如,如果您有一系列要运行的作业,这些作业通常会累加成一个长任务,则可以插入让步来拆分该任务。
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
runJobs() 的延续将优先执行,但仍允许执行更高优先级的工作(例如直观地响应用户输入),而不必等待可能很长的作业列表完成。
不过,这并不是一种高效的让出方式。scheduler.yield() 快速高效,但确实会产生一些开销。如果 jobQueue 中的某些作业非常短,那么开销可能会很快累积到比执行实际工作更多的时间。
一种方法是将作业分批,仅在距离上次让出时间足够长时才在作业之间让出时间。常见的截止时间是 50 毫秒,目的是防止任务变成长任务,但可以根据响应速度和完成作业队列所需时间之间的权衡进行调整。
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
这样一来,作业会被拆分,运行时间不会太长,但运行程序大约每 50 毫秒才会让出主线程。
请勿使用 isInputPending()
isInputPending() API 提供了一种方法来检查用户是否尝试与网页互动,并且仅在有输入待处理时才让出。
这样,如果没有待处理的输入,JavaScript 就可以继续执行,而不是让出控制权并最终排在任务队列的末尾。如发布意向中所述,对于原本可能不会让出主线程的网站,这可以显著提升性能。
不过,自该 API 发布以来,我们对让步的理解有所加深,尤其是引入 INP 之后。我们不再建议使用此 API,而是建议无论输入是否处于待处理状态都让出控制权,原因如下:
- 在某些情况下,即使有用户互动,
isInputPending()也可能会错误地返回false。 - 输入并不是任务应该让出控制权的唯一情况。动画和其他常规界面更新对于提供响应式网页同样重要。
- 此后,我们引入了更全面的让出 API,以解决让出问题,例如
scheduler.postTask()和scheduler.yield()。
总结
管理任务具有挑战性,但这样做可确保您的网页更快地响应用户互动。管理任务和确定任务优先级没有单一的方法,而是有许多不同的技巧。重申一下,在管理任务时,您需要考虑以下主要事项:
- 让出主线程,以便执行面向用户的关键任务。
- 使用
scheduler.yield()(具有跨浏览器回退功能)以符合人体工程学的方式让出控制权并获得优先延续 - 最后,在函数中尽可能少执行操作。
如需详细了解 scheduler.yield()、其显式任务调度相对 scheduler.postTask() 以及任务优先级,请参阅优先任务调度 API 文档。
借助其中一个或多个工具,您应该能够对应用中的工作进行结构化处理,使其优先满足用户的需求,同时确保不太重要的工作仍能完成。这将带来更出色的用户体验,让用户能够更快速地响应,并享受更愉悦的使用体验。
特别感谢 Philip Walton 对本指南的技术审核。