您可能听过“请勿阻塞主线程”和“请拆分长时间运行的任务”这两条建议,但这两条建议具体意味着什么?
关于如何让 JavaScript 应用保持快速运行的常见建议通常归结为以下建议:
- "请勿阻塞主线程。"
- “拆分长任务。”
这是一个很好的建议,但需要做哪些工作?少加载 JavaScript 固然很好,但这是否会自动等同于界面响应更灵敏?也许,也许不会。
要了解如何在 JavaScript 中优化任务,首先需要知道什么是任务以及浏览器处理任务的方式。
什么是任务?
任务是指浏览器执行的任何单独工作。这些工作包括呈现、解析 HTML 和 CSS、运行 JavaScript 以及您可能无法直接控制的其他类型的工作。在所有这些因素中,您编写的 JavaScript 可能是最大的任务来源。
与 JavaScript 相关的任务会在以下几个方面影响性能:
- 当浏览器在启动过程中下载 JavaScript 文件时,它会将任务排入队列,以解析和编译该 JavaScript,以便稍后执行。
- 在网页生命周期的其他时间,当 JavaScript 执行工作(例如通过事件处理脚本、JavaScript 驱动的动画以及分析数据收集等后台活动来促成互动)时,系统会将任务加入队列。
所有这些操作(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);
});
}
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 实现接续的内置收益
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()
的好处在于能够继续执行,这意味着,如果您在一组任务中途让出,其他已安排的任务将在让出点之后按相同的顺序继续执行。这样可以避免来自第三方脚本的代码中断代码的执行顺序。
请勿使用 isInputPending()
isInputPending()
API 提供了一种方法,可用于检查用户是否尝试过与网页互动,并且仅当某项输入处于待处理状态时才会产生收益。
这样,如果没有待处理的输入,JavaScript 就会继续执行,而不是让出并位于任务队列的后面。这可能会显著提升性能,如发布意图中所详述,对于可能不会让出主线程的网站,这种做法尤其有效。
不过,自该 API 发布以来,我们对让出内存的理解不断加深,尤其是在引入 INP 后。出于多种原因,我们不再建议使用此 API,而是建议无论输入是否待处理,都进行让出:
- 尽管在某些情况下用户发生了互动,
isInputPending()
仍可能会错误地返回false
。 - 任务应让出执行权的情况并不仅限于输入。动画和其他常规界面更新对于提供自适应网页同样重要。
- 此后,引入了更全面的收益生成 API 来解决收益问题,例如
scheduler.postTask()
和scheduler.yield()
。
总结
管理任务是一项具有挑战性的工作,但这样做可确保您的网页更快地响应用户互动。管理任务和确定任务优先级并非一成不变的建议,而是可以使用很多不同的技术。再次强调,下面是您在管理任务时需要考虑的主要事项:
- 让出主线程以执行面向用户的关键任务。
- 使用
postTask()
确定任务的优先级。 - 不妨考虑使用“
scheduler.yield()
”进行实验。 - 最后,尽量减少在函数中执行的工作。
使用这些工具中的一个或多个,您应该能够在应用中构建工作的结构,以便它优先考虑用户的需求,同时确保仍能完成不太重要的工作。这将带来更好的用户体验,响应速度更快,使用起来更愉快。
特别感谢 Philip Walton 对本指南进行了技术审核。
缩略图图片来自 Unshot,由 Amirali Mirhashemian 提供。