浏览器渲染原理:从解析 HTML/CSS、构建渲染树到重排重绘与首屏优化

摘要:本文从浏览器内核视角,把 HTML/CSS/JS 变成屏幕像素的全过程拆成「关键渲染路径」5 步,并讲清重排、重绘、合成三者的成本差异与触发条件。配合可运行对比代码(left 动画 vs transform 动画、读取 offset 触发强制重排、批量改样式),给出布局抖动解法、GPU 加速实战与 10 条首屏优化清单,帮你把零散概念串成可落地的性能优化体系。

一、引言:浏览器到底把你的代码变成了什么?

你写的 .html、.css、.js 只是一堆文本字节,最终却变成了屏幕上跳动的按钮、滚动的列表、流畅的动画。中间这段「字节 → 像素」的转化,就是前端性能优化的主战场------不是调几个参数这么简单,而是理解浏览器是怎么干活的。

很多同学都有类似的困惑:

  • 知道 display:none 能隐藏元素,却不清楚渲染树为什么直接把它排除,而 visibility:hidden 却被保留;
  • 做位移动画时用 left / top / margin,页面肉眼可见地卡,但说不清背后触发了什么;
  • 改一个样式整页抖动(布局抖动 / Layout Thrashing),不知道读取 offsetTop 这类属性会强制浏览器立即回流;
  • 首屏加载慢,却不知道该从「关键渲染路径」(Critical Rendering Path)的哪一步下手。

要回答这些问题,先建立一个最朴素的目标感:流畅的画面是 60fps ,也就是每秒 60 帧。一秒 1000 毫秒,平均每帧只有约 16.7ms 的预算,而这一帧里要塞下 JavaScript、样式计算、布局、绘制、合成所有活儿。一旦某一步超过预算,掉帧就开始了。

指标 数值 说明
目标帧率 60fps 人眼感知流畅的底线
单帧预算 ≈ 16.7ms 1000ms / 60,含脚本到合成全流程
单帧预算(高刷) ≈ 8.3ms 120fps 设备,余量更紧

本文主线分三块:关键渲染路径(字节到像素的 5 步)→ 重排 / 重绘 / 合成的成本差异 → 首屏优化与 GPU 加速实战。下面一节一节拆开。

二、关键渲染路径全景:从字节到像素的 5 步

浏览器把文档变成像素所经历的步骤序列,称为关键渲染路径(Critical Rendering Path, CRP)。它直接决定「首次渲染」需要多久,是首屏优化的核心抓手。

完整的链路是:

text 复制代码
HTML 字节 ──▶ DOM 树
                │
CSS 字节  ──▶ CSSOM 树
                │
             渲染树(Render Tree,仅可见节点 + 计算样式)
                │
             布局(Layout / Reflow,算尺寸与位置)
                │
             绘制(Paint,转实际像素)
                │
             合成(Composite,GPU 多图层拼合输出)

也就是说,从两个独立的数据结构(DOM、CSSOM)出发,先合成出「渲染树」,再依次做布局、绘制、合成。对每一步的直觉:

  • DOM:HTML 解析出的文档对象模型,描述结构;
  • CSSOM:CSS 解析出的样式树,描述每个节点的计算样式;
  • 渲染树:DOM 与 CSSOM 的交集------只有「可见且有样式」的节点才进;
  • 布局:给每个节点算出在视口里的宽高和坐标;
  • 绘制:把每个盒子画成像素(文字、颜色、边框、阴影);
  • 合成:把多个图层交给 GPU 拼成最终画面。

节点越多、CSS 选择器越复杂、样式层叠越深,CRP 后面几个阶段就越慢。所以「优化 CRP」的本质,是让这条链路更短、更便宜,从而缩短首次渲染时间。

三、解析 HTML:增量构建 DOM 树

浏览器拿到 HTML 响应后是增量解析的------不需要等整个文件下载完,边下载边把字节转成 DOM 树。过程可以概括成四步:

