用 DeepSeek 翻译一篇好文章,翻译不合理部分稍作修改
JavaScript 中分解长任务的多种方法
在 JavaScript 中,将耗时长、开销大的任务拆解到事件循环的多个周期中是常见做法。但可供选择的方法实在太多,让我们一探究竟。
如果放任一个耗时长、开销大的任务独占主线程,很容易破坏网站的用户体验。无论应用多复杂,事件循环一次只能处理一件事。如果代码长期占用主线程,其他操作都会被搁置,用户通常很快就能察觉到问题。
来看一个刻意设计的例子:我们有一个用于增加屏幕计数的按钮,同时存在一个执行繁重工作的大循环。这里虽然只是同步暂停,但请假设这是你出于某些原因必须在主线程上按顺序执行的重要操作。
html
<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>
运行此代码时,视觉上不会有任何更新------连循环计数都不变。因为浏览器始终没有机会渲染屏幕。无论你如何狂点按钮,都只能看到初始状态。只有当循环完全结束时才会获得反馈。
开发者工具的性能火焰图印证了这一点。事件循环中的这个单一任务耗时五秒才完成,糟糕至极。
如果你曾遇到过类似情况,就知道解决方案是定期将这个大型任务拆解到事件循环的多个周期中。这能让浏览器主线程有机会处理其他重要事务,如处理按钮点击和重新渲染。我们的目标是从这样:
变成这样:
实现这个目标的方法多得惊人。我们将从最经典的方法开始探索:递归。
#1: setTimeout()
+ 递归
如果你在原生 Promise 出现前就写过 JavaScript,一定见过这种写法:函数在定时器回调中递归调用自身。
js
function processItems(items, index) {
index = index || 0;
var currentItem = items[index];
console.log("processing item:", currentItem);
if (index + 1 < items.length) {
setTimeout(function () {
processItems(items, index + 1);
}, 0);
}
}
processItems(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);
即使在今天,这个方法依然有效。毕竟目标达成了------每个项目的处理都在不同周期进行,分摊了工作量。看这个 400ms 的火焰图切片,我们得到了一堆小任务而非单个大任务:
这让 UI 保持流畅响应。点击处理可以正常工作,浏览器也能渲染屏幕更新:
但 ES6 发布已逾十年,浏览器提供了多种更符合人体工程学的方式来实现相同目标,其中大部分借助了 Promise。
#2: 异步函数 + 超时
这种组合让我们可以放弃递归并简化代码:
html
<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 解析。事件循环的节奏非常相似,但有一个关键变化(用红色标出):
Promise 的 .then()
方法总是在微任务队列执行,在调用栈清空后立即处理。虽然差异几乎可以忽略,但仍值得注意。
#3: scheduler.postTask()
Chromium 浏览器新增的调度器接口旨在成为调度任务的一流工具,提供更精细的控制和更高的效率。本质上这是对 setTimeout
数十年来功能的升级。
js
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()
运行循环的有趣之处在于任务间隔时间。这是 400ms 的火焰图切片,注意每个新任务与前一个的紧密度:
postTask()
的默认优先级是 "user-visible",似乎与 setTimeout(() => {}, 0)
的优先级相当。输出顺序总是与代码执行顺序一致:
js
setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask"));
// 输出顺序
// setTimeout
// postTask
js
scheduler.postTask(() => console.log("postTask"));
setTimeout(() => console.log("setTimeout"));
// 输出顺序
// postTask
// setTimeout
但与 setTimeout
不同,postTask()
专为调度设计,不受超时机制的限制。所有通过它调度的任务都会被放在任务队列前端,防止其他任务插队导致执行延迟,这在快速排队时尤为重要。
虽然无法断言,但 postTask()
作为专精的调度工具,其火焰图表现也反映了这一点。你还可以通过设置参数来最大化任务优先级:
js
scheduler.postTask(() => {
console.log("postTask");
}, { priority: "user-blocking" });
"user-blocking" 优先级适用于影响用户体验的关键任务(如响应用户输入)。因此可能不适用于拆分大型工作负载------毕竟我们是要优雅地让出主线程。实际上,使用 "background" 低优先级可能更合适:
js
scheduler.postTask(() => {
console.log("postTask - background");
}, { priority: "background" });
setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask - default"));
// 输出顺序
// setTimeout
// postTask - default
// postTask - background
遗憾的是,调度器接口的浏览器支持度还不理想,但可以轻松通过现有异步 API 实现 polyfill,让大部分用户受益。
关于 requestIdleCallback()
的思考
既然降低优先级是好事,你可能会想到 requestIdleCallback()
。它设计为在浏览器空闲时执行回调。问题是无法保证其执行时机。虽然可以设置 timeout
,但需要面对Safari 仍不支持该 API 的现实。
此外,MDN 建议对必要工作使用 timeout,因此可能应完全避免为此目的使用它。
#4: scheduler.yield()
调度器接口的 yield()
方法比其他方法更专业,因为它专为此类场景设计。来自 MDN:
yield()
方法用于在任务执行期间让出主线程,稍后以优先任务继续执行...这允许拆分长时间运行的任务以保持浏览器响应。
首次使用时其优势更明显。不再需要手动返回和解析 Promise,只需等待提供的 Promise:
js
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
事件触发昂贵任务的复选框:
js
document
.querySelector('input[type="checkbox"]')
.addEventListener("change", function (e) {
waitSync(1000);
});
现在点击复选框会导致 UI 冻结一秒:
但立即让出控制权,浏览器就有机会更新 UI:
diff
document
.querySelector('input[type="checkbox"]')
.addEventListener("change", async function (e) {
+ await scheduler.yield();
waitSync(1000);
});
看,流畅多了:
与调度器接口的其他方法一样,它的浏览器支持有限,但 polyfill 很简单:
js
globalThis.scheduler = globalThis.scheduler || {};
globalThis.scheduler.yield =
globalThis.scheduler.yield ||
(() => new Promise((r) => setTimeout(r, 0)));
#5: requestAnimationFrame()
requestAnimationFrame()
API 设计用于在浏览器重绘周期安排工作。因此它能精确调度回调,总是在下次重绘前执行。这可能解释了火焰图中任务紧密排列的原因。动画帧回调实际拥有自己的队列,在渲染阶段的特定时间运行,其他任务很难插队。
但在重绘期间执行高开销任务会影响渲染性能。看同期火焰图中的黄色/条纹部分,表示"部分呈现的帧":
这在其他方法中未出现。考虑到这点,加上动画帧回调在非激活标签页中通常不执行,可能也应避免此方法。
#6: MessageChannel()
这种用法不常见,但有时被选作零延迟超时的轻量替代方案。通过实例化通道并立即发送消息:
js
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 代码:
js
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('Process complete!');
}
`;
const blob = new Blob([workerScript], { type: "text/javascipt" });
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 区域,留下了充足的活动空间。
如果不需要在 UI 中反映进度,应该一次性传递整个项目列表给 Worker,进一步减少开销。
如何选择?
虽然方法远不止这些,但上述方案很好地展示了拆分长任务时的各种权衡。根据需求,我会选择其中部分方案:
能移出主线程时 :首选 Web Worker。浏览器支持良好,专为减轻主线程负担设计。缺点是 API 笨拙,但可用 Workerize 或 Vite 内置的 worker 导入 缓解。
需要极简拆分 :选择 scheduler.yield()
。虽然要为非 Chromium 用户 polyfill,但大多数用户 能受益,值得增加这点开销。
需要精细优先级控制 :选择 scheduler.postTask()
。其深度定制能力令人印象深刻。虽然也需要 polyfill,但支持优先级控制、延迟、任务取消等功能。
需要广泛兼容性 :选择 setTimeout()
。作为传奇 API 永不过时,即使新方案不断涌现。
遗漏了什么?
我承认其中有些方法从未在实际应用中使用过,因此可能存在盲点。如果你有更多见解,欢迎补充。