主线程瓶颈:JavaScript执行、样式计算、布局与绘制的耗时分析

说到刷新卡顿,我敢打赌,十次里有八次问题都出在主线程上。你想想看,浏览器的主线程就像一条单车道,所有任务都得排队通过——JavaScript 要跑、样式要算、布局要排、最后还得画出来。任何一个环节堵了,用户就能感觉到掉帧。

我个人习惯把主线程的工作流叫做「渲染流水线」。这条流水线跑得顺不顺,直接决定了页面是 60fps 丝滑,还是 20fps 卡成 PPT。今天咱们就把这条流水线拆开,看看每个环节到底在干什么,以及怎么找到瓶颈。

3.1 渲染流水线全景

先看一张整体流程图,帮你建立全局认知。这张图我每次做性能分析前都会在脑子里过一遍,它就像一张地图,告诉你车现在堵在哪一段。

主线程渲染流水线 JavaScript 执行 样式计算 Style 布局 Layout 绘制 Paint 合成 Composite 关键说明: • 每个环节都可能成为瓶颈,但 JavaScript 执行是最常见的「堵点」 • 并非每次更新都会走完所有环节——比如只改 transform 可以跳过布局和绘制 • 16.6ms 内必须完成整条流水线,否则就会掉帧

这张图里每个环节都有颜色标记,后面咱们逐个拆解。注意看,合成(Composite)是唯一可以脱离主线程运行的阶段——这就是为什么能用 transform 和 opacity 做动画时,尽量别碰其他属性。

3.2 JavaScript 执行:最常见的「凶手」

我在项目中遇到过无数次这样的情况:打开 Chrome DevTools 的 Performance 面板,录一段操作,然后看到一长串的橙色 Task 块,每个都超过 50ms。嗯,不用查了,JS 执行超时了。

JavaScript 执行为什么会卡?说白了就两个原因:干得太多 和 干得太久。

3.2.1 长任务(Long Task)

浏览器把超过 50ms 的任务定义为「长任务」。为什么是 50ms?因为 60fps 下每帧只有 16.6ms,50ms 意味着至少阻塞了 3 帧。用户能明显感觉到卡顿。

常见的长任务来源:

  • 大循环/复杂计算:比如遍历一个 10 万条数据的数组做 DOM 操作
  • JSON.parse/stringify:处理超大 JSON 对象时,解析本身就能吃掉几十毫秒
  • 递归或深层嵌套调用:调用栈过深,GC 频繁触发
  • 事件处理函数过于臃肿:一个 scroll 或 resize 回调里塞了太多逻辑
我的习惯:写代码时我会问自己——这段逻辑真的必须在主线程上跑吗?能用 Web Worker 的绝不留在主线程。比如数据格式化、加密、大数组排序,这些活儿扔给 Worker 干,主线程只负责收结果。

3.2.2 强制回流(Forced Reflow)

这个坑我踩过不止一次。你以为只是读个 offsetHeight,结果浏览器为了给你「准确」的值,不得不提前执行布局计算。这就是强制回流。

// 坏写法:读-写交替,触发强制回流
const height = element.offsetHeight;  // 读
element.style.height = height + 10 + 'px';  // 写
const width = element.offsetWidth;  // 又读
element.style.width = width + 10 + 'px';  // 又写

// 好写法:批量读,再批量写
const height = element.offsetHeight;
const width = element.offsetWidth;
element.style.height = height + 10 + 'px';
element.style.width = width + 10 + 'px';

我曾经在一个列表拖拽排序组件里,每拖一次就读十几个布局属性,结果拖起来跟幻灯片似的。后来改成用 requestAnimationFrame 批量处理,流畅度直接翻倍。

3.3 样式计算:被低估的耗时环节

很多人觉得样式计算很快,不就是匹配一下选择器吗?其实不然。当你有几千个 CSS 规则,DOM 节点又有上万个时,样式计算能轻松吃掉 10-20ms。

样式计算的本质是:遍历所有匹配的选择器,计算每个元素的最终样式。选择器越复杂,匹配成本越高。