text 复制代码
字节流(bytes) ──▶ 标记(token) ──▶ 节点(node) ──▶ DOM 树(tree)

网络传过来的是原始字节(如 UTF-8 编码的 <div>),分词器把它们切成标记(start tag、end tag、text、comment 等),再组装成节点对象,最后连成树。

但有几个外部资源会打断这个顺畅的过程:

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <!-- 样式表:阻塞渲染(Render-Blocking) -->
  <link rel="stylesheet" href="style.css">
  <!-- 普通脚本:阻塞 HTML 解析(Parser-Blocking) -->
  <script src="app.js"></script>
</head>
<body>
  <img src="hero.png" alt="首图">
  <!-- 带 async 的脚本不阻塞解析 -->
  <script async src="track.js"></script>
</body>
</html>

几个要点:

  • <link rel="stylesheet"> 是阻塞渲染的:浏览器要等 CSSOM 构建完,才敢进入渲染树阶段,否则用户会看到「无样式的闪屏」;
  • 头部没有 defer / async 的 <script> 会阻塞 HTML 解析 ------解析器遇到它要先停下来下载并执行,这也是为什么通常建议脚本放底部或用 defer;
  • DOM 节点的总数量会一路传导到布局、绘制、合成阶段,节点越多后面越慢。

四、解析 CSS:构建 CSSOM 与样式层叠

很多人以为「样式是挂在 DOM 上的」,其实 CSSOM 是和 DOM 相互独立的另一棵树。浏览器处理 CSS 的方式和 HTML 类似:把一条条规则解析成节点,再组织成树状结构。

难点在「层叠(cascade)」:同一个元素可能被多个来源的规则命中------

text 复制代码
作者样式(你写的 CSS)
   ↑ 覆盖
用户样式(浏览器设置 / 扩展)
   ↑ 覆盖
UA 默认样式表(浏览器内置,如 <h1> 默认加粗、<a> 默认蓝色)

浏览器从「最通用」的 UA 默认样式表开始,逐层被更具体的规则覆盖,最终递归细化出每个节点的计算样式(computed style) 。这套层叠 + 继承 + 优先级的计算,正是 DevTools 里 Recalculate Style 这一项的耗时来源------它等于「解析 CSS + 构建 CSSOM + 计算所有计算样式」的总时间。

一个容易忽略的事实:即使你只写了很少 CSS,浏览器也要先加载 UA 默认样式表,再叠加你的规则。所以「页面默认就有样式」不是魔法,是层叠的结果。

五、DOM + CSSOM 合成渲染树:谁进了渲染树,谁被排除?

渲染树是 DOM 与 CSSOM 的交集。浏览器从 DOM 根节点开始遍历,对每个「可见」节点去匹配它的计算样式,把两者合并成渲染树节点。

这里就回答了一开始的问题:为什么 display:none 和 visibility:hidden 命运不同?

属性 是否进入渲染树 是否占空间 说明
display: none 否 否 节点从渲染树中完全移除,不绘制
visibility: hidden 是 是 节点在渲染树中,占位置但不显示
<script> / <head> 否 否 不带可见内容,天然不进渲染树
opacity: 0 是 是 只是透明,仍参与布局和绘制

一句话记:渲染树 = 所有「可见 + 有内容 + 有计算样式」的节点的集合 。display:none 因为「不可见」被直接排除,后续布局 / 绘制根本不会为它分配成本;visibility:hidden 仍然占着布局空间,所以进渲染树但不画出来。

用代码感受一下区别:

html 复制代码
<div class="box-a" style="display:none">我不在渲染树里</div>
<div class="box-b" style="visibility:hidden">我在渲染树里,但看不见</div>
<div class="box-c">我是正常可见节点</div>

改写样式把 display:none 换成 display:block,这个节点会从无到有进入渲染树 ,触发一次完整的「布局 → 绘制 → 合成」;而把 visibility:hidden 换成 visible 只是「从不显示变显示」,只在重绘阶段处理,代价明显更低。

