2. 浏览器渲染流程详解:关键渲染路径(CRP)

说实话,很多前端搞了几年,对浏览器怎么把代码变成画面这件事,还是模模糊糊的。

我刚开始带团队时,有个组员改了一行CSS,结果页面白屏了两秒。他一脸懵,不知道发生了什么。

说白了,你如果不理解浏览器渲染的完整链条,你就没法定位卡顿的根因。尤其是内存泄漏,它几乎可以攻击这条链上的每一个环节。

这一章,我们来彻底搞懂关键渲染路径。

什么是关键渲染路径?

关键渲染路径,英文叫 Critical Rendering Path,简称 CRP。它是浏览器将 HTML、CSS、JavaScript 转换为屏幕上像素的整个过程。

我见过很多人以为 "渲染" 就是 paint 那一步。其实不是的。它是一整条流水线,从资源加载到像素绘制,每一步都可能成为瓶颈。

整个路径可以拆成五个核心步骤:

  1. DOM 树构建 —— 解析 HTML,生成节点树
  2. CSSOM 树构建 —— 解析 CSS,生成样式规则树
  3. 渲染树构建 —— 合并 DOM + CSSOM,生成可见节点树
  4. 布局(Layout) —— 计算每个节点的几何位置
  5. 绘制(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)完整流程图 HTML 文档 DOM 树 CSS 文档 CSSOM 树 HTML 解析器 CSS 解析器 渲染树 合并可见节点 布局(Layout) 计算几何位置 绘制(Paint) 内存泄漏影响点 • DOM 泄漏 → 渲染树膨胀 • 样式反复计算 → 布局抖动 • 未释放的图层 → 绘制过重 • 内存持续增长 → 页面卡顿

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 的五个步骤,就等于拿到了分析卡顿的导航地图。接下来,当我们深入内存泄漏时,你会发现每一个泄漏类型都能在这个路径上找到对应的破坏点。