经常听人说“不要阻塞主线程”,或者“减少长耗时”,到底该如何落地?聊网站性能的文章很多,通常为了提高 JavaScript 性能,绕不开这两点:
该怎么做呢?精简 JavaScript 代码当然有好处,但更少的代码量是否就一定意味着用户界面的体验更顺畅?可能会,但也可能恰恰相反。
要弄懂优化 JavaScript 任务的重要性,首先需要了解什么是任务、任务的角色,以及浏览器的任务处理机制。
浏览器中的任务
浏览器执行的任务之间相互独立,像页面渲染、HTML 和 CSS 的解析,以及执行 JavaScript 代码,都属于任务的范畴。虽然开发者不能直接控制这些任务,但毫无疑问,浏览器中的任务主要源自开发者编写和部署的代码。

上图中的任务就是 Chrome DevTools 性能剖析中点击事件触发的。从图中能看到,任务在顶端,任务下面列出了点击事件、调用的函数,此外还调用了很多其他方法。
任务影响性能的方式很多。比如打开网站时下载 JavaScript 代码,浏览器会把任务放到队列中暂不执行,而是准备解析和编译 JavaScript 以防止阻塞。之后网站上的任务才会因为用户交互驱动事件处理器、JavaScript 动画以及分析收集的后台活动等 JavaScript 活动而触发——Web Worker 例外。
什么是主线程?
浏览器绝大多数任务都发生在主线程,名称的由来也主要是因为:几乎所有 JavaScript 都在主线程上运行。
主线程每次只能处理一个任务。当任务耗时超过特定时间,比如 50ms,就会被归类为长耗时。如果发生长耗时期间存在用户交互或关键渲染更新,浏览器就会延后处理用户交互,直接导致交互延迟或渲染卡顿。
谷歌性能剖析中的长耗时一般会在任务角上标出红色三角形,其中被阻塞的任务部分用红色细斜条纹标识。优化长耗时,意味着将单个长耗时任务拆解成几个耗时相对短的小任务,可以查看下图。

如上图所示,单个长任务被拆分成了 5 个短任务。
为什么拆分任务如此重要?因为拆分长任务后,浏览器就有了更多机会去处理优先级别更高的工作,其中就包括用户交互行为。

如果任务非常长,浏览器对用户交互的展示如上图左侧——浏览器没法快速处理用户交互;而拆分长任务之后,效果就完全不同了。
因为长任务的缘故,用户交互产生的事件处理必须排队,等待长任务执行完才能执行,这会导致交互延迟。而拆分成较短的任务后,事件处理器就有机会更快触发。因为事件处理器能够在短任务之间得以执行,用户可能就感觉体验很流畅。
那么问题来了:减少长耗时到底该怎么做?“不要阻塞主线程”写得也不够明确。这篇文章就为你解开这些神秘的面纱。
任务管理策略
软件架构中有时会将一个任务拆分成多个函数,这不仅能增强代码可读性,也让项目更容易维护,当然也更容易写测试。
function saveSettings() {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在上面的例子中,函数 saveSettings 调用了另外 5 个函数,包括验证表单、展示加载动画、发送数据到后端等。理论上讲,这是很合理的架构。如果需要调试这些功能,只需要在项目中查找每个函数即可。
然而这样也有问题:JavaScript 并不会为每个方法开辟一个单独的任务。因为这些方法都包含在 saveSetting 这个函数中,也就是说这五个方法在一个任务中执行。
重点提示:JavaScript 遵循执行才编译的原理,也就是说,只有一个任务结束才会执行下一个任务,而且不论这个任务会阻塞主线程多久。

saveSetting 调用 5 个函数,这个函数的执行看起来就像一个特别特别长的任务。
在很多场景中,单个函数耗时可能会超过 50ms,从而使得整体任务耗时更长。如果测试环境不够理想,尤其是在“资源受限”设备上,每个函数的耗时可能都会更久。接下来分享几个优化策略。
使用代码延迟任务执行
为了拆分长任务,开发者经常使用定时器 setTimeout。通过把方法传递给 setTimeout,也就等同于重新创建了一个新的任务,延迟了回调的执行。而且使用该方法,即便是将 delay 值设定成 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,但需要注意这个 API 编排的任务优先级最低,只会在浏览器空闲时执行。当主线程繁忙时,通过 requestIdleCallback 编排的任务可能永远不会执行。
使用 async、await 来创造让步点
这里先解释一个新词——“让步”。当让步于主线程后,浏览器就有机会处理那些更重要的任务,而不是让它们继续在队列中排队。理想状态下,一旦出现用户界面级别的任务,就应该让步给主线程,让任务更快执行完。让步于主线程让更重要的工作能更快完成。
分解任务后,按照浏览器内部的优先级别划分,其他任务的优先级可能被调整得更高。一种让步于主线程的方式是配合用了 setTimeout 的 promise:
function yieldToMain() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
注意:尽管这个例子在返回的 promise 中通过 setTimeout 来调用 resolve,但这并不是新开一个任务让 promise 执行后续代码,而是通过 setTimeout 调用。因为 promise 的回调属于微任务,因此不会让步于主线程。
在 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();
}
}
重要提示:并不是所有函数调用都要让步于主线程。如果两个函数的结果在用户界面上有重要更新,最好就不要拆分。可以的话,让关键任务先执行,然后考虑在那些不重要的函数或者能在后台运行的函数之间让步。
这样的好处是,单个大的长任务被拆分成了多个独立的任务。