六、布局(Layout / Reflow):计算每个节点的几何信息

拿到渲染树后,浏览器要计算每个节点在屏幕上的尺寸和位置------这个过程叫布局(Layout),也叫重排(Reflow,指后续重新计算)。

布局基于视口大小 ,从 body 开始自顶向下遍历盒模型:每个元素多大、相对父级偏移多少、文字折行后高度怎么变,全部在这一步定下来。

一个经典坑:没有声明尺寸的图片 。图片是异步到达的,若 HTML 里没写 width / height,浏览器在布局阶段不知道它占多大,等图片下载完、知道真实尺寸后,只能再触发一次重排把后面的内容往下挤:

html 复制代码
<!-- 反例:不写尺寸,图片到达后触发二次重排 -->
<img src="banner.png" alt="横幅">

<!-- 正例:提前占好位,避免布局抖动 -->
<img src="banner.png" alt="横幅" width="1200" height="400">

在现代浏览器里还可以用 aspect-ratio 或 CSS width / height 属性预留宽高比,让布局时就能算准位置,图片到达后只补像素、不重排。这点在第十一节的优化清单里还会展开。

七、绘制(Paint)与合成(Composite):图层与 GPU 加速

布局算出了「在哪、多大」,**绘制(Paint)**负责把每个盒子真正变成像素:文字字形、颜色填充、边框、阴影、替换元素(图片 / 视频)等,逐一画到内存里的图层上。

浏览器并不会把所有东西都画在一张「画布」上,而是把页面拆成多个图层(layer) ,各自绘制,最后由 GPU 合成(Composite) 把这些图层拼成最终画面。这带来一个关键收益:当一个图层内部变化时,只需重绘该层再合成,不必重绘整页。

哪些元素 / 属性会「升级」成独立合成层(送 GPU)?常见清单:

元素 / 属性 是否通常创建合成层 说明
拥有 3D transform(如 translateZ(0)) 是 经典「图层提升」hack
will-change: transform 等 是 显式提示浏览器
position: fixed / sticky 通常是 滚动时独立合成更顺
<video> / <canvas> / <iframe> 通常是 自带合成层
高 opacity 动画 是(动画期间) 仅合成、不重绘
普通 background-color 变化 否 通常触发重绘而非独立层

注意:分层能省 CPU 但更耗内存 (每层都要一块显存)。所以合成层不是「越多越好」------这正是第十节 will-change 不能乱加的原因。

八、重排与重绘:触发条件、代价与「渲染瀑布流」

现在把前面串起来,看一次样式变化到底会点燃多长的链路(渲染瀑布流):

text 复制代码
Recalculate Style(算计算样式)
      ↓
   Layout(重排)------ 若存在几何变化
      ↓
   Paint(重绘)------ 若存在像素变化
      ↓
 Composite(合成)------ 始终存在(拼图层)

代价从高到低分三类,记住这个表就能判断「我这个改动贵不贵」:

你改的属性 触发链路 代价 典型例子
几何 / 位置类 重排 → 重绘 → 合成 最贵 width、height、margin、top、left、增删节点、resize 视口
像素 / 外观类 重绘 → 合成 中等 color、background、box-shadow、visibility、outline
合成层属性 仅合成 最便宜 transform、opacity(在自身合成层由 GPU 处理)

直觉记忆:动「筋骨」(位置尺寸)最贵,换「皮肤」(颜色)中等,换「图层」(transform/opacity)最便宜。

触发重排(Layout)的常见操作:

javascript 复制代码
// 几何相关:几乎都会重排
el.style.width = '300px';
el.style.marginTop = '20px';
el.style.top = '100px';
el.appendChild(document.createElement('div')); // 增删节点
window.resizeTo(800, 600);                       // 视口变化

