主题
浏览器渲染流程
从 URL 到像素
当用户在地址栏输入 URL 并按下回车,到页面呈现在屏幕上,浏览器经历了以下完整流程:
URL 输入
↓
DNS 解析(域名 → IP)
↓
TCP 三次握手(+ TLS 握手,如果 HTTPS)
↓
HTTP 请求/响应
↓
━━━━━━━ 渲染流水线开始 ━━━━━━━
↓
HTML 解析 → DOM Tree
↓ 并行
CSS 解析 → CSSOM Tree ←──────── 可能阻塞渲染
↓
DOM + CSSOM → Render Tree
↓
Layout(布局 / 回流)
↓
Paint(绘制)
↓
Composite(合成)
↓
像素显示在屏幕上本文聚焦渲染流水线部分——从拿到 HTML 响应到像素上屏的全过程。
构建 DOM 树
浏览器收到 HTML 字节流后,经过以下步骤构建 DOM 树:
字节(Bytes)→ 字符(Characters)→ 令牌(Tokens)→ 节点(Nodes)→ DOM Tree
具体过程:
1. 解码:将字节流按编码(UTF-8)转为字符
2. 词法分析(Tokenizer):将字符流分割为 Token
- 开始标签 Token:<div class="card">
- 文本 Token:Hello World
- 结束标签 Token:</div>
3. 语法分析(Parser):根据 Token 构建 DOM 节点
4. 构建树形结构:根据嵌套关系连接父子节点
document
|
<html>
/ \
<head> <body>
| |
<link> <div>
/ \
<h1> <p>增量解析
HTML 解析器是增量式的——不需要等到整个 HTML 文档下载完毕才开始解析:
网络层返回第一个 chunk: <html><head><link rel="stylesheet"...
↓ 立即开始解析
解析器处理 <link> → 发起 CSS 请求
网络层返回第二个 chunk: ></head><body><div>Hello</div>
↓ 继续解析
解析器处理 <body><div> → 构建 DOM 节点解析阻塞
关键问题:什么会阻塞 HTML 解析?
1. <script>(无 async/defer)
解析器遇到 <script> → 暂停 HTML 解析 → 下载 + 执行 JS → 恢复解析
原因:JS 可能调用 document.write() 修改 DOM 结构
2. <script defer>
不阻塞解析 → HTML 解析完毕后、DOMContentLoaded 之前执行
按出现顺序执行
3. <script async>
不阻塞解析 → 下载完立即执行(可能在解析过程中)
执行顺序不确定
4. <link rel="stylesheet">
不直接阻塞 HTML 解析
但会阻塞后续 <script> 的执行(因为 JS 可能读取样式信息)
会阻塞渲染(Render Tree 需要 CSSOM)时间线对比:
同步 <script>:
HTML ████░░░░░░░████████ (中间暂停等待 JS)
JS ████████ (下载 + 执行)
<script defer>:
HTML ████████████████████ (不阻塞)
JS ████ (HTML 解析完毕后执行)
<script async>:
HTML ████████████████████ (不阻塞)
JS ████ (下载完立即执行,可能打断渲染)构建 CSSOM 树
浏览器收到 CSS(外部样式表、<style> 标签、内联样式)后,构建 CSSOM(CSS Object Model):
CSS 字节 → 字符 → Token → 节点 → CSSOM Tree
body { font-size: 16px; }
.card { padding: 16px; background: #fff; }
.card .title { font-weight: bold; }
CSSOM Tree:
body
font-size: 16px
|
.card
padding: 16px
background: #fff
|
.card .title
font-weight: boldCSS 阻塞渲染
CSS 被视为渲染阻塞资源(Render-Blocking Resource):
原因:
浏览器需要完整的 CSSOM 才能构建 Render Tree
→ 如果 CSS 未加载完,浏览器不会渲染任何内容
→ 避免出现"无样式内容闪烁"(FOUC)
优化策略:
1. 关键 CSS 内联到 <head> 的 <style> 中
2. 非关键 CSS 异步加载:
<link rel="preload" href="non-critical.css" as="style"
onload="this.rel='stylesheet'">
3. media 属性标记非匹配样式表为非阻塞:
<link rel="stylesheet" href="print.css" media="print">构建 Render Tree
DOM Tree + CSSOM Tree → Render Tree(渲染树):
DOM Tree CSSOM Render Tree
───────── ───── ───────────
html (不在渲染树中)
├─ head (不在渲染树中)
│ ├─ meta (不在渲染树中)
│ └─ link (不在渲染树中)
└─ body body{font:16px} body {font:16px}
├─ div.card .card{padding:16px} div.card {padding:16px}
│ ├─ h1.title .title{bold} h1.title {bold, 16px}
│ └─ p p{color:#666} p {color:#666, 16px}
└─ div.hidden .hidden{display:none} (不在渲染树中!)关键规则:
- 不可见元素不进入 Render Tree ——
display: none的元素(及其子树)不会出现在渲染树中 visibility: hidden仍在渲染树中 —— 占位但不可见,与display: none不同<head>中的元素不在渲染树中 ——<meta>、<link>、<script>等- 伪元素会进入渲染树 ——
::before、::after不在 DOM 中但在渲染树中 - 样式继承在此阶段计算 —— 子节点从父节点继承
font-size、color等
Layout(布局 / 回流 / Reflow)
计算每个渲染树节点的精确几何信息——位置(x, y)和尺寸(width, height):
Render Tree 节点
↓
布局计算
↓
Layout Tree(带几何信息)
.card {
width: 50%; → 父容器 800px → 计算为 400px
padding: 16px; → 实际内容区 400 - 32 = 368px
margin: 0 auto; → 左偏移 = (800 - 400) / 2 = 200px
}触发回流(Reflow)的操作
回流是代价最高的渲染操作——修改元素几何属性会导致部分或整个渲染树重新布局:
必然触发回流的操作:
几何属性变化:
width / height / padding / margin / border
top / left / right / bottom
font-size / line-height
display / position / float
DOM 结构变化:
添加/删除/移动 DOM 节点
修改文本内容
窗口变化:
resize 事件
滚动(某些情况)
读取布局信息(强制同步布局):
offsetTop / offsetLeft / offsetWidth / offsetHeight
scrollTop / scrollLeft / scrollWidth / scrollHeight
clientTop / clientLeft / clientWidth / clientHeight
getComputedStyle()
getBoundingClientRect()强制同步布局(Layout Thrashing)
js
// ❌ 强制同步布局——每次循环都触发回流
const elements = document.querySelectorAll('.item');
for (const el of elements) {
const width = el.offsetWidth; // 读取 → 触发布局计算
el.style.width = width * 2 + 'px'; // 写入 → 使布局失效
// 下一次 offsetWidth 读取时,浏览器必须重新布局
}
// ✅ 批量读取,批量写入
const widths = [];
for (const el of elements) {
widths.push(el.offsetWidth); // 批量读取
}
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = widths[i] * 2 + 'px'; // 批量写入
}Paint(绘制 / 重绘 / Repaint)
将布局信息转换为绘制指令列表(Paint Records):
Layout Tree
↓
绘制指令列表(类似 Canvas 2D 的绘制命令)
↓
[
"drawRect(200, 100, 400, 300, #fff)",
"drawBorder(200, 100, 400, 300, 1px, #e0e0e0)",
"drawText(216, 116, 'Hello', bold 18px sans-serif, #333)",
"drawText(216, 148, 'Description', 14px sans-serif, #666)",
]绘制顺序
元素的绘制遵循层叠上下文的 7 层顺序(从底到顶):
1. 背景色(background-color)
2. 背景图(background-image)
3. 边框(border)
4. 子元素(按文档流顺序)
5. 轮廓(outline)
6. 浮动元素
7. 定位元素(按 z-index)触发重绘但不触发回流的属性
仅触发重绘(不改变几何信息):
color / background-color / background-image
visibility / outline / box-shadow
border-color / border-style(不改变 border-width)
代价:比回流低得多(不需要重新计算布局)回流 vs 重绘
回流(Reflow / Layout) 重绘(Repaint)
────────────────── ──────────────
改变几何属性 改变视觉属性(不影响布局)
必然触发重绘 不一定触发回流
代价高(可能影响整棵子树) 代价较低
width/height/margin/padding color/background/visibilityComposite(合成)
现代浏览器将页面分为多个合成层(Compositing Layer),各层独立光栅化,最终由 GPU 合成为最终画面:
Render Tree
↓
分层(Layer)
↓
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ 主文档层 │ │ 固定导航栏层 │ │ 弹出层 │
│ (大部分内容) │ │ (position: │ │ (z-index, │
│ │ │ fixed) │ │ transform) │
└─────────────┘ └──────────────┘ └──────────────┘
↓ ↓ ↓
光栅化 光栅化 光栅化
(Rasterize) (Rasterize) (Rasterize)
↓ ↓ ↓
└────────────────┼──────────────────┘
↓
GPU 合成(Composite)
↓
屏幕像素创建独立合成层的条件
以下属性/条件会创建新的合成层:
1. will-change: transform / opacity / filter
2. transform: translateZ(0) / translate3d(0,0,0)(hack 方式)
3. position: fixed
4. <video> / <canvas> / <iframe>
5. 有 3D transform 的元素
6. 对 opacity / transform / filter 做动画
7. 有 CSS filter 的元素
8. 元素有一个包含合成层的后代节点(隐式合成)合成层的优势
不使用合成层的动画:
用户交互 → JS → Style → Layout → Paint → Composite
↑ ↑
可能触发 可能触发
(代价很高) (代价中等)
使用合成层的动画(仅 transform / opacity):
用户交互 → JS → Style → Composite
↑
只触发合成
(代价极低,GPU 处理)
关键:transform 和 opacity 的动画可以完全在合成线程中完成,
不需要回到主线程做 Layout 和 Paint完整渲染流水线(Pixel Pipeline)
JavaScript → Style → Layout → Paint → Composite
| | | | |
修改 DOM 计算样式 计算布局 生成绘制 GPU 合成
触发动画 匹配规则 几何信息 指令列表 最终画面
三种更新路径:
路径 1(最重):JS → Style → Layout → Paint → Composite
触发条件:修改了几何属性(width/height/margin...)
路径 2(中等):JS → Style → Paint → Composite
触发条件:修改了视觉属性(color/background/visibility...)
跳过 Layout
路径 3(最轻):JS → Style → Composite
触发条件:仅修改 transform / opacity
跳过 Layout 和 Paint关键渲染路径(Critical Rendering Path)
从收到 HTML 到首次渲染(First Paint)的最短路径:
关键资源:
HTML → 解析为 DOM
CSS → 解析为 CSSOM(渲染阻塞)
JS → 可能修改 DOM/CSSOM(解析阻塞)
┌─── 渲染阻塞 ───┐
HTML ───→ DOM ──────┤ ├──→ Render Tree → Layout → Paint → FP
CSS ───→ CSSOM ────┘ │
JS ───→ 执行 ─→ 可能修改 DOM/CSSOM─┘
└── 解析阻塞 ──┘
优化关键渲染路径的目标:
1. 减少关键资源数量
2. 减少关键资源大小
3. 减少关键资源的往返次数(RTT)优化策略
策略 1:关键 CSS 内联
<head>
<style>
/* 首屏需要的样式直接内联 */
body { margin: 0; font-family: sans-serif; }
.header { height: 64px; background: #fff; }
.hero { padding: 48px 0; }
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="full.css" as="style"
onload="this.rel='stylesheet'">
</head>
策略 2:JS 延迟加载
<!-- defer:不阻塞解析,DOMContentLoaded 前按序执行 -->
<script defer src="app.js"></script>
<!-- async:不阻塞解析,下载完立即执行 -->
<script async src="analytics.js"></script>
策略 3:资源预加载
<link rel="preconnect" href="https://api.example.com">
<link rel="preload" href="hero-image.webp" as="image">
<link rel="dns-prefetch" href="https://cdn.example.com">
策略 4:减少关键资源大小
- HTML/CSS/JS 压缩(Gzip/Brotli)
- Tree-shaking 移除无用代码
- 代码分割(Code Splitting)首屏只加载必要代码回流优化实战
方法 1:批量 DOM 操作
js
// ❌ 每次修改触发一次回流
for (let i = 0; i < 100; i++) {
const el = document.createElement('div');
el.textContent = `Item ${i}`;
container.appendChild(el); // 100 次回流
}
// ✅ DocumentFragment 批量插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const el = document.createElement('div');
el.textContent = `Item ${i}`;
fragment.appendChild(el);
}
container.appendChild(fragment); // 1 次回流方法 2:脱离文档流修改
js
// ✅ display: none → 修改 → display: block(2 次回流代替 N 次)
element.style.display = 'none';
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';
element.style.display = 'block';方法 3:使用 CSS class 替代多次 style 修改
js
// ❌ 多次修改 style(可能触发多次回流)
el.style.width = '100px';
el.style.height = '200px';
el.style.background = 'red';
// ✅ 一次性切换 class(1 次回流)
el.classList.add('active');方法 4:使用 transform 代替几何属性
css
/* ❌ 触发 Layout + Paint */
.box {
transition: left 0.3s;
}
.box:hover {
left: 100px;
}
/* ✅ 只触发 Composite */
.box {
transition: transform 0.3s;
}
.box:hover {
transform: translateX(100px);
}方法 5:requestAnimationFrame
js
// ❌ 在任意时机修改样式(可能导致强制同步布局)
window.addEventListener('scroll', () => {
element.style.transform = `translateY(${window.scrollY}px)`;
});
// ✅ 在浏览器下一帧渲染前修改
window.addEventListener('scroll', () => {
requestAnimationFrame(() => {
element.style.transform = `translateY(${window.scrollY}px)`;
});
});浏览器渲染进程架构
现代 Chrome 浏览器采用多进程 + 多线程架构:
Browser Process Renderer Process (每个 Tab 一个)
───────────────── ────────────────────────────────
UI 线程 主线程(Main Thread)
网络线程 ├─ HTML 解析
存储线程 ├─ CSS 解析
├─ JS 执行
├─ Style 计算
├─ Layout
└─ Paint(生成绘制指令)
合成线程(Compositor Thread)
├─ 分层(Layerize)
├─ 分块(Tiling)
└─ 光栅化调度
光栅化线程池(Raster Threads)
└─ 将图块光栅化为位图
GPU Process
─────────────────
执行最终合成 + 显示关键认知:
- 主线程是瓶颈 —— JS 执行、样式计算、布局、绘制都在主线程
- 合成线程独立于主线程 ——
transform/opacity动画在合成线程处理,不受主线程阻塞影响 - 这就是为什么 JS 长任务会导致页面卡顿 —— 主线程被 JS 占用时无法处理布局和绘制
面试高频题
1. 浏览器渲染的完整流程是什么?
浏览器收到 HTML 后:①解析 HTML 构建 DOM 树(增量解析);②解析 CSS 构建 CSSOM 树;③DOM + CSSOM 合并为 Render Tree(不包含 display: none 的元素);④Layout 阶段计算每个节点的精确位置和尺寸;⑤Paint 阶段生成绘制指令列表;⑥Composite 阶段将页面分为多个合成层,交给 GPU 合成最终画面。其中 CSS 是渲染阻塞资源(没有 CSSOM 就不能构建 Render Tree),同步 JS 是解析阻塞资源(暂停 HTML 解析)。
2. 回流(Reflow)和重绘(Repaint)的区别?如何减少回流?
回流是重新计算元素的几何属性(位置、尺寸),代价高且会触发重绘。重绘是重新绘制视觉属性(颜色、背景),不涉及布局计算,代价较低。减少回流的方法:①使用 transform 代替 top/left;②批量 DOM 操作(DocumentFragment / 一次性切换 class);③避免强制同步布局(不要在循环中交替读写布局属性);④脱离文档流后再做大量修改;⑤使用 requestAnimationFrame 收敛帧内的样式修改。
3. 什么是合成层?为什么 transform 动画性能更好?
合成层是浏览器将页面分割成的独立图层,每个层独立光栅化,最终由 GPU 合成。transform 和 opacity 动画性能好是因为:①它们不改变元素的布局信息,跳过了 Layout 和 Paint 阶段;②动画直接在合成线程(Compositor Thread)中处理,不占用主线程;③GPU 原生支持矩阵变换和透明度混合。创建合成层的方式包括 will-change: transform、transform: translateZ(0)、position: fixed 等。但过多合成层会消耗显存,需要注意"层爆炸"问题。
4. <script> 的 async 和 defer 属性有什么区别?
两者都不会阻塞 HTML 解析(下载阶段并行)。区别在于执行时机:①defer:HTML 解析完成后、DOMContentLoaded 事件之前,按文档中的出现顺序执行。②async:下载完成后立即执行,可能在 HTML 解析过程中执行,执行顺序不确定。适用场景:defer 适合需要操作 DOM 的脚本(有序依赖);async 适合独立的第三方脚本(如统计、广告,无依赖关系)。
5. 什么是关键渲染路径?如何优化?
关键渲染路径是浏览器从收到 HTML 到完成首次渲染(First Paint)必须经过的步骤——包括构建 DOM、构建 CSSOM、构建 Render Tree、Layout、Paint。优化方向:①减少关键资源数量——合并文件、移除非首屏 CSS/JS;②减少关键资源大小——压缩(Gzip/Brotli)、Tree-shaking、代码分割;③减少阻塞时间——关键 CSS 内联、JS 使用 defer/async、使用 preconnect/preload 提前建立连接和加载资源;④减少渲染阻塞——非关键 CSS 异步加载、使用 media 属性标记打印样式表。
追问思考
requestAnimationFrame的回调在渲染流水线的哪个阶段执行?它和setTimeout(fn, 0)在时序上有什么本质区别?- Chrome DevTools 的 Performance 面板中,如何识别"强制同步布局"(Forced Synchronous Layout)?它在火焰图中是什么样子的?
content-visibility: auto是如何优化大型页面渲染性能的?它跳过了渲染流水线的哪些阶段?- React 的虚拟 DOM Diff 和批量更新机制,本质上是在优化渲染流水线的哪个环节?
- 为什么 CSS 动画比 JS 动画更流畅?从浏览器线程架构的角度解释。