2. 浏览器渲染流程详解:关键渲染路径(CRP)
说实话,很多前端搞了几年,对浏览器怎么把代码变成画面这件事,还是模模糊糊的。
我刚开始带团队时,有个组员改了一行CSS,结果页面白屏了两秒。他一脸懵,不知道发生了什么。
说白了,你如果不理解浏览器渲染的完整链条,你就没法定位卡顿的根因。尤其是内存泄漏,它几乎可以攻击这条链上的每一个环节。
这一章,我们来彻底搞懂关键渲染路径。
什么是关键渲染路径?
关键渲染路径,英文叫 Critical Rendering Path,简称 CRP。它是浏览器将 HTML、CSS、JavaScript 转换为屏幕上像素的整个过程。
我见过很多人以为 "渲染" 就是 paint 那一步。其实不是的。它是一整条流水线,从资源加载到像素绘制,每一步都可能成为瓶颈。
整个路径可以拆成五个核心步骤:
- DOM 树构建 —— 解析 HTML,生成节点树
- CSSOM 树构建 —— 解析 CSS,生成样式规则树
- 渲染树构建 —— 合并 DOM + CSSOM,生成可见节点树
- 布局(Layout) —— 计算每个节点的几何位置
- 绘制(Paint) —— 将像素填充到屏幕上
这五步,一步都不能少。你想想看,如果某一步卡住了,用户看到的就是一顿一顿的界面。
第一步:DOM 树构建
浏览器拿到 HTML 字节流,先做词法分析,再做语法解析,最后生成一棵 DOM 树。
这个过程里,最要命的是 阻塞解析。
注意: 当 HTML 解析器遇到 <script> 标签(非 async/defer),它会停下来,等 JS 下载并执行完,再继续解析后面的 HTML。
这就是为什么你经常看到 <script> 被建议放在 </body> 前面。
我曾经在一个项目里排查白屏问题,发现是因为第三方 SDK 的脚本太大,而且没有加 async。结果 DOM 解析被硬生生拖慢了 3 秒。
第二步:CSSOM 树构建
CSS 的解析也会阻塞渲染,但它不阻塞 HTML 解析。也就是说,HTML 可以继续往下走,但渲染要等到 CSSOM 准备好。
这里有一个常见误区:很多人以为 CSS 是异步的,其实不是。它是 渲染阻塞资源。
避坑指南: 我曾经看到有人在页面的 head 里放了十几个大的 CSS 文件。表面上看页面内容出来了,但样式一闪一闪的,用户体验极差。这是因为 CSSOM 没有及时构建完,浏览器只能先展示无样式的裸 HTML。
这就是所谓的 FOUC(Flash of Unstyled Content)。
我个人的习惯是:首屏只加载关键 CSS,其余的非关键 CSS 用 media 属性或 rel="preload" 异步加载。
<!-- 关键 CSS,立即阻塞渲染 -->
<link rel="stylesheet" href="#" aria-disabled="true" data-keke-missing-local-link="1" onclick="return false" />
<!-- 非关键 CSS,异步加载 -->
<link rel="preload" href="#" aria-disabled="true" data-keke-missing-local-link="1" onclick="return false" as="style" onload="this.rel='stylesheet'" />
第三步:渲染树构建
DOM 树 + CSSOM 树,合在一起就是渲染树。
但注意,渲染树只包含 可见节点。比如 head、display: none 的元素,是不会出现在渲染树里的。
这一步其实很快,但有一个坑:如果你用 JavaScript 动态改了大量 DOM 的样式,浏览器会反复重建渲染树。这就是所谓的 Reflow 重排 和 Repaint 重绘 的来源。
小技巧: 如果你需要批量修改样式,用 classList.add() 一次性改,不要一行行地改 style 属性。这样能减少渲染树重建的次数。
第四步:布局(Layout)
这一步,浏览器会计算每个节点在视口中的精确位置和大小。说白了,就是算坐标。
布局是一个非常昂贵的操作。为什么?因为它涉及递归遍历整个渲染树,计算每个盒模型的尺寸。
我举个例子你就明白了:
// 这段代码会触发两次布局
const box = document.getElementById('box');
console.log(box.offsetHeight); // 第一次读取,触发强制布局
box.style.height = '200px'; // 修改样式
console.log(box.offsetHeight); // 第二次读取,再次触发强制布局
这种模式叫 Layout Thrashing(布局抖动)。我之前在一个数据大屏项目里遇到过,每秒更新几十个图表,每次都反复读取 offsetHeight,结果页面直接卡成 PPT。
解决办法很简单:把读和写分开。
// 正确做法:批量读取,批量写入
const heights = [];
// 先全部读
for (let i = 0; i < elements.length; i++) {
heights.push(elements[i].offsetHeight);
}
// 再统一写
for (let i = 0; i < elements.length; i++) {
elements[i].style.height = heights[i] * 1.2 + 'px';
}
你看,就是改一下顺序,性能天差地别。
第五步:绘制(Paint)
这是最后一步。浏览器把布局计算好的节点,变成像素,填充到屏幕上。
绘制本身也分很多层。复杂页面可能会有几十个绘制层,浏览器会先画背景,再画内容,最后画边框。
我习惯把绘制理解成:你在 Photoshop 里一层层叠图层的过程。
如果页面有大量的 box-shadow、border-radius、filter 这些高消耗样式,绘制的开销会非常大。再加上内存泄漏,比如 DOM 节点没有被释放,绘制层的数量会越来越多,最终撑爆合成器的内存。
这就是为什么内存泄漏到最后,页面滚动一下都卡得要死。
CRP 完整流程图
下面这张图,把整个关键渲染路径梳理成了可视化流程。你可以对照着理解每一步的输入和输出:
CRP 的优化原则
了解了 CRP 的完整路径,优化方向就很清楚了。
| CRP 步骤 | 优化目标 | 核心手段 |
|---|---|---|
| DOM 构建 | 减少阻塞 | async/defer 脚本、减少 script 数量 |
| CSSOM 构建 | 优先加载关键 CSS | 内联关键样式、媒体查询分离 |
| 渲染树 | 减少重建次数 | 批量 DOM 操作、使用文档片段 |
| 布局 | 避免布局抖动 | 读写分离、避免强制布局 |
| 绘制 | 降低绘制复杂度 | 简化样式、使用 will-change |
为什么内存泄漏会影响 CRP?
讲到这里,你可能已经猜到了。
内存泄漏在 CRP 上的表现,不是直接让某一步变慢,而是污染整条链路。
- 泄漏的 DOM 节点挂在树上,渲染树越来越大,布局的时间指数级增长。
- 泄漏的 JavaScript 回调不断触发样式修改,引发连锁的布局抖动。
- 泄漏的图层(Layer)越积越多,绘制时合成器不堪重负。
我的经验: 有一次排查一个后台管理系统的卡顿问题,发现每次切换 Tab 都会新增几千个 DOM 节点,而且之前 Tab 的节点根本没有被销毁。结果切到第 5 个 Tab 时,渲染树已经有了 3 万多个节点,布局耗时从 2ms 飙升到 120ms。你看,这不就是典型的 "积少成多" 吗?
小结
关键渲染路径,是理解浏览器性能的基石。
你掌握了 CRP 的五个步骤,就等于拿到了分析卡顿的导航地图。接下来,当我们深入内存泄漏时,你会发现每一个泄漏类型都能在这个路径上找到对应的破坏点。