kciter 写了篇《The browser's main thread is expensive》。这个话题不新,但这篇有个优点:它把"主线程"当成一种有明确预算的稀缺资源来讲,而不是"慢就优化"。

主线程到底在干什么

浏览器的主线程负责串行执行这一串工作:解析和构建 DOM、计算 CSSOM、样式重算(style recalc)、布局(layout/reflow)、绘制(paint),以及跑 JavaScript。除此之外,它还要处理用户的输入事件。

关键在于"串行"。这些工作不是分别跑在不同的核上互相并行——它们排在同一条队列里,一个做完才轮到下一个。所以你在主线程上花掉的每一毫秒,都是渲染和输入响应用不到的毫秒。

预算从哪来?最常见的显示器是 60Hz,也就是每秒 60 帧,每帧约 16.6 毫秒。在这 16.6ms 里,浏览器要完成上面那串工作,才能保证不掉帧。你的 JS 如果占了 12ms,留给样式和布局的就只剩 4ms——大概率不够,于是掉帧。

一个反直觉的细节:预算从"帧开始"算

文章里有一段我印象最深的代码思路:做时间分片(time slicing)时,不要按"我已经跑了 5ms"来切,要按"距离这一帧开始已经过了 5ms"来切:

// 只在距本帧开始 5ms 内继续干活
while (i < items.length && (performance.now() - frameStart) < 5) {
  process(items[i++]);
}

差别在哪?如果按"我跑了 5ms 就停",多个独立的任务各自都觉得自己只跑了 5ms,加起来可能把一整帧挤爆。而按"距帧开始 5ms"来算,所有任务共享同一个预算锚点——你跑掉的时间会被别人看到,自然就互相谦让了。

这个技巧解决的是"多个动画/任务共享一帧"时的资源竞争。它把时间预算从"各自的私有量"变成"共享量",光是这一点就能解释很多"单个组件很快、合起来就卡"的现象。

三条路线:用得更少、别用它、干掉它

文章把策略分成三档,我觉得这个分类比按 API 分类更有用。

第一档:把主线程资源用一个更聪明的方式花掉。

  • 拆分(Splitting):把一个大任务切成几个小块,块之间让出主线程,让渲染有机会插进来。上面那段 5ms 分片就是它的实现。
  • 批处理(Batching):短时间内的多次更新合并成一次。典型是虚拟列表——一屏只渲染可见项,滚动时增量更新,而不是一次性渲染 10000 行。
  • 定优先级(Prioritizing):输入响应和动画优先,后台计算靠后。scheduler.postTask 这类接口允许给任务标优先级。
  • 延后(Deferring):不急的活推迟到空闲时段。文章里举的输入场景很典型:打字时每次按键都重算会卡,改成防抖 300ms(停手之后再算一次),输入立刻变得顺滑。它给的对比是"一次算完" vs "每帧 5ms" vs "300ms 防抖"——同一个功能,三种策略下的手感差别很大。

第二档:根本不用主线程。

  • 交给合成器(Moving work to the compositor):transform 和 opacity 的动画可以在合成器线程上跑,不占主线程。这就是为什么"用 transform 做动画比用 left/top 快"——后者会触发布局,前者不会。
  • 交给 Worker(Sending work to a worker):把纯计算搬到 Web Worker。注意"纯计算"是重点——Worker 拿不到 DOM,凡是需要读写 DOM 的活都搬不走。能用 OffscreenCanvas 的渲染工作也可以搬。

第三档:把它删掉。

  • 消除工作本身(Eliminating the work itself):这是收益最高也最容易被忽略的一档。少画一层、少嵌套一个容器、少一次无意义的样式重算,比任何"优化执行"都有效。content-visibility: auto 让屏幕外的内容跳过渲染,本质也是消除工作。

为什么"长任务"是分界线

主线程上超过 50ms 的任务被称为长任务(long task),因为它已经不可能在一帧内完成,必然影响响应性。这个 50ms 是个经验阈值,但它对应的是一个很实在的体验指标:用户点击之后,如果 100ms 内没有可见反馈,人就会觉得"卡了"。

所以排查性能问题时的第一步,往往不是"哪个函数慢",而是"主线程上有没有长任务"。有的话,先把它拆到 50ms 以下,再谈别的优化。切分本身可能只花几行代码,收益却常常超过后面所有的微优化。

我的做法

在真实项目里,我现在按这个顺序处理主线程问题:

  1. 用 DevTools 的 Performance 面板看有没有长任务,有就先拆。这一步收益最大,代价最小。
  2. 动画一律用 transform / opacity,需要动画的属性先想"能不能换成这两个"。
  3. 输入相关的高频事件(输入、滚动、resize)默认加防抖或节流。默认加,不要等出问题再加。
  4. 列表默认虚拟化。超过几百项就上虚拟列表,不要赌用户不滚。
  5. 最后才看"优化某个函数"。前三步做完,多数卡顿已经消失了。

主线程的贵,贵在它是共享的。你写的每一段 JS 都在跟渲染、跟输入响应抢同一条 16.6ms 的通道。意识到这一点,优化的方向会清楚很多。