7AA7 Blog

浏览器渲染管线:从 HTML 到像素的每一步

陈砚舟 UID:106
2026-09-30 16:43
18 4 0

页面上的一个像素要出现在屏幕上,中间要穿过解析、样式计算、布局、绘制、栅格化、合成这几道工序。搞清楚每道工序的输入输出,才能解释为什么改 width 卡而改 transform 不卡。

解析:字节流变成两棵树

HTML 解析器拿到的是字节流,先按编码(<meta charset>、HTTP 头、BOM 推断)解码成字符,再交给分词器产出 token,最后组装成 DOM 节点。这个流式过程意味着浏览器可以在首包还没收完时就开始建树。

CSS 走的是另一条线,产出 CSSOM。CSSOM 必须建成完整规则集才能参与样式计算,所以 CSS 是”解析阻塞渲染”的:<head> 里的外链样式会推迟首次绘制。这也是内联关键 CSS 能改善首屏的底层原因。

JS 的处境更特殊。默认情况下脚本会阻塞 DOM 构建,因为它可以调用 document.write 改变输入流。遇到 <script src> 时解析器会停下来等脚本下载执行完毕。

<!-- defer:DOM 解析完再按序执行,不阻塞解析 -->
<script defer src="app.js"></script>
<!-- async:下载完立刻执行,顺序不保证,适合独立脚本 -->
<script async src="analytics.js"></script>

defer 与 async 的差别不在”快慢”,而在执行时机与顺序保证。有依赖关系的脚本只能 defer,无依赖的埋点类才适合 async。

样式计算:层叠、继承与匹配

浏览器把 CSSOM 规则逐个匹配到 DOM 节点上,得到每个元素的计算样式(computed style)。三步走:

  1. 收集:把所有命中的声明收集起来,不管优先级;
  2. 层叠:按来源(!important 作者样式 > 动画 > 作者 > 用户代理)、特异性、书写顺序排序,取胜者;
  3. 继承与默认:未声明的属性从父级继承或用初始值兜底。

选择器匹配是从右往左走的。.list .item 会先找所有 .item,再向上验证祖先。所以最右那一层的选择器越具体越好,写 .item 比写 div span:nth-child(2) 省事得多。

布局:几何计算的成本

布局(Layout / Reflow)是给每个可见盒子算出位置和尺寸。它是一个递归过程:父节点的尺寸依赖子节点,子节点的百分比又依赖父节点。

关键认知是布局的作用域不是元素本身。改一个节点的宽度,可能导致父节点高度变化,再引起兄弟节点位移,一路波及到祖先和后续兄弟。这就是”重排很贵”的来源——它常常不是 O(1) 而是影响一整片子树甚至整个文档。

// 反面教材:读写交替,每次读都强制同步布局
for (const el of items) {
  el.style.width = '50%';
  console.log(el.offsetWidth); // 读取 → 强制刷新布局
}

// 正确做法:先批量读,再批量写
const widths = items.map(el => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] / 2 + 'px'; });

这个现象叫布局抖动(layout thrashing)。现代浏览器有异步布局缓存,但只要你在写操作之后立刻读几何属性,缓存就会失效,被迫同步计算一次。

绘制与栅格化

布局之后是绘制(Paint):把每个元素按层叠顺序拆成一系列绘制指令(画矩形、画文字、画边框、画阴影),指令序列先被记录进显示列表,还不会变成像素。

栅格化(Rasterize)才真正产生位图,由合成器线程交给 GPU 或光栅线程执行。栅格化的最小单位是图块(tile),浏览器优先栅格化视口附近的图块。

这里有一个容易忽略的点:栅格化结果会被缓存。图块没变就不必重画。把一个高频移动的元素单独提成一层,意味着它移动时不需要重栅格化底下的内容。

合成与 GPU 加速

合成(Composite)阶段把若干图层按 z 序、变换、透明度叠起来输出最终画面,这一步跑在合成器线程上,不受主线程 JS 阻塞影响。

只有 transform、opacity、filter 这类属性在特定条件下可以完全由合成器处理——前提是该元素已经拥有独立的合成层。

属性改动的代价分级

