你有没有遇到过这种场景:用户点了一下"保存"按钮,界面纹丝不动,过了一两秒才跳出 loading。你检查网络请求——没问题;检查后端接口——也没问题。最后发现原因让人哭笑不得:不是接口慢,是你的 JavaScript 霸占了主线程,浏览器根本来不及响应。
这就是"长任务"问题。听起来很基础,但它是 2024 年 Google 把 Core Web Vitals 的交互指标从 FID 换成 INP(Interaction to Next Paint) 的直接原因——旧指标只看"第一次"输入延迟,而 INP 看的是整个页面生命周期里最慢的那次交互。换句话说,你的页面不是只需要"第一印象好",而是全程都不能卡。
web.dev 最近更新了一篇经典文章 Optimize Long Tasks,系统讲了长任务的本质、拆分策略和最新的浏览器原生 API。读完之后我最大的感受是:长任务不是"代码多"的问题,是"你没给浏览器喘气的机会"。
一、50 毫秒:那条看不见的红线
先搞清楚一个定义:浏览器主线程每次能做的事叫"任务"(task),渲染、解析 HTML、执行 JS 都是任务。而任何超过 50 毫秒的任务,就叫长任务。
为什么是 50ms?这源自 Google 的 RAIL 性能模型——人类对交互的感知阈值大约是 100ms,留一半给浏览器做渲染和排版,留给 JS 的就只有 50ms。
DevTools 中的长任务标识:红色三角形 + 红色斜条纹
在 Chrome DevTools 的 Performance 面板里,长任务会被一个红色三角形标记出来,超出 50ms 的部分用红色斜条纹填充。这是浏览器在告诉你:这段时间内,用户的点击、滚动、输入全部在排队等着。
50 毫秒,就是主线程的"呼吸间隔"。超了,用户就会感觉到"卡"。
二、一辆车堵死整条路
理解长任务的关键,是理解 JavaScript 的运行到完成模型(run-to-completion) 。
这个模型的意思很简单:一段 JS 代码一旦开始执行,就必须全部跑完才能轮到下一个任务。中间不会被打断——哪怕用户在疯狂点按钮。
看这段代码:
function saveSettings() {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
看着结构清晰、各司其职。但问题是:这五个函数作为一个任务在主线程上串行执行。如果总耗时超过 50ms,浏览器在这段时间内什么都做不了——既不能显示 spinner,也不能响应用户的其他操作。
这就像一辆大卡车开上了单车道的小路。车本身没毛病,但它太长了,后面所有车都被堵死了。
单个长任务 vs 拆分后的多个短任务
这个交通类比其实暗含了一条更深的设计原则。在操作系统领域,它叫协作式多任务(cooperative multitasking)——系统不会强制打断你,但你有义务在适当的时候主动让出控制权。早期的 Windows 3.1 和 Mac OS 9 就是这个模型,一个程序卡死整个系统就崩了。现代操作系统换成了抢占式调度,但浏览器的主线程——至今还是协作式的。
你的代码不会被抢占。所以"主动让步"不是锦上添花,是基本责任。
三、拆任务的正确姿势
明白了长任务的本质,拆分策略的核心原则就很清楚了:
先做用户能看到的事,再做用户看不到的事。
回到 saveSettings 的例子,showSpinner() 和 updateUI() 是用户可见的,saveToDatabase() 和 sendAnalytics() 用户感知不到。把后者延后,用户体验就不一样了。
方案一:setTimeout(fn, 0)——最朴素的让步
function saveSettings() {
validateForm();
showSpinner();
updateUI();
// 把非关键工作推到下一个任务
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
setTimeout(fn, 0) 不是"立刻执行",而是"把这段代码扔到任务队列末尾"。浏览器在当前任务结束后,先处理可能排着的渲染和用户输入,然后才轮到它。
但 setTimeout 有三个硬伤:
| 问题 | 说明 |
|---|---|
| 嵌套惩罚 | 连续嵌套 5 次以上,浏览器会强制加 ≥5ms 延迟 |
| 队尾排队 | 延迟的任务排到队列末尾,可能被第三方脚本插队 |
| 写法别扭 | 在循环里用 setTimeout 做时间分片,代码结构会变得很痛苦 |
方案二:scheduler.yield()——浏览器原生的"让一下" ⭐
2024 年 Chrome 129 正式发布了 scheduler.yield() API,这是浏览器专门为拆分长任务设计的原生方案。
async function saveSettings() {
validateForm();
showSpinner();
updateUI();
await scheduler.yield(); // 主动让步
saveToDatabase();
sendAnalytics();
}
一行 await scheduler.yield(),执行到这里暂停,把控制权还给浏览器。等浏览器处理完更高优先级的工作(比如用户交互、渲染),再从中断的地方继续。
拆分后浏览器可以在任务间隙响应交互
它和 setTimeout 的最大区别是什么?
| 特性 | setTimeout | scheduler.yield() |
|---|---|---|
| 让步后的恢复位置 | 队列末尾(被插队) | 优先恢复(不被插队) |
| 代码写法 | 回调嵌套 | await 一行搞定 |
| 第三方脚本插队 | 会 | 不会 |
scheduler.yield() 相当于你在高速收费站说:"我先让后面的救护车过,但我排在下一个。"而 setTimeout 是:"我去重新排队。"
三种让步方式的行为差异对比
考虑到兼容性(Safari 暂不支持),推荐这个渐进增强写法:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
或者更激进的一行版——支持就让步,不支持就跳过:
await globalThis.scheduler?.yield?.();
四、循环里的时间分片
拆分单次调用还好办,但如果你要处理一个大数组呢?比如渲染 10000 条列表数据、批量处理文件。
最直觉的做法是每次迭代都让步:
async function processItems(items) {
for (const item of items) {
processItem(item);
await yieldToMain();
}
}
问题是:如果每个 processItem 只花 0.1ms,那让步的开销比工作本身还大。像每走一步就停下来看看路况——效率太低了。
更聪明的做法是设一个 50ms 的预算,花完了才让步:
async function processItems(items) {
let lastYield = performance.now();
for (const item of items) {
processItem(item);
if (performance.now() - lastYield > 50) {
await yieldToMain();
lastYield = performance.now();
}
}
}
这就是时间分片(time slicing) 。React 的 Fiber 架构做的本质上也是这件事——把一棵大的渲染树切成小块,每块控制在一帧的预算内,做完一块就检查有没有更高优先级的任务要插队。
时间分片的思想来自操作系统的 CPU 调度:不是给每个进程无限时间,而是给一个时间片(quantum),用完就切换。
五、一张决策图,搞定所有场景
什么时候该让步,什么时候不该?这张图帮你快速判断:
| 场景 | 策略 |
|---|---|
| 有用户可见的更新(spinner、UI 变化) | 先执行完所有可见更新,然后让步 |
| 后台工作(埋点、持久化、预计算) | 通过让步延迟到下一个任务 |
| 循环处理大数据 | 时间分片,50ms 预算用完就让步 |
| 两段代码都涉及关键 UI 更新 | 不要在中间让步,一起完成 |
| 不确定要不要让步 | 宁可让步,也别阻塞 |
最后给一个优先级选择链:
scheduler.yield() ← 首选
↓ 不支持
scheduler.postTask() ← 需要优先级控制时
↓ 不支持
setTimeout(fn, 0) ← 兜底方案
只记一句话
如果你只想带走一句话,我建议记这个:
长任务的本质不是"代码多",而是"你没在该停的地方停"。先完成用户能看到的事,然后主动让步——这不是优化技巧,是主线程上的基本公民义务。
这条原则适用的范围远比前端宽。项目管理里有个很像的说法:在任何排期紧张的时候,先交付用户最先触碰的东西。因为用户不会等你把所有事情做完——他们只需要在他们需要的那个瞬间,感受到你在回应。
参考资料
• Optimize long tasks — web.dev(Jeremy Wagner、Brendan Kenny)
• Interaction to Next Paint (INP) — web.dev
• INP 正式成为 Core Web Vitals — Google