避免阻塞主线程和拆分长任务的实际方法
保持JavaScript应用快速运行的常见建议通常包括: - “不要阻塞主线程” - “将耗时任务拆分开来”
这些建议很好,但实际操作起来是怎样的呢?虽然减少JavaScript代码量是好的做法,但这并不一定会自动提升用户界面的响应性。要理解如何优化JavaScript中的任务,首先需要了解什么是任务,以及浏览器如何处理它们。
什么是任务?
任务是指浏览器执行的独立工作单元,包括渲染、HTML和CSS解析、JavaScript执行等开发者无法直接控制的工作。其中,开发者编写的JavaScript代码可能是任务最主要的来源。
与JavaScript相关的任务主要通过以下两种方式影响性能:
- 当浏览器在启动时下载JavaScript文件时,解析和编译这些JavaScript的任务会被加入队列,以便后续执行。
- 在页面生命周期的其他时间点,当JavaScript处理事件处理器交互响应、JavaScript驱动的动画、分析数据收集等后台活动时,任务也会被加入队列。
除了Web Workers和类似API外,所有这些处理都在主线程上进行。
主线程是什么?
主线程是浏览器中执行大多数任务的地方,也是几乎所有开发者编写的JavaScript代码运行的地方。
主线程一次只能处理一个任务。任何超过50毫秒的任务都被视为长任务。对于超过50毫秒的任务,其总时间减去50毫秒即为任务的阻塞时间。
浏览器在执行任务期间会阻止其他操作发生,只要任务执行时间不太长,用户通常不会察觉到阻塞。然而,当存在多个长任务时,如果用户尝试与页面交互,主线程会被长时间阻塞,导致用户界面无响应,甚至可能看起来像是崩溃了。
为了防止主线程长时间阻塞,需要将长任务拆分为多个较小的任务。
任务拆分的重要性在于,它使得浏览器能够更快地响应用户操作等高优先级工作。之后,剩余的任务会继续执行,直到完成最初加入队列的所有工作。
在上图的上半部分,用户操作触发的事件处理器需要等待一个长任务完成后才能开始执行,导致操作延迟。在这种情况下,用户很可能会察觉到延迟。而在下半部分,事件处理器能够更快执行,使得交互感觉瞬间完成。
既然理解了任务拆分的重要性,接下来让我们学习如何在JavaScript中实际拆分任务。
任务管理策略
软件架构中常见的建议是将工作拆分为小函数:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在这个例子中,saveSettings() 函数调用了五个函数,分别负责表单验证、显示加载指示器、向应用后端发送数据、更新用户界面以及发送分析信息。
从概念上讲,saveSettings() 的设计是合理的。如果需要调试其中任何一个函数,可以顺着项目树查看每个函数的具体实现。这种工作拆分方式使得项目导航和维护更加容易。
然而,由于这些函数都在 saveSettings() 函数内部执行,JavaScript并不会将它们作为独立任务来执行。这意味着五个函数全部作为一个单一任务运行。
在最好的情况下,仅仅其中一个函数就可能使任务总时间增加50毫秒以上。在最坏的情况下,这些任务的执行时间可能会显著延长(特别是在资源受限的设备上)。
在这种情况下,saveSettings() 由用户点击触发。由于浏览器需要等到整个函数执行完成后才能显示响应,这个长任务的结果就是UI延迟和无响应,导致Interaction to Next Paint(INP)得分较低。
关键点: JavaScript之所以这样工作,是因为它使用任务执行完成模型。这意味着每个任务都会运行到完成,无论它阻塞主线程多长时间。
手动延迟代码执行
为了确保重要的用户任务和UI响应比低优先级任务优先执行,可以通过暂时中断工作来让出主线程,给浏览器执行关键任务的机会。
开发者长期以来使用 setTimeout() 来将任务拆分为更小的部分。这种技术将函数传递给 setTimeout(),即使将超时时间设为0,回调函数的执行也会被推迟到另一个任务中。
function saveSettings () {
// 执行用户可见的关键工作:
validateForm();
showSpinner();
updateUI();
// 将用户不可见的工作推迟到单独的任务:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
这被称为”让步”(yielding),特别适用于需要按顺序执行的一系列函数。
重要提示: 让出主线程使得浏览器有机会处理比最初排队任务更重要的任务。如果有需要比不让步时更快执行的用户导向工作(如用户界面更新),让出主线程是理想的选择。
然而,代码并不总是这样组织。例如,当需要处理大量数据且迭代次数很多时,任务可能会花费非常长的时间。
function processData () {
for (const item of largeDataArray) {
// 在此处理单个项目
}
}
在这种情况下,使用 setTimeout() 会带来开发者体验上的问题。此外,如果嵌套5个 setTimeout(),浏览器会对每个额外的 setTimeout() 施加4毫秒的最小延迟。
setTimeout 在让步方面还有另一个缺点:通过 setTimeout 延迟后续任务中的代码执行来让出主线程,该任务会被添加到队列末尾。如果有其他等待中的任务,它们会在延迟代码之前执行。
专用让步API:scheduler.yield()
scheduler.yield() 是专门设计用于让出浏览器主线程的API。
它不是语言级别的语法或特殊构造,scheduler.yield() 只是一个返回在未来任务中解决的Promise的函数。链接到该Promise解决后执行的代码(在显式的 .then() 链中,或在异步函数中使用 await 后)将在后续任务中执行。
实际上,在函数中插入 await scheduler.yield() 会使函数在该点暂停执行,并将控制权交给主线程。函数的剩余部分(函数的延续)将被安排在新的任务中执行。当该任务开始时,等待的Promise会被解决,函数将从暂停的地方继续执行。
async function saveSettings () {
// 执行用户可见的关键工作:
validateForm();
showSpinner();
updateUI();
// 让出主线程:
await scheduler.yield()
// 用户不可见的工作,在单独的任务中继续:
saveToDatabase();
sendAnalytics();
}
关键点: 不需要在每次函数调用时都让步。例如,如果执行两个会导致用户界面关键更新的函数,在它们之间让步可能并不理想。如果可能,首先执行这些工作,然后在用户看不到的低优先级或后台工作的函数之间考虑让步。
scheduler.yield() 相对于其他让步方法的真正优势在于其延续具有优先权。这意味着在任务中途让步时,当前任务的延续将在其他类似任务开始之前执行。
这可以防止来自其他任务源(如第三方脚本的任务)的代码中断执行顺序。
跨浏览器支持
scheduler.yield() 尚未在所有浏览器中得到支持,因此需要备用方案。
一种解决方案是在构建中包含scheduler-polyfill。这样可以直接使用 scheduler.yield()。polyfill会处理到其他任务调度函数的回退,确保在不同浏览器中表现一致。
也可以编写一个更简单的版本,仅使用 setTimeout 包装的Promise作为 scheduler.yield() 不可用时的回退。
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// 回退到使用setTimeout让步
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
注意: 确保检查全局scheduler对象及其yield方法的存在。
在不支持 scheduler.yield() 的浏览器中,虽然不会发生优先延续,但仍然会进行让步以保持浏览器的响应性。
最后,如果延续不能优先执行(例如,在已知繁忙状态的页面上,让步可能导致处理长时间无法完成的风险),代码可能无法让出主线程。在这种情况下,scheduler.yield() 可以视为一种渐进增强手段:在支持 scheduler.yield() 的浏览器中让步,否则继续执行。
可以通过一行便捷代码实现功能检测和回退到单个微任务的等待:
// 如果scheduler.yield()可用,让出主线程
await globalThis.scheduler?.yield?.();
使用scheduler.yield()拆分长时间运行的处理
scheduler.yield() 的一个优点是可以在任何异步函数中使用 await。
例如,如果有一个需要执行的作业数组,且它们经常形成长任务,可以插入让步点来拆分任务。
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// 运行作业:
job();
// 让出主线程:
await yieldToMain();
}
}
重要提示: scheduler.yield() 只负责任务的调度,实际暂停执行并等待新任务开始的是 await。不要忘记使用 await,同时注意 Array.prototype.forEach 等迭代方法不会等待每个回调解决后再进行下一次迭代。
runJobs() 的延续具有优先权,但浏览器也能执行高优先级处理,如对用户输入的视觉响应。无需等待长作业列表全部完成。
然而,这并不是最高效的让步使用方法。虽然 scheduler.yield() 快速高效,但仍有开销。如果 jobQueue 中的某些作业非常短,开销会迅速累积,导致在让步和恢复上花费的时间比实际工作执行时间还长。
一种方法是批量处理作业,仅在上次让步后经过足够时间时才在作业间让步。常见的截止时间是50毫秒,这可以防止任务变成长任务,但需要在响应性和作业队列完成时间之间进行权衡调整。
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// 运行作业:
job();
// 如果超过截止时间,让出主线程:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
这样,作业会被拆分以确保执行时间不会过长,但运行器每50毫秒才让出一次主线程控制权。
不建议使用isInputPending()
isInputPending() API 提供了一种检查用户是否尝试与页面交互的方法,仅在有待处理输入时才返回结果。
这使得JavaScript可以在没有待处理输入时继续执行,而不是在任务队列末尾中断并结束。如Intent to Ship中详细说明的那样,这可以显著提高不主动让出主线程的网站的性能。
然而,自该API发布以来,特别是随着INP的引入,我们对让步的理解更加深入。不建议使用此API,而是建议无论是否有待处理输入都进行让步,原因如下:
isInputPending()可能在用户已操作的情况下误报false- 任务需要让步的原因不仅限于输入,动画和其他常规用户界面更新同样对提供响应式网页至关重要
此后,引入了更全面的让步API,如 scheduler.postTask() 和 scheduler.yield(),解决了与让步相关的问题。
总结
任务管理可能具有挑战性,但通过有效管理任务,可以使页面快速响应用户操作。任务管理和优先级排序没有单一的方法,而是涉及多种技术。在管理任务时,主要考虑以下几点:
- 为重要的用户导向任务让出主线程
- 使用
scheduler.yield()(带有跨浏览器回退)以获得符合人体工程学的优先延续 - 最后,尽量减少函数中执行的工作量
有关 scheduler.yield()、其显式任务调度对应物 scheduler.postTask() 和任务优先级的更多信息,请参阅优先级任务调度API文档。
通过使用这些工具中的一个或多个,可以构建应用程序的工作结构,在优先考虑用户需求的同时,确保低优先级工作也能得到执行。这样可以实现响应更快、使用更愉悦的用户体验。
正在加载评论...