// 仅重绘(不会重排)
el.style.color = '#f00';
el.style.backgroundColor = '#fff';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,.2)';

// 仅合成(自身层内)
el.style.transform = 'translateX(100px)';
el.style.opacity = '0.5';

九、强制同步布局与布局抖动(Layout Thrashing)

前面说「重排很贵」,正常情况下浏览器会聪明地批量 处理:你连续写一堆样式,它攒到最后一次性重排。但有些代码会破坏这个优化------主动逼浏览器立刻回流。

当你读取几何类属性时,浏览器为了保证返回的是「最新值」,必须先把前面累积的写操作结算成一次重排,才能读:

javascript 复制代码
// 这些「读」会强制同步布局(forced synchronous layout)
const top = el.offsetTop;
const w = el.offsetWidth;
const s = el.scrollTop;
const cw = el.clientWidth;
const style = getComputedStyle(el).width; // 同样会强制回流

问题在「写---读---写---读」循环里:每读一次,浏览器就得先把前面所有写结算成重排,于是循环一次就重排一次,整页疯狂抖动,这就是布局抖动(Layout Thrashing)。

javascript 复制代码
// 反例:循环中读写 offset,每次读都强制重排,性能灾难
function bad() {
  const boxes = document.querySelectorAll('.box');
  boxes.forEach(box => {
    box.style.width = box.offsetWidth + 10 + 'px'; // 读前先重排!
  });
}

// 正例:先批量读,再批量写,只重排一次
function good() {
  const boxes = document.querySelectorAll('.box');
  const widths = [...boxes].map(box => box.offsetWidth); // 先全读
  boxes.forEach((box, i) => {
    box.style.width = widths[i] + 10 + 'px';             // 再全写
  });
}

更稳妥的做法是用 requestAnimationFrame 把「读」和「写」分到不同帧,或在写入前把需要的值一次性读齐。核心原则就一句:读归读、写归写,别在循环里交替。

十、GPU 加速实战:把动画送上合成层

动画是重排重绘最容易翻车的地方。结论先给:位移 / 缩放动画优先用 transform 和 opacity,它们只走合成,不触发重排 / 重绘。

css 复制代码
/* 反例:用 left 做位移动画,每帧触发重排 → 重绘 → 合成,掉帧 */
.box-bad {
  position: absolute;
  left: 0;
  transition: left 1s ease;
}
.box-bad.run { left: 300px; }

/* 正例:用 transform 做位移,仅合成,GPU 处理,丝滑 */
.box-good {
  transform: translateX(0);
  transition: transform 1s ease;
}
.box-good.run { transform: translateX(300px); }

在 Chrome DevTools 里打开 Paint Flashing (高亮重绘区域):用 left 动画时整块区域疯狂闪烁(持续重绘),用 transform 时几乎不闪------这就是「只合成」的直观证据。

will-change 是给浏览器的「预告」:这个元素接下来要变,提前帮我把层建好。

css 复制代码
.card {
  will-change: transform; /* 提示浏览器:transform 会动,预提升图层 */
  transition: transform .3s;
}
.card:hover { transform: scale(1.05); }

但 will-change 是「最后一招」,不是银弹:

  • 别给几十个元素都加 will-change,每层吃显存,滥用反而更卡;
  • 元素不再变化时应移除(如动画结束 will-change: auto),让浏览器回收图层;
  • 与其预测性堆 will-change,不如先确认瓶颈真在重绘 / 重排上(用 Performance 面板看);
  • 经典「图层提升」写法 transform: translateZ(0) 与 will-change: transform 效果类似,但同样不应滥用。

其它会被送 GPU 的情况:position: fixed、3D 变换(translateZ / rotate3d)、<video> / <canvas> 等。

十一、首屏优化清单:缩短关键渲染路径的 10 条建议

