原文链接:macarthur.me/posts/long-…
我们常会有意将耗时的长任务拆分到事件循环的多个执行周期中执行,但可供选择的实现方式实在太多了。今天,我们就来逐一探索这些方法。
作者: Alex MacArthur
让一个耗时的长任务霸占主线程,很容易就会毁掉网站的用户体验。无论应用的逻辑变得多复杂,浏览器的事件循环始终一次只能执行一个任务。如果你的代码一直占用着主线程,其他所有操作都会处于等待状态,而用户往往很快就能察觉到这种卡顿。
来看一个简单的示例:页面上有一个按钮,点击可增加屏幕上的计数,同时还有一个执行密集计算的大型循环。这个循环只是模拟了同步的耗时操作,你可以把它想象成一些有实际业务意义、因某些原因必须在主线程按顺序执行的代码。
<button id="button">计数</button>
<div>点击次数:<span id="clickCount">0</span></div>
<div>循环次数:<span id="loopCount">0</span></div>
<script>
function waitSync(milliseconds) {
const start = Date.now();
while (Date.now() - start < milliseconds) {}
}
button.addEventListener("click", () => {
clickCount.innerText = Number(clickCount.innerText) + 1;
});
const items = new Array(100).fill(null);
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
waitSync(50);
}
</script>
运行这段代码后,你会发现页面上的内容完全没有视觉更新,就连循环次数都不会变化。这是因为浏览器根本没有机会对页面进行重绘。无论你疯狂点击按钮,页面都只会保持初始状态,只有当循环完全执行完毕后,你才能看到所有的反馈结果。
开发者工具的火焰图也印证了这一点:事件循环中的这一个任务耗时整整 5 秒,体验糟糕透顶。
如果你遇到过类似的情况,就会知道解决办法是将这个大任务定期拆分到事件循环的多个执行周期中。这样浏览器的其他模块就有机会使用主线程处理一些重要操作,比如响应按钮点击、重绘页面。我们的目标是将执行流程从:
变成:
实现这个目标的方法其实多到令人惊讶,我们来探索其中几种,从最经典的递归 + 定时器开始。
方法 1:setTimeout() + 递归
如果你在原生 Promise 出现之前就写过 JavaScript,那你一定见过这种写法:在定时器的回调函数中递归调用自身。
function processItems(items, index) {
index = index || 0;
var currentItem = items[index];
console.log("处理元素:", currentItem);
if (index + 1 < items.length) {
setTimeout(function () {
processItems(items, index + 1);
}, 0);
}
}
processItems(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);
即便在今天,这种写法也没有任何问题。毕竟我们的目标已经达成 ——每个元素都在事件循环的不同周期处理,分散了计算压力。来看火焰图中 400 毫秒的片段,原本的一个大任务,被拆分成了多个小任务:
这样一来,页面的交互性会变得非常好:点击事件能正常响应,浏览器也能及时重绘页面更新内容:
但如今距离 ES6 发布已经过去十年了,浏览器提供了更多实现相同功能的方法,借助 Promise,这些方法的写法会更加优雅。
方法 2:Async/Await 结合定时器
这种组合让我们可以抛弃递归,让代码逻辑更简洁:
<button id="button">计数</button>
<div>点击次数:<span id="clickCount">0</span></div>
<div>循环次数:<span id="loopCount">0</span></div>
<script>
function waitSync(milliseconds) {
const start = Date.now();
while (Date.now() - start < milliseconds) {}
}
button.addEventListener("click", () => {
clickCount.innerText = Number(clickCount.innerText) + 1;
});
(async () => {
const items = new Array(100).fill(null);
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
await new Promise((resolve) => setTimeout(resolve, 0));
waitSync(50);
}
})();
</script>
这样写就舒服多了,只是一个简单的for循环,加上对 Promise 解析的等待。这段代码在事件循环中的执行节奏和方法 1 非常相似,只有一个关键区别(红色标注部分):
Promise 的.then()方法始终在微任务队列中执行,且会在调用栈中的所有同步代码执行完毕后触发。这一点差异在大多数情况下几乎可以忽略,但依然值得我们注意。
方法 3:scheduler.postTask()
调度器Scheduler接口是 Chromium 系浏览器中新增的特性,它的设计目标是成为一个一等公民级的任务调度工具,让我们能更灵活、高效地控制任务执行。它本质上是几十年来我们一直依赖的setTimeout()的升级版。
const items = new Array(100).fill(null);
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
await new Promise((resolve) => scheduler.postTask(resolve));
waitSync(50);
}
用postTask()执行循环的有趣之处,在于调度任务之间的时间间隔。我们再来看 400 毫秒的火焰图片段,可以发现每个新任务都会在前一个任务执行完毕后立即触发,间隔非常短。
postTask()的默认优先级是user-visible(用户可见) ,这个优先级和setTimeout(() => {}, 0)基本相当。任务的执行结果似乎总是和代码中的书写顺序一致:
setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask"));
// 输出:
// setTimeout
// postTask
scheduler.postTask(() => console.log("postTask"));
setTimeout(() => console.log("setTimeout"));
// 输出:
// postTask
// setTimeout
但和setTimeout()不同的是,postTask()是专为任务调度设计的,不会受到定时器的各种限制。由它调度的所有任务都会被放到任务队列的前端,避免其他任务插队导致执行延迟,尤其是在快速连续调度任务时,这个优势会更明显。
虽然无法百分百确定,但我认为正是因为postTask()是一个功能单一、实现完善的工具,火焰图才会呈现出这样的执行效果。当然,我们还可以为postTask()调度的任务设置更高的优先级:
scheduler.postTask(() => {
console.log("postTask");
}, { priority: "user-blocking" });
user-blocking(用户阻塞) 优先级适用于对页面用户体验至关重要的任务(比如响应用户输入)。因此,这个优先级可能并不适合仅仅用来拆分长任务 —— 毕竟我们的初衷是友好地让出主线程,让其他任务有机会执行。事实上,我们甚至可以将优先级设置得更低,使用background(后台) 优先级:
scheduler.postTask(() => {
console.log("postTask - background");
}, { priority: "background" });
setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask - default"));
// 输出:
// setTimeout
// postTask - default
// postTask - background
遗憾的是,整个 Scheduler 接口存在一个短板:目前并非所有浏览器都支持。但好在我们可以用现有的异步 API 为它做兼容处理,因此至少大部分用户能从中受益。
那requestIdleCallback()呢?
说到主动让出任务优先级,你可能会想到requestIdleCallback()。这个 API 的设计初衷是在浏览器的空闲时段执行回调函数,但它的问题在于:技术上无法保证回调函数的执行时间,甚至无法保证是否会执行。你可以在调用时设置一个超时时间,但即便如此,你依然需要面对一个事实 ——Safari 浏览器至今完全不支持这个 API。
除此之外,MDN 也建议,对于必须执行的任务,使用定时器比requestIdleCallback()更合适。因此,为了拆分长任务,我大概率会直接避开这个 API。
方法 4:scheduler.yield()
Scheduler 接口的yield()方法,比我们之前介绍的所有方法都更特殊,因为它就是为拆分长任务这个场景量身打造的。根据 MDN 的定义:
Scheduler 接口的
yield()方法用于在任务执行过程中向主线程交出执行权,并在后续将继续执行的逻辑调度为一个带优先级的任务…… 这一特性可以将长时间运行的任务拆分,保证浏览器的响应性。
当你第一次使用这个方法时,会更深刻地体会到这一点:我们不再需要手动创建并解析 Promise,只需等待它内置的 Promise 即可:
const items = new Array(100).fill(null);
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
await scheduler.yield();
waitSync(50);
}
它还能让火焰图的执行栈更简洁,你会发现执行栈中需要解析的内容少了一项:
这个 API 的设计实在太优雅了,让你忍不住想在各个场景中使用它。比如,有一个复选框,在change事件触发时会执行一个耗时任务:
document
.querySelector('input[type="checkbox"]')
.addEventListener("change", function (e) {
waitSync(1000);
});
按照这样的写法,点击复选框后,页面会冻结 1 秒钟。
但现在,我们可以让代码立即向浏览器交出执行权,让浏览器在点击后有机会更新 UI:
document
.querySelector('input[type="checkbox"]')
.addEventListener("change", async function (e) {
+ await scheduler.yield();
waitSync(1000);
});
你看,这样处理后,页面的交互就变得非常流畅了。
和 Scheduler 接口的其他特性一样,这个方法的浏览器支持度也不高,但做兼容处理依然很简单:
globalThis.scheduler = globalThis.scheduler || {};
globalThis.scheduler.yield =
globalThis.scheduler.yield ||
(() => new Promise((r) => setTimeout(r, 0)));
方法 5:requestAnimationFrame()
requestAnimationFrame() API 的设计初衷是围绕浏览器的重绘周期调度任务,因此它对回调函数的调度非常精准。回调函数总会在下一次页面重绘之前执行,这或许就能解释为什么火焰图中的这些任务执行间隔会如此之近。
动画帧的回调函数实际上有自己的专属队列,这个队列会在渲染阶段的特定时间执行,这意味着其他任务很难插队,也不会让动画帧任务被挤到队列末尾。
然而,在页面重绘前后执行耗时任务,似乎会影响页面的渲染效果。来看同一时间段内的帧时序图,黄色的条纹区域表示 “部分渲染的帧”,说明页面渲染出现了异常:
这种情况在其他拆分长任务的方法中并不会出现。考虑到这一点,再加上只有当标签页处于激活状态时,动画帧的回调函数才会执行,我大概率也会避开这个方法。
方法 6:MessageChannel()
你很少会看到这种用法,但它有时会被选为零延迟定时器的轻量替代方案。这种方法不会让浏览器创建定时器并调度回调,而是创建一个消息通道,立即向其发送消息:
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
await new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = resolve;
channel.port2.postMessage(null);
});
waitSync(50);
}
从火焰图的表现来看,这个方法的性能似乎有一定优势,每个调度任务之间的延迟非常短:
但这种方法存在一个主观上的缺点:代码实现过于繁琐。很明显,这个 API 的设计初衷并非用于拆分长任务。
方法 7:Web Workers
尽管我们介绍了多种主线程内的拆分方法,但如果你的任务可以在主线程外执行,那么 Web Worker 毫无疑问是你的首选。技术上,你甚至不需要单独的文件来编写 Worker 代码:
const items = new Array(100).fill(null);
const workerScript = `
function waitSync(milliseconds) {
const start = Date.now();
while (Date.now() - start < milliseconds) {}
}
self.onmessage = function(e) {
waitSync(50);
self.postMessage('处理完成!');
}
`;
const blob = new Blob([workerScript], { type: "text/javascript" });
const worker = new Worker(window.URL.createObjectURL(blob));
for (const i of items) {
worker.postMessage(items);
await new Promise((resolve) => {
worker.onmessage = function (e) {
loopCount.innerText = Number(loopCount.innerText) + 1;
resolve();
};
});
}
来看一看,当每个元素的处理逻辑都放到 Worker 中执行后,主线程会变得异常空闲。所有的耗时计算都被放到了火焰图下方的 “Worker” 区域,主线程因此有了大量空间来处理其他交互操作。
我们这个示例的需求是在 UI 中实时反映任务执行进度,因此我们需要逐个将元素传递给 Worker,并等待其执行完成的响应。但如果你的业务场景允许一次性将所有元素传递给 Worker,那一定要这么做,这会进一步减少通信开销。
该如何选择?
我们介绍的这些方法并非穷举,但它们足以代表拆分长任务时需要考虑的各种取舍。当然,根据实际需求,我自己大概率只会选择其中的一部分方法。
- 如果任务可以在主线程外执行:我会毫不犹豫地选择 Web Worker。它的浏览器支持度非常高,设计初衷就是为了将计算任务从主线程卸载。唯一的缺点是 API 使用起来比较繁琐,但 Workerize、Vite 内置的 Worker 导入等工具可以缓解这个问题。
- 如果需要一种极简的拆分方式:我会选择
scheduler.yield()。虽然为了兼容非 Chromium 系浏览器,我需要为它做兼容处理,但考虑到大部分用户都能从中受益,我愿意接受这一点额外的代码成本。 - 如果需要对拆分后的任务进行精细的优先级控制:
scheduler.postTask()会是我的选择。这个 API 能根据需求进行深度定制,令人印象深刻。它支持优先级控制、任务延迟、任务取消等诸多功能,和.yield()一样,目前只需要做一点兼容处理即可。 - 如果浏览器兼容性和可靠性是首要考虑因素:我会直接选择
setTimeout()。即便有各种花里胡哨的替代方案出现,这个经典的 API 也依然坚挺,不会被淘汰。
我还有遗漏吗?
我承认,其中有几种方法我从未在实际项目中使用过,因此本文的内容可能存在一些盲区。如果你能对这个话题做更多补充,哪怕是关于其中某一种方法的见解,都非常欢迎在评论区留言。