常言道“不要阻塞主线程”和“拆分长任务”,但执行这些任务意味着什么?
如果您阅读了大量关于网络性能的资料,关于如何使 JavaScript 应用保持快速运行的建议往往涉及以下一些小窍门:
- “请勿阻塞主线程。”
- “拆分长任务。”
这意味着什么?减少交付 JavaScript 固然不错,但这是否会自动等同于在整个网页生命周期中拥有更快速的界面?可能会,但可能不是。
要了解使用 JavaScript 优化任务的重要性,您需要了解任务的作用以及浏览器如何处理任务,首先要了解什么是任务。
什么是任务?
“任务”是指浏览器执行的任何独立工作。任务涉及的工作包括渲染、解析 HTML 和 CSS、运行您编写的 JavaScript 代码以及您无法直接控制的其他事情。其中,您编写并部署到网络的 JavaScript 是主要任务来源。
任务会在以下几个方面影响性能。例如,当浏览器在启动期间下载一个 JavaScript 文件时,它会将任务加入队列以解析和编译该 JavaScript,以便能够执行该 JavaScript。在网页生命周期的后期阶段,当您的 JavaScript 正常运行时,任务便会启动,例如通过事件处理脚本、JavaScript 驱动的动画和后台活动(例如 Analytics 数据收集)驱动互动。所有这些操作(Web 工作器和类似 API 除外)都发生在主线程上。
主线程是什么?
主线程是在浏览器中运行大多数任务的位置。我们将它称为主线程,是因为它是一个线程,您编写的几乎所有 JavaScript 都会在这个线程中工作。
主线程一次只能处理一个任务。如果任务的延时时间超过某一点(确切来说是 50 毫秒),则会被归类为耗时较长的任务。如果用户在运行耗时较长的任务时尝试与网页互动,或者如果需要进行重要的呈现更新,浏览器在处理相应工作时会延迟。这会导致交互或呈现延迟。
您需要将任务拆分。也就是说,需要将一个较长的任务分成若干个更小的任务,分别耗时较少的任务。
这一点很重要,因为当任务拆分后,浏览器有更多机会响应优先级较高的工作,其中包括用户互动。
在上图的顶部,由用户互动排队的事件处理脚本必须等待单个较长的任务才能运行,这会延迟交互的发生。在底部,事件处理脚本有机会更快地运行。因为事件处理脚本有机会在一些小任务之间运行,所以它的运行速度比必须等待较长的任务完成时要快。在上方的示例中,用户可能注意到了延迟;在底部的示例中,互动感觉像是即时互动。
问题在于,“拆分长任务”和“不要阻塞主线程”的建议是不够具体的,除非您已经知道如何完成这些任务。本指南将详细阐述。
任务管理策略
在软件架构方面,常见的建议是将工作分解为更小的函数。这样可以提高代码可读性和项目可维护性。这也使得测试更易于编写。
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在此示例中,有一个名为 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()
并非适合此作业的工具 - 至少不能以这种方式使用。
除了 setTimeout()
之外,还有几个其他 API 可让您将代码执行推迟到后续任务。一种方案涉及使用 postMessage()
以加快超时速度。您还可以使用 requestIdleCallback()
拆分工作,但要注意!requestIdleCallback()
会将任务安排为尽可能低的优先级,并且仅在浏览器空闲时执行。当主线程拥塞时,使用 requestIdleCallback()
调度的任务可能永远无法运行。
使用 async
/await
创建屈服点
在本指南的其余部分,您会看到“yield to the main thread”这个短语,但这是什么意思呢?为什么要这样做?您应在何时执行此操作?
当任务被分解后,浏览器的内部优先级方案可以更好地确定其他任务的优先级。让步于主线程的一种方式是组合使用通过调用 setTimeout()
进行解析的 Promise
:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
在 saveSettings()
函数中,如果您在每次函数调用后都 await
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:
await yieldToMain();
}
}
结果是,曾经的单体式任务现在被分解成了单独的任务。
与手动使用 setTimeout()
相比,使用基于 Promise 的方法进行生成,好处更多,更符合工效学要求。挂起点变为声明式,因此更易于写入、读取和理解。
仅在必要时收益
如果您有大量任务,但只想在用户尝试与网页互动时让步,该怎么办?这正是isInputPending()
的初衷。
isInputPending()
是一个可随时运行的函数,用于确定用户是否尝试与页面元素互动:调用 isInputPending()
将返回 true
。否则返回 false
。
假设您有一个需要运行的任务队列,但您不希望干扰任何输入。此代码使用 isInputPending()
和我们的自定义 yieldToMain()
函数,可确保在用户尝试与网页互动时输入不会延迟:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
while (tasks.length > 0) {
// Yield to a pending user input:
if (navigator.scheduling.isInputPending()) {
// There's a pending user input. Yield here:
await yieldToMain();
} else {
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
}
当 saveSettings()
运行时,它会循环遍历队列中的任务。如果 isInputPending()
在循环期间返回 true
,则 saveSettings()
将调用 yieldToMain()
,以便处理用户输入。否则,它会将下一个任务移出队列,并持续运行。系统会一直执行此操作,直到不再有任务为止。
将 isInputPending()
与收益机制结合使用,是让浏览器停止其正在处理的任何任务的好方法,以便响应面向用户的关键互动。这有助于提高网页在多项任务同时进行时响应用户的能力。
使用 isInputPending()
的另一种方式(尤其是在您担心为不支持它的浏览器提供回退机制时)是将基于时间的方法与可选链接运算符结合使用:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
let deadline = performance.now() + 50;
while (tasks.length > 0) {
// Optional chaining operator used here helps to avoid
// errors in browsers that don't support `isInputPending`:
if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
// There's a pending user input, or the
// deadline has been reached. Yield here:
await yieldToMain();
// Extend the deadline:
deadline = performance.now() + 50;
// Stop the execution of the current loop and
// move onto the next iteration:
continue;
}
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
通过这种方法,您可以回退到不支持 isInputPending()
的浏览器,方法是使用基于时间的方法,该方法使用(并调整)截止时间,以便在必要情况下拆分工作,无论是让于用户输入,还是在某个时间点之前进行拆分。
当前 API 存在的差距
到目前为止提到的 API 可以帮助您拆分任务,但它们有一个很大的缺点:如果您通过推迟代码在后续任务中运行来让出主线程,该代码就会被添加到任务队列的最末端。
如果您控制网页上的所有代码,则可以自行创建调度程序,并设定任务优先级,但第三方脚本不会使用您的调度程序。事实上,您在此类环境中无法真的优先处理工作。您只能分多次进行讨论,或者明确地由用户互动。
幸运的是,目前正在开发中的专用调度器 API 可以解决这些问题。
专用调度器 API
调度程序 API 目前提供 postTask()
函数,在编写代码时,该函数在 Chromium 浏览器和 Firefox 中均可用(通过一个标志实现)。postTask()
支持更精细的任务调度,可帮助浏览器确定工作优先级,使低优先级的任务让出主线程。postTask()
使用 promise,并接受 priority
设置。
您可以使用 postTask()
API 的三个优先级:
'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()
让主线程让出去。 - 使用
postTask()
确定任务的优先级。 - 最后,尽量少在函数中执行操作。
借助上述一种或多种工具,您应该能够设计应用中的工作结构,使其能够优先满足用户需求,同时确保不太重要的工作仍然完成。这将带来更好的用户体验,响应速度更快,使用起来更愉悦。
特别感谢 Philip Walton 对本文进行技术审查。
主打图片来自 Unsplash 用户,由 Amirali Mirhashemian 提供。