把前面所有原理落到「首屏更快」上,给出 10 条可直接照做的清单:

  1. 非关键 JS 用 defer / async,避免阻塞 HTML 解析。
  2. 拆分 CSS 用媒体查询,让不阻塞渲染的样式不挡路。
  3. 内联首屏关键 CSS,其余异步加载,缩短首次渲染。
  4. 移除未使用 CSS,减小 CSSOM 构建与层叠计算成本。
  5. 图片声明 width / height(或 aspect-ratio),避免到达后二次重排。
  6. 图片懒加载 loading="lazy",缩短关键渲染路径上的资源量。
  7. 预连接 / 预加载关键资源 :dns-prefetch、preconnect、preload。
  8. 减少 DOM 节点数,降低布局 / 绘制 / 合成的整体开销。
  9. 批量改样式而非逐条改,避免布局抖动(参考第九节)。
  10. 动画走 transform / opacity ,谨慎使用 will-change,控制首屏内容体积。

第 1 条和第 2 条写成代码就是这样:

html 复制代码
<!-- 脚本:defer 等 DOM 解析完再按顺序执行;async 下载完就执行,不保证顺序 -->
<script defer src="app.js"></script>
<script async src="analytics.js"></script>

<!-- CSS:print 媒体不阻塞屏幕渲染;screen 才阻塞 -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="main.css" media="screen">

<!-- 预连接 / 预加载关键资源,抢在请求之前建立连接 -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="preload" href="critical.css" as="style">

第 5、6 条:

html 复制代码
<!-- 声明尺寸,占位不重排 -->
<img src="hero.png" alt="首图" width="1200" height="400">
<!-- 非首屏图片懒加载 -->
<img src="below-fold.jpg" alt="次屏" loading="lazy">

十二、常见误区与 DevTools 验证

最后澄清几个高频误区,并给出用 DevTools 自证的方法。

误区 真相
display:none 一定比 visibility:hidden 省 前者不进渲染树、后者进但占空间;要「彻底移除」用前者,要「保留布局」用后者,看场景
will-change 加得越多越快 分层耗显存,应针对性使用并在动画后回收,滥用更卡
改颜色一定比重排便宜 改颜色确实只重绘,但大面积 / 全屏重绘仍然贵,别轻视重绘成本
用了 transform 就绝对不重绘 仅当元素在自身合成层且只动 transform/opacity 时「只合成」;涉及其它属性仍会重绘

验证手段(以 Chrome 为例):

  1. Performance 面板 :录制一次交互,看 Layout / Paint / Composite 各占多少时间。掉帧时通常能看到长条的 Layout 或 Paint 任务;
  2. Paint Flashing 工具 (Rendering → Paint flashing):开启后重绘区域会绿色高亮。用 left 做动画时满屏闪,用 transform 时基本不闪------直观验证属性选择;
  3. 对比实测 :写一个 transition: left 和一个 transition: transform 的方块,各跑一遍 Performance,对比 Layout 耗时差异。

把这套「看瀑布流 → 找最贵一步 → 换成更便宜的属性」的方法变成习惯,性能优化就不是玄学,而是可度量、可复现的工程动作。


参考资料

© 2026 | 转载请注明出处

结论:PASS

相关推荐
明月_清风1 小时前
前端已死?别急,这可能只是所有行业的开始
前端·ai编程
明月_清风1 小时前
干了 6 年前端,我是怎么一步步转型到 AI 的?
前端·后端·ai编程
乘风gg1 小时前
花了 100 亿 Token 后,我发现 Code is cheap 是最大的谎言
前端·ai编程·claude
IMPYLH1 小时前
HTML 的 <textarea> 元素
前端·html
吴声子夜歌1 小时前
HTML——庞杂的表单控件元素(二)
前端·html
吴声子夜歌1 小时前
HTML——无障碍访问(一)
前端·html
代码无边,回头是岸1 小时前
allfile‑preview|一款适配局域网,开箱即用的前端统一文件预览方案
前端
曹牧1 小时前
Spring MVC : Controller 层URL划分
java·运维·服务器·前端