有人提醒你“不阻塞主线程”以及“分解长任务” 但做这些事情意味着什么?
关于让 JavaScript 应用保持快速运行的常见建议通常归结为以下建议:
- "请勿阻塞主线程。"
- “将冗长的任务分门别类。”
这是很好的建议,但涉及哪些工作?少加载 JavaScript 固然很好,但这是否会自动等同于界面响应更灵敏?也许,但不一定。
要了解如何在 JavaScript 中优化任务,首先需要知道什么是任务以及浏览器处理任务的方式。
什么是任务?
“任务”是浏览器执行的任何离散工作。这些工作包括呈现、解析 HTML 和 CSS、运行 JavaScript 以及您可能无法直接控制的其他类型的工作。在所有这些功能中,您编写的 JavaScript 可能是最大的任务来源。
与 JavaScript 相关的任务会在以下几个方面影响性能:
- 当浏览器在启动过程中下载 JavaScript 文件时,会将任务排入队列,以解析和编译该 JavaScript,以便稍后执行。
- 在页面生命周期的其他时间,当 JavaScript 起作用时(例如通过事件处理程序、JavaScript 驱动的动画和后台活动(例如 Analytics 收集)驱动互动),系统会将任务排入队列。
除 Web Worker 和类似的 API 之外,所有这些内容都发生在主线程上。
什么是主线程?
主线程是大多数任务在浏览器中运行的位置,您编写的几乎所有 JavaScript 都在主线程中执行。
主线程一次只能处理一个任务。任何耗时超过 50 毫秒的任务都属于耗时较长的任务。对于超过 50 毫秒的任务,其总时间减去 50 毫秒后,称为任务的阻塞期。
浏览器会在任何长度的任务运行时阻止交互,但只要任务运行时间不过长,用户就不会察觉到这种情况。但是,如果用户在执行许多耗时较长的任务时尝试与页面进行交互,界面会感觉无响应,如果主线程长时间处于阻塞状态,甚至可能被破坏。
为防止主线程被阻塞的时间过长,您可以将一个长任务拆分为几个较小的任务。
这一点很重要,因为当任务分解时,浏览器可以更快地响应优先级更高的工作,包括用户互动。之后,剩余任务会运行完成,确保您最初加入队列的工作已经完成。
在上图的顶部,由用户互动排入队列的事件处理程序必须等待一个长任务才能开始,这就延迟了互动的发生。在这种情况下,用户可能已注意到延迟。在底部,事件处理脚本可以更早开始运行,用户可能感觉到即时互动。
现在,您已经了解了拆分任务的重要性,接下来可以学习如何在 JavaScript 中拆分任务了。
任务管理策略
软件架构的一个常见建议是将工作分解为较小的函数:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在此示例中,有一个名为 saveSettings()
的函数,该函数会调用五个函数来验证表单、显示旋转图标、将数据发送到应用后端、更新界面以及发送分析数据。
从概念上讲,saveSettings()
具有良好的架构。如果您需要调试其中某个函数,可以遍历项目树来确定每个函数的用途。像这样拆分工作会使项目更易于导航和维护。
不过,这里的潜在问题是,JavaScript 不会将每个函数作为单独的任务运行,因为它们是在 saveSettings()
函数中执行的。也就是说,这五个函数都将作为一个任务运行。
在最佳情况下,即使只有一个函数也会对任务的总长度产生 50 毫秒或更多的时间。在最坏的情况下,其中更多的任务可以运行更长时间,尤其是在资源受限的设备上。
手动推迟代码执行
开发者用于将任务拆分为较小任务的一种方法是 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()
并不是适合这项作业的工具,至少以这种方式使用时是错误的。
使用 async
/await
创建收益点
为了确保面向用户的重要任务发生在优先级较低的任务之前, 您可以让出主线程,只需短暂中断任务队列即可让 来运行更重要的任务。
如前所述,setTimeout
可用于让出主线程。不过,为了方便起见并提高可读性,您可以在 Promise
中调用 setTimeout
,并将其 resolve
方法作为回调进行传递。
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
<ph type="x-smartling-placeholder">
yieldToMain()
函数的优势在于,您可以在任何 async
函数中对其执行 await
操作。基于前面的示例,您可以创建一个要运行的函数数组,并在每个函数运行后让给主线程:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
结果就是,曾经的单体式任务现在被分解成了单独的任务。
专用的调度器 API
setTimeout
是拆分任务的有效方式,但也有一个缺点:当您通过推迟在后续任务中运行代码来让出主线程时,该任务会被添加到队列的末尾。
如果您控制着页面上的所有代码,则可以自行创建调度程序并设定任务的优先级,但第三方脚本不会使用您的调度程序。实际上,在此类环境中,您无法确定工作的优先次序。您只能细分数据,或者明确让出用户互动。
调度器 API 提供 postTask()
函数,用于更精细地调度任务,是帮助浏览器确定工作的优先级,从而使低优先级任务交给主线程的一种方法。postTask()
使用 promise,并接受以下三种 priority
设置之一:
'background'
表示优先级最低的任务。'user-visible'
,适用于中优先级任务。如果未设置priority
,则这是默认值。'user-blocking'
(适用于需要以高优先级运行的关键任务)。
以以下代码为例,其中 postTask()
API 用于以尽可能高的优先级运行三个任务,剩下的两个任务以尽可能低的优先级运行。
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'});
};
在这里,任务优先级的安排方式可让浏览器优先任务(例如用户互动)根据需要进行。
这是 postTask()
使用方法的简单示例。您可以实例化不同的 TaskController
对象,这些对象可以在任务之间共享优先级,包括根据需要更改不同 TaskController
实例的优先级。
内置收益,可使用即将推出的 scheduler.yield()
API 实现延续
我们提议在调度器 API 中添加 scheduler.yield()
,该 API 专为让浏览器中的主线程而设计。其用法类似于本指南前面演示的 yieldToMain()
函数:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
大家基本熟悉此代码,但它不使用 yieldToMain()
,而是使用
await scheduler.yield()
。
scheduler.yield()
的优势在于连续性,这意味着,如果您在一组任务的中间让出,其他计划任务在屈服点后将按相同的顺序继续执行。这样可以避免来自第三方脚本的代码中断代码的执行顺序。
由于 user-blocking
优先级较高,将 scheduler.postTask()
与 priority: 'user-blocking'
一起使用也很有可能会继续运行,因此在此期间可以使用此方法作为替代方案。
使用 setTimeout()
(或将 scheduler.postTask()
与 priority: 'user-visibile'
或没有显式 priority
搭配使用)可将任务安排在队列后部,以便允许其他待处理任务在继续前运行。
不使用 isInputPending()
浏览器支持
- 87
- 87
- x
- x
isInputPending()
API 提供了一种方法,可用于检查用户是否尝试过与网页互动,并且仅当某项输入处于待处理状态时才会产生收益。
这样,即使没有待处理的输入,JavaScript 也能继续运行,而不是在任务队列后退并结束。对于那些可能不会回退到主线程的网站,这可能会带来显著的性能提升(如 Intent to Ship 中所述)。
不过,自该 API 推出以来,我们对收益的理解加深了,尤其是在引入 INP 后。我们不再建议使用此 API,而是建议执行生成操作,无论输入是否处于待处理状态,原因如下:
- 尽管在某些情况下用户发生了互动,
isInputPending()
仍可能会错误地返回false
。 - 输入并不是任务应产生的唯一情况。动画和其他定期的界面更新对于提供自适应网页同样重要。
- 此后,引入了更全面的收益生成 API 来解决收益问题,例如
scheduler.postTask()
和scheduler.yield()
。
总结
管理任务颇具挑战性,但这样做可确保您的网页更迅速地响应用户交互操作。管理任务和确定任务优先级并非一成不变的建议,而是可以使用很多不同的技术。再次强调,下面是您在管理任务时需要考虑的主要事项:
- 在面向用户的关键任务中让给主线程。
- 使用
postTask()
确定任务的优先级。 - 不妨考虑使用
scheduler.yield()
进行实验。 - 最后,尽量减少在函数中执行的工作。
使用这些工具中的一个或多个,您应该能够在应用中构建工作的结构,以便它优先考虑用户的需求,同时确保仍能完成不太重要的工作。这将带来更好的用户体验,响应速度更快,使用起来更愉快。
特别感谢 Philip Walton 对本指南的技术审查。
缩略图图片来自 Unshot,由 Amirali Mirhashemian 提供。