选择器类型 示例 匹配成本 优化建议
ID 选择器 #header 低 优先使用
类选择器 .item 低 常用,没问题
标签选择器 div 中 避免单独使用
后代选择器 .list .item 高 尽量用子选择器 >
通配选择器 * 极高 绝对避免
属性选择器 [data-type] 高 少用,尤其避免正则匹配
避坑指南:我曾经接手过一个项目,全局 CSS 里写了 div * { box-sizing: border-box; }。就这一行,每次样式计算都要遍历所有后代元素。改成 *, *::before, *::after { box-sizing: border-box; } 后,样式计算时间从 12ms 降到了 3ms。通配符的威力,你感受一下。

3.4 布局:重排的代价

布局阶段,浏览器要计算每个元素的位置和尺寸。只要改了元素的几何属性(宽、高、边距、定位等),就会触发重排(Reflow)。

重排的代价有多大?我举个例子:一个页面有 5000 个 DOM 节点,你改了最外层容器的宽度,浏览器就得重新计算所有子节点的位置。这就是「牵一发而动全身」。

触发重排的操作:

  • 改变元素尺寸:width、height、padding、margin、border
  • 改变元素位置:top、left、position、float
  • 改变内容:文本变化、图片加载、输入框输入
  • 读取布局属性:offsetHeight、scrollTop、getComputedStyle

你想想看,如果在一个循环里反复读写这些属性,浏览器会被迫反复重排。这就是为什么「批量操作」这么重要。

3.5 绘制:像素的最后一公里

布局算完了,接下来就是把元素画成像素。绘制阶段会生成绘制指令列表(Paint List),然后由合成线程去执行。

绘制的耗时取决于:

  • 绘制区域大小:全屏重绘 vs 局部重绘,差距巨大
  • 绘制复杂度:纯色背景 vs 渐变+圆角+阴影+滤镜,后者能慢 10 倍
  • 层叠上下文:每个层叠上下文都是一个独立的绘制层,层数越多,合并成本越高

核心原则:能用 transform 和 opacity 实现的动画,绝对不要用 left、top、width、height。前者只触发合成,后者会触发布局+绘制+合成,成本翻三倍。

3.6 如何定位主线程瓶颈

光说不练假把式。下面是我常用的定位方法,你可以直接拿去用。

3.6.1 使用 Performance 面板

  1. 打开 Chrome DevTools → Performance 面板
  2. 点击录制,然后操作页面(比如滚动、点击)
  3. 停止录制,查看主线程火焰图
  4. 找到超过 50ms 的 Task,看是哪个环节耗时最长

3.6.2 使用 FPS 计数器

在 DevTools 里按 Cmd+Shift+P(Mac)或 Ctrl+Shift+P(Win),输入 Show frames per second,打开 FPS 仪表盘。如果数字经常掉到 30 以下,说明主线程有瓶颈。

3.6.3 使用 console.time 做微基准

console.time('style-recalc');
// 触发样式变化的操作
element.classList.add('active');
console.timeEnd('style-recalc');

这个方法适合定位具体某段代码的耗时。我经常用它来对比「优化前」和「优化后」的效果,数据说话,比感觉靠谱多了。

3.7 实战:一个卡顿列表的优化过程

最后分享一个真实案例。有个长列表页面,滚动时掉帧严重。我用 Performance 面板录了一段,发现主线程上 JavaScript 执行占了 80ms,样式计算 15ms,布局 20ms,绘制 30ms。加起来远超 16.6ms。

优化步骤:

  • 第一步:把列表数据的格式化逻辑扔进 Web Worker,主线程 JS 执行降到 20ms
  • 第二步:用 will-change: transform 把列表项提升为合成层,减少重绘区域
  • 第三步:用虚拟滚动只渲染可见区域的 20 个节点,DOM 数量从 2000 降到 20

优化后,主线程总耗时从 145ms 降到了 12ms。滚动丝滑得像德芙巧克力。

我的建议:别一上来就搞复杂优化。先录一段 Performance,看看哪个环节最耗时。80% 的情况下,干掉那个最大的瓶颈,卡顿就解决了。剩下的 20%,才是那些精细活儿。