现在能看到,saveSetting 函数内的函数现在成为了单独的任务。
通过使用 promise 这种方式,和手动写 setTimeout 相比,在工程上有更多好处。让步的时间点变成了声明式,代码写起来更容易,阅读和理解也更轻松。
只在必要时让步
假如有一堆任务,但只想在用户交互的时候才让步,该怎么办?正好有这样一个 API——isInputPending。
isInputPending 这个函数可以在任何时候调用,它能判断用户是否要与页面元素进行交互。调用 isInputPending 会返回布尔值,true 代表要与页面元素交互,false 则不交互。
比如说,任务队列中有很多任务,但不想阻挡用户输入,使用 isInputPending 和自定义的 yieldToMain 方法,就能够保证用户交互时的 input 不会延迟。
async function saveSettings() {
// 函数队列
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
while (tasks.length > 0) {
// 让步于用户输入
if (navigator.scheduling.isInputPending()) {
// 如果有用户输入在等待,则让步
await yieldToMain();
} else {
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
}
在 saveSetting 执行过程中,会逐个循环队列中的任务。如果循环时 isInputPending 返回 true,saveSetting 就会调用 yieldToMain 函数,这样就能处理用户输入的事件;反之,则继续执行下一个任务,直到队列执行完毕。

saveSetting 任务队列中有 5 个任务,但如果正在执行第二个任务时用户想打开某个菜单,于是点击了这个菜单,isInputPending 就会让步,让主线程处理交互事件,同时稍后继续执行剩余的任务。
用户输入后 isInputPending 的返回值不一定总是 true,这是因为操作系统需要时间来通知浏览器交互结束,也就是说其他代码可能已经开始执行了。比如截图例子中的 saveToDatabase 这个方法可能已经在执行了。即便使用 isInputPending,仍然需要限制每个方法中任务的数量。
使用 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 += 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 对于拆解任务都有帮助,但也有弊端:让步于主线程意味着延迟代码稍后执行,即该部分代码被添加到稍后的事件队列中。
如果能控制页面中所有的代码,就可以编排各个任务的优先级,但第三方 JavaScript 脚本可能不会服从安排。实际上,也不可能真正意义上给所有的任务排优先级,只能让它们成堆,或者让步于特定的用户交互行为。
幸运的是,有一个专门编排优先级的 API 正在开发中,相信能够解决这些问题。
专门编排优先级的 API
目前在书写本文时,该 API 提供 postTask 的功能,对于所有 Chromium 内核的浏览器和 Firefox 均可使用。
postTask 允许更细粒度地编排任务,该方法能让浏览器编排任务的优先级,以便低优先级别的任务能够让步于主线程。目前 postTask 使用 promise,接受优先级参数设定。
postTask 方法有三个优先级别:
background 级:适用于优先级别最低的任务
user-visible 级:适用于优先级别中等的任务,也是该函数的默认参数(无入参时)
user-blocking 级:适用于优先级别最高的任务
拿下面的代码举例,三个关键任务都用最高优先级别调度,另外两个任务则使用最低优先级别:
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' });
}
在上面例子中,通过这种优先级编排方式,能让浏览器的高优先级别任务——比如用户交互——得以及时触发。

当 saveSettings 方法执行时,会使用 postTask 来编排每个方法。关键的用户侧任务优先级别高,用户无感知的任务按照 background 级别执行,这就可以提高整体响应能力。
这是使用 postTask 的一个非常简单的例子。你可以用不同的 TaskController 对象来区分,这样能在不同任务之间共享优先级别,也能为不同的 TaskController 实例变更优先级。
重点提示:postTask 并不是所有浏览器都支持。可以检测是否可用,或者考虑使用 polyfill。
内置不中断的让步方法
还有一个编排 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() 方法。

下面三幅图分别对应:不使用 yield、使用 yield、以及使用 yield 且不中断。
- 不使用 yield:出现了长耗时任务。
- 使用 yield:短任务数量变多了,而且还能被其他不相关的任务打断。
- 使用 yield 且不中断:短任务更多,但执行顺序是固定的。
使用 scheduler.yield 的好处是“不中断”。也就是说,如果在一连串任务中 yield,那么从 yield 的时间点开始,其他编排好的任务会继续执行。这就能避免第三方 JavaScript 脚本代码阻塞代码的执行。
结语
虽然管理任务富有挑战,但收益也颇多——网站能获得更快的用户交互体验。管理和调优没有万灵药,但确有一系列不同的技巧。最后总结重申一下,管理任务时主要需要考虑以下几点:
- 遇到关键任务和用户侧任务时,需要让步于主线程
- 使用
isInputPending 来让步主线程,让用户可以与页面交互
- 使用
postTask 来调整任务的优先级
- 每个函数尽可能地减少活动
使用以上一个或多个方法,就能够将应用中的任务进行管理,根据用户需要调整优先级,同时保证相对不那么重要的工作得以继续执行。这样能创造更好的用户体验,网站响应更快,使用更令人愉悦。
如果你想继续深挖前端性能优化与算法架构的系统方法,欢迎访问云栈社区,与更多开发者一起探讨技术话题。