跳到主要内容
预计阅读 38 分钟

浏览器篇 | 渲染顺序

前言

当你在浏览器地址栏敲下回车的那一刻,到页面呈现在你眼前,中间经历了什么?这个问题几乎是前端面试的”开场白级”考题。

大部分人都能说出”HTML 解析成 DOM,CSS 解析成 CSSOM,然后合并成渲染树”这样的大致流程。但如果面试官追问下去:

  • CSS 到底是阻塞 DOM 解析还是阻塞渲染?两者有什么区别?
  • JS 为什么能阻塞 DOM 解析?是下载阻塞还是执行阻塞?
  • DOMContentLoadedload 事件分别在什么时机触发?它们和 CSS 加载有什么关系?
  • 什么是”关键渲染路径”?怎么优化它?

很多人就开始含糊了。

本章我们就把浏览器从拿到 HTML 到像素呈现在屏幕上的完整过程拆解清楚,让你面试时能讲出一条完整、准确的链路。


诊断自测

Q1:CSS 会阻塞 DOM 解析吗?会阻塞渲染吗?

点击查看答案

CSS 不阻塞 DOM 解析,但阻塞渲染。浏览器解析 HTML 构建 DOM 树的过程不会因为 CSS 而暂停,但在 CSSOM 构建完成之前,浏览器不会进行渲染(不会生成渲染树)。此外,CSS 还会阻塞其后 JS 的执行——因为 JS 可能会读取样式信息(比如 getComputedStyle),所以浏览器必须等 CSSOM 就绪后才执行 JS。

Q2:DOMContentLoaded 事件是在 DOM 解析完就触发,还是要等 CSS 加载完?

点击查看答案

DOMContentLoaded 是在DOM 解析完成后触发,不等待图片、样式表等子资源。但有一个例外:如果页面中有 <script> 标签(非 async),而这个脚本前面有 CSS 还没加载完,浏览器会等 CSS 加载完 → 执行 JS → 然后才能继续解析 DOM → 最终触发 DOMContentLoaded。所以 CSS 可能间接延迟 DOMContentLoaded

Q3:Layout(布局)和 Paint(绘制)的区别是什么?

点击查看答案

Layout(也叫 Reflow,回流)计算的是每个元素在页面上的几何位置和大小——在哪里、多宽、多高。Paint(绘制)则是把元素的视觉属性画出来——颜色、边框、阴影、文字等。Layout 确定”在哪画、画多大”,Paint 确定”画成什么样”。改变元素的位置或尺寸会触发 Layout + Paint,而只改变颜色等视觉属性只触发 Paint。


一、关键渲染路径全景图

浏览器从接收到 HTML 到把像素画到屏幕上,经历以下主要步骤:

HTML 字节流
    ↓ 解码
字符流
    ↓ 词法分析(Tokenize)
Token 流
    ↓ 语法分析
DOM 树
    +
CSS 字节流 → CSSOM 树
    ↓ 合并
Render Tree(渲染树)

Layout(布局 / 回流)

Paint(绘制)

Composite(合成)

屏幕上的像素

这条链路叫做关键渲染路径(Critical Rendering Path),是浏览器渲染性能的核心。下面我们逐步拆解每个环节。


二、HTML → DOM 树

解析过程