改动的属性 触发布局 触发绘制 触发合成 说明
width / height / padding 是 是 是 几何变化,波及祖先与兄弟
top / left / margin 是 是 是 相对定位位移仍走布局
font-size / line-height 是 是 是 文本度量变化,重排范围大
color / background-color 否 是 是 跳过布局,仍需重绘该区域
box-shadow / border-radius(动画中) 否 是 是 重绘代价随面积增长
opacity 否 否(提层后) 是 提层后仅合成器参与
transform 否 否(提层后) 是 位移/缩放/旋转的最优选择

判定”某个属性到底走不走布局”的最可靠办法不是背表,而是打开 DevTools 的 Performance 面板录制,看有没有紫色(Layout)和绿色(Paint)条目。

预加载扫描器:解析阻塞的例外

浏览器在遇到阻塞脚本时并不是完全停摆。主线程被脚本卡住期间,有一个独立的预加载扫描器会继续往下扫 HTML,提前发现 <img>、<link>、<script> 引用的资源并发出请求。这解释了为什么即使解析被阻塞,后面的图片仍然可能已经在下载。

但它有盲区:由 JS 动态插入的资源、document.write 生成的内容、CSS 里的 url()(要等 CSSOM 建好才能扫),扫描器都看不到。凡是只能靠 JS 才能发现的资源,都要晚一个环节才能开始加载。

document.write 会把脚本输出直接插入输入流,这让预加载扫描器已经发出的请求可能作废,也是它被列为反模式的原因之一。

图层提升的收益与代价

给元素一个合成层,常见写法:

.card {
  will-change: transform;      /* 提前提示,浏览器先备好层 */
  transform: translateZ(0);    /* 旧写法,副作用类似 */
}

收益是动画不再触发重绘,滚动和位移由合成器独立完成。代价同样实在:

  • 显存占用:一层就是一张位图,宽 × 高 × 4 字节。一个 1920×1080 的全屏层约 8MB,十几个大层就能吃掉几百 MB 显存,移动端更容易爆。
  • 过度分层:will-change 写在几十个元素上,等于把内存压力和管理开销都抬上去,反而变慢。
  • 层树遍历成本:层数越多,合成时的遍历与排序越重。
  • 文本渲染变化:提层后可能不再走子像素抗锯齿,文字看起来发虚。

正确的用法是动态加、用完就摘:

el.addEventListener('mouseenter', () => { el.style.willChange = 'transform'; });
el.addEventListener('animationend', () => { el.style.willChange = 'auto'; });

层叠上下文与合成层不是一回事

z-index、opacity < 1、transform、filter、will-change、position: fixed 等属性会创建层叠上下文,它只决定绘制顺序,不必然产生独立的合成层。

合成层是合成器线程的管理单位,由浏览器根据启发式规则决定,或者被 will-change 显式提示。一个元素有层叠上下文却没有独立合成层是完全正常的——它仍然和父层一起被栅格化。

混淆这两者的后果是:写了 will-change: opacity 期待加速,但因为层没提成,opacity 动画依然在走重绘。判断有没有真的提层,要看 DevTools 的 Layers 面板,而不是看 CSS 写了什么。

写代码时的具体约束

  • 批量改样式用 class 切换,而不是逐个改 style 属性;
  • 需要脱离文档流的动画元素,给它 position: absolute/fixed 减少布局波及;
  • 用 requestAnimationFrame 对齐帧节奏,避免在一帧内多次读写;
  • 滚动监听里不要读 offsetTop,用 IntersectionObserver 或缓存值;
  • 对容器尺寸变化的响应优先用 ResizeObserver,它不依赖布局读取,也不会触发抖动。

理解管线的价值在于:绝大多数”页面卡”可以归结为主线程被 JS 或布局/绘制占满,或者动画走了不该走的路径。定位到具体阶段,优化手段就自动浮现了。

713Views
18Likes
4Comments
4Participants
10 minread time

Comments (4)

  • VectorQ · 2026-09-30 19:06

    这玩意儿在生产上真敢上?我们上次试了一下,监控直接报警。

    Reply
  • Layout 何 · 2026-10-01 04:28

    这文章把我说服了,之前的做法确实有问题。

    Reply
  • Cache 孙 · 2026-10-01 13:10

    讲道理,这个方案对新人不太友好,学习曲线有点陡。

    Reply
  • 白露 · 2026-10-01 21:45

    面试被问过这个问题,当时答得稀烂,现在终于敢说了。

    Reply