页面卡了?你的代码霸占车道了

0 阅读7分钟

你有没有遇到过这种场景:用户点了一下"保存"按钮,界面纹丝不动,过了一两秒才跳出 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 中的长任务标识:红色三角形 + 红色斜条纹

DevTools 中的长任务标识:红色三角形 + 红色斜条纹

在 Chrome DevTools 的 Performance 面板里,长任务会被一个红色三角形标记出来,超出 50ms 的部分用红色斜条纹填充。这是浏览器在告诉你:这段时间内,用户的点击、滚动、输入全部在排队等着。

50 毫秒,就是主线程的"呼吸间隔"。超了,用户就会感觉到"卡"。

二、一辆车堵死整条路

理解长任务的关键,是理解 JavaScript 的运行到完成模型(run-to-completion)

这个模型的意思很简单:一段 JS 代码一旦开始执行,就必须全部跑完才能轮到下一个任务。中间不会被打断——哪怕用户在疯狂点按钮。

看这段代码:

function saveSettings() {
    validateForm();
    showSpinner();
    saveToDatabase();
    updateUI();
    sendAnalytics();
}

看着结构清晰、各司其职。但问题是:这五个函数作为一个任务在主线程上串行执行。如果总耗时超过 50ms,浏览器在这段时间内什么都做不了——既不能显示 spinner,也不能响应用户的其他操作。

这就像一辆大卡车开上了单车道的小路。车本身没毛病,但它太长了,后面所有车都被堵死了。

单个长任务 vs 拆分后的多个短任务

单个长任务 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 的最大区别是什么?

特性setTimeoutscheduler.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

scheduler.yield() — MDN

INP 正式成为 Core Web Vitals — Google

qrcode_for_gh_6a9e7f3719d6_344.jpg