浏览器收到 HTML 后,经过以下步骤构建 DOM 树:

  1. 解码:将字节流按编码(如 UTF-8)转换为字符
  2. 词法分析(Tokenize):将字符流拆分成一个个 Token(如 StartTag: divCharacter: helloEndTag: div
  3. 语法分析(Parse):根据 Token 构建 DOM 节点,形成树形结构
<html>
  <body>
    <div>
      <p>Hello</p>
    </div>
  </body>
</html>

解析后生成的 DOM 树:

Document
  └── html
       └── body
            └── div
                 └── p
                      └── "Hello"

增量解析

DOM 的构建是增量的——浏览器不需要等整个 HTML 下载完才开始解析。网络传来一部分字节,就解析一部分,构建一部分 DOM 节点。这就是为什么大型页面可以”逐渐”显示出来。

什么会打断 DOM 解析

DOM 解析过程中,如果遇到以下内容,会暂停:

  • 同步 <script> 标签:暂停解析,下载并执行脚本
  • document.write():可能修改 token 流,导致解析器重新工作

这就是我们在后续章节(async 与 defer)会详细讲解的:普通 script 会阻塞 DOM 解析。


三、CSS → CSSOM 树

解析过程

和 DOM 的构建类似,浏览器也会将 CSS 解析成一个树形结构——CSSOM(CSS Object Model)

body { font-size: 16px; }
div { color: red; }
div p { font-weight: bold; }

解析后生成的 CSSOM 树(简化示意):

body
  font-size: 16px
  └── div
       color: red
       └── p
            font-weight: bold

CSSOM 是一棵树,而不是扁平的键值对列表,因为 CSS 属性会继承——子节点会继承父节点的某些样式。

CSS 阻塞渲染但不阻塞 DOM 解析

这个区分非常重要,也是面试高频考点:

  • 不阻塞 DOM 解析:浏览器在等待 CSS 下载的同时,可以继续解析 HTML、构建 DOM 树
  • 阻塞渲染:在 CSSOM 构建完成之前,浏览器不会进行渲染。因为没有 CSSOM 就无法构建渲染树,渲染树是渲染的前提

为什么要阻塞渲染?因为如果浏览器在 CSS 加载完之前就渲染页面,用户会先看到没有样式的”裸”页面,CSS 加载完后页面又突然变样——这种体验叫做 FOUC(Flash of Unstyled Content,无样式内容闪烁),非常糟糕。

CSS 阻塞 JS 执行

还有一个容易被忽略的关系:CSS 会阻塞其后 JS 的执行

<link rel="stylesheet" href="style.css">
<script>
  // 这段 JS 会等 style.css 加载完后才执行
  console.log(getComputedStyle(document.body).fontSize);
</script>

为什么?因为 JS 可能需要读取元素的样式信息(通过 getComputedStyleoffsetWidth 等)。如果 CSS 还没加载完就让 JS 执行,JS 拿到的样式可能是错的。所以浏览器会保证:在一个 script 执行之前,它前面的所有 CSS 都已经加载并解析完毕。

这就导致了一个连锁效应:

CSS 下载中 → 阻塞 JS 执行 → JS 阻塞 DOM 解析 → DOM 解析被延迟

虽然 CSS 本身不阻塞 DOM 解析,但通过”阻塞 JS”这个中间环节,它可能间接延迟 DOM 的解析。


四、DOM + CSSOM → Render Tree(渲染树)

当 DOM 树和 CSSOM 树都准备好了,浏览器会将它们合并成一棵渲染树(Render Tree)

渲染树的构建规则

  1. 从 DOM 树的根节点开始遍历
  2. 对每个可见节点,在 CSSOM 中找到对应的样式规则
  3. 将节点及其计算后的样式组合成渲染树的节点

哪些节点不会出现在渲染树中

  • display: none 的元素:既不可见也不占空间,不会进入渲染树
  • <head><script><meta> 等非可视元素:不需要渲染
  • visibility: hidden 的元素:虽然不可见,但出现在渲染树中(因为它仍然占据空间)
/* 不在渲染树中 */
.hidden { display: none; }

/* 在渲染树中(占空间但不可见) */
.invisible { visibility: hidden; }

/* 在渲染树中(完全透明但占空间且可交互) */
.transparent { opacity: 0; }

这也是一道经典面试题:display: nonevisibility: hiddenopacity: 0 三者的区别。

属性是否在渲染树中是否占空间是否可交互是否触发重排
display: none切换时触发
visibility: hidden不触发
opacity: 0不触发

五、Layout(布局 / 回流)

渲染树告诉浏览器”哪些节点需要显示,以及它们的样式是什么”,但还没有确定每个节点的具体位置和大小。这就是 Layout 阶段要做的事。

Layout 做什么

Layout(也叫 Reflow)的任务是:根据渲染树中每个节点的样式,计算出它在视口中的精确几何信息——位置(x, y)、宽度、高度。

这个过程是递归的:父元素的大小可能依赖子元素(比如 height: auto),子元素的位置又依赖父元素。浏览器需要在整棵树上做多次遍历才能确定所有节点的布局信息。

什么操作会触发 Layout

Layout 是一个计算密集的操作。以下操作会触发 Layout:

  • 改变元素的几何属性:widthheightpaddingmarginborder
  • 改变元素的位置:topleftposition
  • 改变字体大小
  • 改变窗口大小(resize)
  • 读取某些布局属性:offsetWidthoffsetHeightclientWidthscrollTopgetComputedStyle()

最后一条容易被忽略:读取布局属性也会触发 Layout。因为浏览器必须保证你读到的值是最新的,所以它不得不先执行一次 Layout。

// ❌ 糟糕的写法:每次循环都触发 Layout
for (let i = 0; i < 100; i++) {
  el.style.width = el.offsetWidth + 1 + 'px'; // 读 + 写交替
}

// ✅ 好的写法:先批量读,再批量写
const width = el.offsetWidth; // 读一次
for (let i = 0; i < 100; i++) {
  el.style.width = width + i + 1 + 'px'; // 只写
}

上面的反面例子就是所谓的强制同步布局(Forced Synchronous Layout),也叫布局抖动(Layout Thrashing),是性能杀手。


六、Paint(绘制)

Layout 确定了每个元素的位置和大小后,浏览器进入 Paint 阶段。

Paint 做什么

Paint 的任务是:将每个元素的视觉属性转换成实际的像素——颜色、背景、边框、阴影、文字等。

Paint 不关心元素在哪、多大(那是 Layout 的事),它只关心”画成什么样”。

绘制顺序

浏览器按照**层叠顺序(stacking order)**绘制元素。大致顺序是:

  1. 背景色
  2. 背景图
  3. 边框
  4. 子元素
  5. 轮廓(outline)

什么操作只触发 Paint 不触发 Layout

如果你只改变了元素的视觉属性,但没有改变它的几何信息,就只会触发 Paint 而不触发 Layout:

  • color
  • background-color
  • box-shadow
  • visibility
  • border-color(不改变 border-width)

只触发 Paint 的操作比触发 Layout 的操作开销小得多。 这也是性能优化的一个方向:尽量让动画只涉及不触发 Layout 的属性。


七、Composite(合成)

现代浏览器不会把所有内容都画在同一个画布上。它会将页面分成多个图层(Layer),分别绘制后再合成到一起。

为什么需要图层

当页面中有动画、transform、固定定位等元素时,如果每一帧都重新绘制整个页面,性能开销太大。把这些元素提升为独立的图层,改变时只需要重新绘制/合成这一层,其他层不受影响。

什么情况会创建新图层

以下情况会让浏览器为元素创建独立的合成层:

  • transform 的 3D 变换(translate3dtranslateZ
  • will-change 属性
  • position: fixed
  • <video><canvas><iframe> 元素
  • 有 CSS 动画的 opacitytransform

GPU 加速

合成阶段通常由 GPU 完成,比 CPU 绘制快得多。这就是为什么人们说”用 transform 做动画比用 left/top 做动画更流畅”:

/* ❌ 触发 Layout + Paint,由 CPU 处理 */
.move-bad {
  animation: move-bad 1s;
}
@keyframes move-bad {
  to { left: 100px; }
}

/* ✅ 只触发 Composite,由 GPU 处理 */
.move-good {
  animation: move-good 1s;
}
@keyframes move-good {
  to { transform: translateX(100px); }
}

transformopacity 的动画可以完全由合成器线程处理,不需要主线程参与,也不会触发 Layout 和 Paint。这是实现 60fps 流畅动画的关键。

完整渲染流水线

把上面的所有步骤串起来:

JavaScript → Style(重新计算样式) → Layout → Paint → Composite

这就是浏览器的渲染流水线(Rendering Pipeline)。不同类型的 CSS 属性变化会走不同长度的流水线:

属性类型经过的阶段示例
几何属性Style → Layout → Paint → Compositewidth, height, margin
视觉属性Style → Paint → Compositecolor, background, box-shadow
合成属性Style → Compositetransform, opacity

优化原则:尽量使用只触发 Composite 的属性做动画。


八、DOMContentLoaded vs load 事件

这两个事件的区别是高频面试题,也是理解渲染流程的关键节点。

DOMContentLoaded

触发时机:HTML 文档被完全解析,DOM 树构建完成。不等待样式表、图片、子框架等外部资源。

document.addEventListener('DOMContentLoaded', () => {
  // DOM 已就绪,可以安全操作 DOM
  console.log(document.getElementById('app'));
});

但有一个重要的细节:DOMContentLoaded 会等待同步 script 执行完。因为 script 可能会修改 DOM,所以 DOM 解析不会在 script 执行完之前结束。而 CSS 又会阻塞 script 的执行,所以 CSS 可能间接延迟 DOMContentLoaded

<link rel="stylesheet" href="heavy.css"> <!-- 加载很慢 -->
<script src="app.js"></script> <!-- 等 CSS 加载完才执行 -->
<!-- DOMContentLoaded 要等 app.js 执行完才触发 -->

load

触发时机:页面上所有资源(包括图片、样式表、脚本、子框架等)都完全加载完毕。

window.addEventListener('load', () => {
  // 所有资源都加载完了
  console.log('页面完全加载');
});

load 事件通常比 DOMContentLoaded 晚很多,因为图片等资源的下载可能很慢。

beforeunload 和 unload

除了 DOMContentLoadedload,页面生命周期中还有两个重要事件:

  • beforeunload:用户即将离开页面时触发,可以用来弹出”确认离开”的对话框
  • unload:页面正在被卸载时触发,通常用来做清理工作(但现代浏览器中 unload 的可靠性有限)

完整的生命周期:

DOMContentLoaded → load → beforeunload → unload

实际中怎么选

需求事件
操作 DOM 元素DOMContentLoaded
需要图片尺寸等信息load
初始化交互逻辑DOMContentLoaded
统计页面加载时间load

在现代框架(React、Vue)中,这些事件的使用频率已经降低了——框架会在组件的生命周期钩子中处理这些逻辑。但理解它们对于排查性能问题仍然很重要。


九、首屏渲染优化策略

理解了关键渲染路径后,优化思路就很清晰了:缩短关键渲染路径的长度和时间

1. 减少关键资源数量

  • 内联首屏关键 CSS(Critical CSS),减少 CSS 请求
  • 使用 deferasync 加载非关键 JS
  • 去除首屏不需要的 CSS 和 JS

2. 减少关键资源大小

  • 压缩 HTML、CSS、JS(minify + gzip/brotli)
  • 使用 Tree Shaking 去除未使用的代码
  • 图片使用现代格式(WebP、AVIF)并适当压缩

3. 减少关键渲染路径长度

  • 使用 preload 预加载关键资源
  • 减少 CSS 和 JS 的链式依赖(A 依赖 B,B 依赖 C)
  • 使用 HTTP/2 的多路复用减少请求阻塞

4. 优化渲染性能

  • 避免强制同步布局
  • 使用 transformopacity 做动画
  • 使用 will-change 提示浏览器创建合成层(但不要滥用)
  • 使用 requestAnimationFrame 而不是 setTimeout 做动画

5. 骨架屏和渐进式渲染

  • 在主内容加载前先显示骨架屏(Skeleton Screen),给用户”正在加载”的感知
  • 服务端渲染(SSR)让首屏 HTML 直接包含内容,不需要等 JS 执行
<!-- 骨架屏示例 -->
<div id="app">
  <div class="skeleton">
    <div class="skeleton-header"></div>
    <div class="skeleton-line"></div>
    <div class="skeleton-line"></div>
  </div>
</div>
<script defer src="app.js"></script>

常见误区

误区一:“CSS 阻塞 DOM 解析”

CSS 不阻塞 DOM 解析,只阻塞渲染。浏览器可以在等待 CSS 下载的同时继续解析 HTML、构建 DOM。但 CSS 会阻塞 JS 执行,而 JS 会阻塞 DOM 解析,所以 CSS 可能通过这条链路间接延迟 DOM 解析。直接说”CSS 阻塞 DOM 解析”是不准确的。

误区二:“重绘(Repaint)一定比回流(Reflow)开销小”

一般情况下确实如此,因为回流涉及几何计算且可能影响整棵树,而重绘只处理视觉属性。但如果重绘的区域很大(比如改变整个页面的背景色),开销也不小。而如果回流只影响了一个独立层中的一个小元素,开销可能很小。所以不能一概而论,要看具体情况。

误区三:“DOM 树和渲染树是一一对应的”

不是。渲染树只包含可见节点。display: none 的元素、<head> 中的内容、<script> 标签等都不会出现在渲染树中。另外,一些 CSS 属性(如 ::before::after 伪元素)会在渲染树中创建节点,但它们在 DOM 树中并不存在。

误区四:“用了 GPU 加速就一定更快”

过度使用 will-changetransform: translateZ(0) 来”hack”GPU 加速,会导致创建大量合成层,反而增加内存消耗和合成开销。每个合成层都需要独立的纹理内存。正确的做法是:只对确实需要频繁变化的元素使用 GPU 加速,而不是给每个元素都加。


小结

本章从 HTML 字节流开始,完整走了一遍浏览器的关键渲染路径。

核心要点

  1. 关键渲染路径:HTML → DOM、CSS → CSSOM → 合并成 Render Tree → Layout → Paint → Composite
  2. CSS 阻塞渲染不阻塞 DOM 解析:CSSOM 未就绪时不会渲染,但 DOM 解析可以继续
  3. CSS 阻塞 JS 执行:JS 可能读取样式,所以浏览器等 CSSOM 就绪后才执行 JS
  4. JS 阻塞 DOM 解析:同步 script 会暂停 HTML 解析
  5. 渲染树只包含可见节点display: none 不在渲染树中
  6. Layout 计算几何信息,Paint 绘制视觉属性,Composite 合成图层
  7. transformopacity 动画只触发 Composite,性能最好
  8. DOMContentLoaded 在 DOM 解析完触发,load 在所有资源加载完触发

本章思维导图

浏览器:渲染顺序
  • 关键渲染路径
    • HTML → DOM 树(增量解析)
    • CSS → CSSOM 树
    • DOM + CSSOM → Render Tree
    • Layout(布局 / 回流)
    • Paint(绘制 / 重绘)
    • Composite(合成)
  • 阻塞关系
    • CSS 不阻塞 DOM 解析,阻塞渲染
    • CSS 阻塞其后的 JS 执行
    • 同步 JS 阻塞 DOM 解析
    • CSS → 阻塞 JS → 阻塞 DOM(间接链路)
  • 渲染树
    • 只包含可见节点
    • display: none 不在渲染树中
    • visibility: hidden 在渲染树中
  • 渲染流水线
    • 几何属性变化:Layout → Paint → Composite
    • 视觉属性变化:Paint → Composite
    • 合成属性变化:Composite
  • 重要事件
    • DOMContentLoaded:DOM 解析完
    • load:所有资源加载完
    • beforeunload / unload
  • 首屏优化
    • 减少关键资源数量
    • 减少关键资源大小
    • 缩短关键路径长度
    • 用 transform/opacity 做动画
    • 骨架屏 / SSR

练习挑战

第一题(⭐ 基础):事件触发顺序

document.addEventListener('DOMContentLoaded', () => {
  console.log('A: DOMContentLoaded');
});

window.addEventListener('load', () => {
  console.log('B: load');
});

console.log('C: inline script');

假设页面中有一张很大的图片,请问 A、B、C 的输出顺序是什么?

点击查看答案与解析

输出顺序:C → A → B

  • C:内联脚本同步执行,最先输出
  • A:DOM 解析完毕后触发,不等待图片
  • B:所有资源(包括那张大图片)加载完后触发,最后输出

第二题(⭐⭐ 进阶):分析阻塞关系

<head>
  <link rel="stylesheet" href="style.css"> <!-- 假设加载需要 2 秒 -->
  <script src="app.js"></script>             <!-- 假设加载需要 1 秒 -->
</head>
<body>
  <div id="app">Hello</div>
</body>

请问:

  1. app.js 什么时候开始执行?
  2. #app 节点什么时候被解析出来?
  3. DOMContentLoaded 大约在第几秒触发?
点击查看答案与解析
  1. app.jsstyle.css 加载完后才能执行(CSS 阻塞 JS)。假设两者并行下载,CSS 2 秒下完,JS 1 秒下完但要等 CSS,所以 app.js 在第 2 秒开始执行

  2. app.js 是同步 script,它阻塞 DOM 解析。#app 在 script 之后,所以要等 script 执行完才能被解析出来。#app 在第 2 秒 + JS 执行时间后被解析。

  3. DOMContentLoaded 在整个 DOM 解析完后触发。大约在第 2 秒 + JS 执行时间后触发。

这个例子很好地展示了 CSS → 阻塞 JS → 阻塞 DOM 解析的间接链路。

第三题(⭐⭐⭐ 综合):优化下面的代码

下面的代码有严重的性能问题,请找出问题并优化:

const list = document.getElementById('list');
const items = document.querySelectorAll('.item');

items.forEach(item => {
  // 读取布局信息
  const height = item.offsetHeight;
  // 根据高度设置新样式
  item.style.marginTop = height / 2 + 'px';
  // 读取新的布局信息
  const newHeight = item.offsetHeight;
  // 设置宽度
  item.style.width = newHeight * 2 + 'px';
});
点击查看答案与解析

问题:布局抖动(Layout Thrashing)。循环中反复交替读写布局属性,每次读取都会触发强制同步布局。

优化方案:先批量读取,再批量写入。

const list = document.getElementById('list');
const items = document.querySelectorAll('.item');

// 第一遍:批量读取所有需要的布局信息
const measurements = Array.from(items).map(item => ({
  height: item.offsetHeight
}));

// 第二遍:批量写入
items.forEach((item, i) => {
  const marginTop = measurements[i].height / 2;
  item.style.marginTop = marginTop + 'px';
});

// 如果还需要基于新的布局信息做操作,用 requestAnimationFrame 分帧处理
requestAnimationFrame(() => {
  const newMeasurements = Array.from(items).map(item => ({
    height: item.offsetHeight
  }));

  items.forEach((item, i) => {
    item.style.width = newMeasurements[i].height * 2 + 'px';
  });
});

核心原则:读写分离。将所有读操作集中在一起,所有写操作集中在一起,避免读写交替导致的强制同步布局。如果需要基于写入后的新值再做操作,用 requestAnimationFrame 延迟到下一帧。


自我检测

  • 能完整说出关键渲染路径的六个步骤:DOM → CSSOM → Render Tree → Layout → Paint → Composite
  • 能区分”CSS 阻塞渲染”和”CSS 阻塞 DOM 解析”,以及解释 CSS 如何间接影响 DOM 解析
  • 能说出 display: nonevisibility: hiddenopacity: 0 在渲染树和行为上的区别
  • 能区分 Layout(回流)和 Paint(重绘),以及知道哪些 CSS 属性只触发 Paint
  • 能解释为什么 transform 做动画比 left/top 更流畅
  • 能说清楚 DOMContentLoadedload 的触发时机和区别
  • 能识别代码中的布局抖动(Layout Thrashing)问题并给出优化方案
  • 能列出至少三种首屏渲染优化策略

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90