大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!
1. 为什么要避免长任务
让耗时长的任务长时间占用主线程很容易破坏用户体验,因为无论应用程序变得多么复杂,事件循环仍然只能一次执行一个任务。
下面是一个例子:在屏幕上有一个用于增加 counter 的按钮,还有一个耗时的循环用于模拟实际任务。
<button id="button">count</button>
<div>Click count: <span id="clickCount">0</span></div>
<div>Loop count: <span id="loopCount">0</span></div>
<script>
const clickCount = document.getElementById('clickCount');
const loopCount = document.getElementByI('loopCount');
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(undefined);
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
waitSync(50);
}
</script>
运行以上程序,没有任何视觉上的更新,因为浏览器根本没有机会绘制 (Paint) 到屏幕上而只能一直处于加载中,直到循环完全结束后才能响应用户操作。
从以下火焰图中也可以看出端倪,事件循环中的单个任务需要五秒钟才能完成。
该问题的通用解决方案是拆分长任务,即让其他任务有机会使用主线程来执行,例如:处理按钮点击和重新绘制等。
2. JavaScript 的长任务通用解决方案
2.1 setTimeout() 和回调组合
如果在原生 Promise 出现之前写过 JavaScript,那么肯定见过下面拆分任务的方式,即一个函数从超时回调中递归调用自身。
function processItems(items, index) {
index = index || 0;
var currentItem = items[index];
console.log("processing item:", currentItem);
if (index + 1 < items.length) {
// 从 setTimeout 中调用自身
setTimeout(function () {
processItems(items, index + 1);
}, 0);
}
}
processItems(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);
通过这种任务拆分方法,每个数组 item 都在不同的 tick 上处理,从而将大任务拆分为不同的小任务,最终让 UI 快速响应。
2.2 Async/Await & Timeout 的组合
这种方式允许放弃 setTimeout 递归并简化代码,比如下面的代码示例:
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(undefined);
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
// 让出主线程用于响应用户操作
// 稍后继续以 Task 执行其他任务
await new Promise((resolve) => setTimeout(resolve, 0));
waitSync(50);
}
})();
await new Promise((resolve) => setTimeout(resolve, 0)); 会将当前任务放入微任务队列中,允许浏览器在下一个事件循环中处理。值得一提的是,Promise 的 .then() 方法始终在微任务队列中执行。
让出主线程通常意味着当前任务执行完毕,或者至少是当前分片的任务执行完毕,从而让浏览器能够有机会去处理其他任务,即在 Performance 面板看到有新的 Task 任务被调度。
2.3 scheduler.postTask()
Scheduler.postTask() 用于根据 优先级 添加要调度的任务,其允许用户选择性地指定任务运行前的最小延迟、任务的优先级。其返回一个 Promise,该 Promise 通过任务回调函数的结果 resolve,或通过中止原因或任务中抛出的错误 reject。
Scheduler.postTask() 接口旨在成为一流的任务调度工具,提供更多的控制和效率,相当于 setTimeout() 的改进版本。
const items = new Array(100).fill(undefined);
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) 的优先级相当。
setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask"));
// 输出 setTimeout、postTask
scheduler.postTask(() => console.log("postTask"));
setTimeout(() => console.log("setTimeout"));
// 输出 postTask、setTimeout
但与 setTimeout() 不同,postTask() 是为任务调度而构建的,即不受超时的限制。其调度的所有任务都放在队列前面,防止其他项目在前面移动并延迟执行,尤其是在以快速的方式排队时。
scheduler.postTask(
() => {
console.log("postTask");
},
{priority: "user-blocking"}
);
“user-blocking” 优先级适用于对用户体验至关重要的任务,例如:响应输入。因此,postTask() 用于任务拆分有点大材小用。开发者可以通过使用 “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() ,其被设计为在 “空闲” 期间执行回调。但问题在于,没有技术保证其何时或是否会运行。虽然开发者可以在调用时设置超时,但 Safari 却根本不支持该 API。
function performTask(deadline) {
// 检查是否还有剩余时间
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
const task = tasks.shift();
task();
}
// 如果还有未完成的任务,继续请求空闲时间
if (tasks.length> 0) {
requestIdleCallback(performTask, { timeout: 500});
}
const tasks = [];
// 添加一些任务到队列中
for (let i = 0; i < 10; i++) {
tasks.push(() => {
console.log(`Task ${i} is running`);
});
}
// requestIdleCallback 设置超时时间为 500 毫秒
requestIdleCallback(performTask, { timeout: 500});
console.log('Main thread continues...');
2.4 scheduler.yield()
scheduler.yield() 方法专为长任务场景而设计,其不再需要返回并 resolve 自己手动创建的 Promise,只需等待该方法返回的 Promise:
const items = new Array(100).fill(undefined);
for (const i of items) {
loopCount.innerText = Number(loopCount.innerText) + 1;
// await scheduler.yield() 自身返回的 Promise
await scheduler.yield();
waitSync(50);
}
下面是一个复选框示例,其在 onchange 时触发一个长任务:
document
.querySelector('input[type="checkbox"]')
.addEventListener("change", function (e) {
waitSync(1000);
});
当单击复选框时会让 UI 冻结 1s,但开发者可以通过 scheduler.yield() 将控制权交给浏览器,从而有机会在点击后更新 UI。
document
.querySelector('input[type="checkbox"]')
.addEventListener("change", async function (e) {
+(await scheduler.yield());
// 这里是添加的代码
waitSync(1000);
});
值得一提的是,该接口缺乏可靠的浏览器支持,但开发者很容易进行 polyfill,例如下面的示例:
// scheduler.yield() 的 polyfill 示例
globalThis.scheduler = globalThis.scheduler || {};
globalThis.scheduler.yield =
globalThis.scheduler.yield || (() => new Promise((r) => setTimeout(r, 0)));
2.5 requestAnimationFrame()
requestAnimationFrame() 旨在根据浏览器的重绘周期来调度工作,其在调度回调时非常精确,即 总是在下一次绘制 (Paint) 之前。
function processTasks(items, loopCountElement, index = 0) {
if (index>= items.length) {
return;
}
// 更新 loopCount
loopCountElement.innerText = Number(loopCountElement.innerText) + 1;
// 模拟一个耗时任务
waitSync(50);
// 使用 requestAnimationFrame 计划下一个任务
requestAnimationFrame(() => processTasks(items, loopCountElement, index + 1));
}
(function startProcessing() {
const items = new Array(100).fill(undefined);
const loopCountElement = document.getElementById("loopCount");
processTasks(items, loopCountElement);
})();
动画帧 (Animation Frame) 回调有自己的 “队列”,其在渲染阶段的特定时间运行,即不会受到其他任务 Task 的影响。
但是,在重绘时进行耗时的任务也会损害渲染。查看同一时间段内的帧,黄色 / 划线部分表示 “部分渲染的帧”:
使用其他任务中断策略时并不会发生这种情况,同时考虑到动画帧回调不会在选项卡非激活状态执行的事实,可能也会避免使用此选项。
2.6 MessageChannel()
MessageChannel() 通常被作为零延迟,即 setTimeout(0) 的更简单的替代方案。 与其要求浏览器排队计时器并调用回调,不如实例化一个通道并立即向其发送消息:
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);
从火焰图来看,MessageChannel() 方法的性能可能有些问题,即每个调度任务之间的延迟并不大:
但这种方法的(主观)缺点是代码比较复杂。很明显,这不是它的设计初衷。
开发者可以使用 MessageChannel 调度一个宏任务,而且与 setTimeout 调度宏任务相比没有 4ms 的怪癖。
2.7 Web Worker 方案
如果要脱离主线程执行工作,那么 Web Worker 无疑是首选。从技术上讲,开发者甚至不需要单独的文件来存放 Worker 代码:
const items = new Array(100).fill(undefined);
// 下面是 worker 允许的代码
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" });
// 创建一个 Blob 对象,是一个不可变的原始数据的文件类对象
const worker = new Worker(window.URL.createObjectURL(blob));
// Worker 使用了 ObjectURL 对象参数
for (const i of items) {
worker.postMessage(items);
// 等待 worker 的消息
await new Promise((resolve) => {
worker.onmessage = function (e) {
loopCount.innerText = Number(loopCount.innerText) + 1;
resolve();
};
});
}
当单个项目的工作在其他地方执行时主线程非常清晰。相反,其全部被推到 “Worker” 下,为活动留下了很大的空间。
如果可以一次性将整个 items 列表传递给 Worker,则会进一步减少开销。
参考资料
https://macarthur.me/posts/long-tasks/
https://github.com/GoogleChromeLabs/scheduler-polyfill?ref=cms.macarthur.me
https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html?ref=cms.macarthur.me#list-of-animation-frame-callbacks