当我们在浏览器地址栏敲下回车,短短几百毫秒内,屏幕上就魔术般地呈现出丰富的网页内容。这个看似简单的过程,背后藏着浏览器内核几十年来不断优化的复杂机制。作为前端开发者,只有深入理解从 URL 到像素的每一个环节,才能写出高性能的代码。本文将以 Chrome 浏览器为例,从底层原理到实际代码,全方位拆解网页渲染的完整流程。
一、URL 解析与网络请求
输入 URL 到浏览器发起请求的过程,像是给餐厅下单采购食材 —— 只有原料到位,厨师(渲染引擎)才能开始工作。这个阶段涉及 DNS 解析、TCP 连接、HTTP 交互等多个网络层操作,每一步都影响着网页加载的速度。
1. URL 的构成与解析
我们输入的 URL(Uniform Resource Locator)本质是资源的唯一
地址,例如https://juejin.cn/user/2417207033471932
包含三个核心部分:
- 协议:
https
(加密的 HTTP 协议,基于 TLS/SSL) - 域名:
juejin.cn
(服务器的 "网络别名") - 路径:
/user/2417207033471932
(资源在服务器上的位置)
浏览器首先会解析 URL,区分出协议、域名和路径。如果是相对路径(如./style.css
),会结合当前页面的基础 URL 补全为绝对路径 —— 这一步由浏览器的 URL 解析器完成,遵循RFC 3986标准。
2. DNS 解析域名
域名只是方便人类记忆的标识,计算机之间通信需要 IP 地址(如119.3.246.xxx
)。DNS(Domain Name System)解析就是 "域名→IP
" 的翻译过程,类似查通讯录找电话号码:
- 浏览器先查本地 DNS 缓存(Chrome 可通过
chrome://net-internals/#dns
查看),如果最近访问过该域名,直接返回 IP; - 缓存未命中则查操作系统 DNS 缓存(如 Windows 的
ipconfig /displaydns
); - 仍未找到则向本地 DNS 服务器(通常是运营商提供的,如 114.114.114.114)请求;
- 本地 DNS 服务器递归查询根域名服务器→顶级域名服务器(如
.cn
)→权威域名服务器,最终返回 IP。
为优化性能,DNS 解析支持预解析:浏览器会扫描 HTML 中的<link>
、<img>
等标签,提前解析可能用到的域名。我们可以通过<link rel="dns-prefetch" href="https://img.juejin.cn">
手动触发预解析,减少后续请求的延迟。
3. TCP 三次握手建立可靠连接
拿到 IP 地址后,浏览器需要与服务器建立 TCP 连接(HTTPS 还需额外的 TLS 握手)。TCP 采用 "三次握手" 确保连接可靠:
- 客户端发送
SYN
包(请求连接); - 服务器返回
SYN+ACK
包(同意连接); - 客户端发送
ACK
包(确认连接)。
为什么需要三次握手?这是为了防止 "已失效的连接请求报文段" 突然传到服务器,导致错误。例如客户端发出的第一个SYN
因网络延迟滞留,超时后客户端重发并建立连接,若滞留的SYN
随后到达,服务器会误以为是新请求,三次握手能避免这种情况。
4. HTTP 请求与响应获取网页内容
连接建立后,浏览器发送 HTTP 请求(以 GET 为例):
GET /user/2417207033471932 HTTP/1.1
Host: juejin.cn
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...
Accept: text/html,application/xhtml+xml...
Cookie: ...
服务器处理后返回响应,包含状态码、响应头和响应体(HTML 内容):
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 12560
Cache-Control: max-age=3600
...
<!DOCTYPE html>
<html lang="zh-CN">
<head>...</head>
<body>...</body>
</html>
这里的Content-Type: text/html
告诉浏览器:"这是 HTML 文件,用 UTF-8 解码"。如果是 CSS 文件,会是text/css
;JS 则是application/javascript
。
二、HTML 解析与 DOM 树构建
浏览器拿到 HTML 字节流后,第一步是将其转化为 DOM 树 —— 这是网页的基础骨架,所有后续渲染步骤都依赖它。这个过程看似简单,实则涉及编码转换、词法分析、语法分析等多个环节。
1. 字节→字符的解码过程
网络传输的是字节流
(如0x68 0x74 0x6D 0x6C
),浏览器需要先按Content-Type
指定的编码(如 UTF-8)解码为字符
(如<html>
)。若响应头未指定编码,浏览器会:
- 检查 HTML 中的
<meta charset="utf-8">
; - 尝试自动检测编码(通过字节序列特征推测)。
编码错误会导致 "乱码",例如将 GBK 编码的 "汉字" 按 UTF-8 解码,会出现浣犲瓧
等错误字符。这就是为什么我们必须在 HTML 头部明确指定编码的原因。
2. 字符→Token(词法分析)
解码后的字符流
需要转化为有意义的 Token
(标签、属性、文本等),这个过程叫词法分析(Lexical Analysis)。例如<div class="box">hello</div>
会被拆分为:
StartTag: div, 属性: class="box"
Text: hello
EndTag: div
浏览器的 HTML 解析器是状态机驱动的,例如遇到<
进入 "标签开始状态",遇到>
进入 "标签结束状态"。特殊情况如<script>
标签,解析器会进入 "JS 解析状态",此时 HTML 解析会暂停(因为 JS 可能通过document.write
修改 DOM),这也是为什么建议<script>
放<body>
底部的原因之一。
3. Token→节点(语法分析)
Token 序列需要进一步转化为 DOM 节点,每个节点包含标签名、属性、子节点等信息。例如StartTag: div
会创建一个HTMLDivElement
对象,Text
会创建Text
节点。
解析过程中,浏览器会维护一个节点栈来处理嵌套关系:
- 遇到开始标签(如
<div>
),创建节点并压入栈; - 遇到结束标签(如
</div>
),弹出栈顶节点,并将其作为当前栈顶节点的子节点; - 文本节点直接作为当前栈顶节点的子节点。
这个过程会生成一棵以document
为根的 DOM 树,我们可以通过console.dir(document)
在控制台查看其结构:
// 简化的DOM树结构
document = {
documentElement: html, // <html>节点
body: body, // <body>节点
childNodes: [html]
};
html = {
tagName: 'HTML',
childNodes: [head, body]
};
4. HTML 解析的特殊性
HTML 不是严格的 XML(例如允许省略结束标签<p>
),因此浏览器采用容错解析(Error-Tolerant Parsing),例如:
- 自动补全缺失的结束标签(如
<ul><li>1<li>2
会补全</li></ul>
); - 忽略错误嵌套(如
<p><div></p></div>
会调整为<p></p><div></div>
)。
这种容错性保证了网页在各种 "不标准" 代码下仍能运行,但也可能导致预期外的布局问题(如p
标签内不能放div
,会被自动拆分)。
三、CSS 解析与 CSSOM 树构建
有了 DOM 骨架,还需要 CSS 来定义样式。CSS 解析过程与 HTML 类似,但语法更严格,最终生成 CSSOM(CSS Object Model)树,用于后续计算每个节点的最终样式。
1. CSS 的来源与优先级
网页的 CSS 来源包括:
- 外部样式表(
<link rel="stylesheet" href="style.css">
); - 内部样式表(
<style>
标签内的 CSS); - 内联样式(
style
属性,如<div style="color: red">
); - 用户代理样式(浏览器默认样式,如
p { margin: 1em 0 }
)。
不同来源的 CSS 优先级不同(从高到低):
- 内联样式(
style
属性) !important
声明- ID 选择器(
#box
) - 类 / 伪类 / 属性选择器(
.box
,:hover
,[type="text"]
) - 元素 / 伪元素选择器(
div
,::before
) - 继承的样式
- 用户代理样式
优先级计算规则:每个选择器对应一个 "权重值"
- ID选择器: 0,1,0,0
- 类/伪类/属性: 0,0,1,0
- 元素/伪元素: 0,0,0,1
- 内联样式: 1,0,0,0
值高者优先;值相同则后定义的覆盖先定义的。
2. CSS 解析过程
CSS 解析也分为词法分析和语法分析:
- 词法分析:将 CSS 文本拆分为
Token
(如selector { property: value; }
中的选择器、属性、值); - 语法分析:将 Token 转化为
CSS 规则
(CSSRule
),每个规则包含选择器和声明块
。
例如:
.box {
width: 100px;
color: red;
}
会被解析为:
CSSRule = {
selectorText: ".box",
style: CSSStyleDeclaration {
width: "100px",
color: "red"
}
}
CSS 解析器是上下文无关文法解析器,比 HTML 解析器更严格,错误的 CSS(如width: 100px,
多了逗号)会被直接忽略。
3. CSSOM 树的构建
CSSOM 树与 DOM 树结构类似,但每个节点存储的是该节点的所有 CSS 规则。例如body
节点的 CSSOM 节点会包含所有匹配body
的规则(如body { margin: 0 }
、.container body { ... }
等)。
构建 CSSOM 树时,浏览器会:
- 收集所有 CSS 规则;
- 对每个 DOM 节点,找出所有匹配的规则;
- 按优先级排序规则,计算最终生效的样式(样式计算)。
样式计算是耗时操作,因为选择器匹配需要遍历 DOM 和 CSSOM。例如div p:nth-child(2)
这样的复杂选择器,匹配效率远低于.class-name
。
四、RenderTree 构建
DOM 树描述结构,CSSOM 树描述样式,两者结合生成 RenderTree(渲染树)—— 只包含可见元素及其最终样式,是布局和绘制的基础。
1. RenderTree 与 DOM 树的区别
RenderTree 不是 DOM 树的简单复制,它有以下特点:
- 忽略不可见元素:如
display: none
的元素(visibility: hidden
会保留,因为它仍占据布局空间); - 合并伪元素:如
::before
、::after
会作为子节点
加入; - 只包含渲染相关信息:每个节点包含样式、几何信息(后续布局阶段计算),不含 DOM 属性(如
id
、class
)。
例如一个简单的 HTML:
<div style="display: none">隐藏</div>
<p>可见文本</p>
其 RenderTree 会包含p
节点(带计算后的样式),但不包含div
节点。
2. 构建过程
RenderTree 的构建步骤:
- 从 DOM 树的根节点开始遍历;
- 对每个节点,检查是否匹配 CSSOM 规则,计算最终样式;
- 若节点
display: none
,跳过该节点及其子节点; - 否则,创建 RenderNode,并加入 RenderTree;
- 递归处理子节点。
这个过程可以理解为 "给 DOM 树穿衣服",只有穿好衣服(计算完样式)的可见节点,才能进入下一步的布局阶段。
3. 关键 CSS 与阻塞渲染
CSS 是阻塞渲染的:浏览器必须等 CSSOM 构建完成,才能构建 RenderTree,进而进行布局和绘制。这就是为什么 "关键 CSS"(首屏必需的 CSS)应内联到<head>
,而非外部引入 —— 减少网络请求时间,加速首屏渲染。
可以通过media
属性标记非关键 CSS,使其不阻塞渲染:
<link rel="stylesheet" href="print.css" media="print"> <!-- 打印样式,不阻塞屏幕渲染 -->
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)"> <!-- 仅在匹配媒体查询时阻塞 -->
五、Layout
RenderTree 确定了 "谁可见" 和 "长什么样",Layout(布局,也称 Reflow / 重排)则要计算 "每个元素占多大地方,放在哪里"—— 即几何信息(位置、尺寸)。
1. 盒模型与坐标系统
布局的基础是CSS 盒模型:每个元素的总宽度 = content width + padding + border + margin。浏览器需要根据盒模型计算每个元素的 "占用空间"。
坐标系统以视口左上角为原点,x 轴向右为正,y 轴向下为正。元素的位置通常用top
、left
(相对于包含块)或transform
(相对于自身)描述。
2. 包含块
元素的布局不是孤立的,它的尺寸和位置受包含块影响。包含块通常是:
- 根元素的包含块是视口(viewport);
- 非根元素的包含块是其最近的定位祖先(
position: relative/absolute/fixed
)的内容区; - 若没有定位祖先,则是父元素的内容区。
例如:
.parent {
position: relative;
width: 300px;
}
.child {
position: absolute;
left: 50px; /* 相对于.parent的左边缘偏移50px */
width: 50%; /* 50% of .parent的width(300px → 150px) */
}
child
的包含块是parent
,因此其left
和width
都是相对于parent
计算的。
3. 布局计算
布局阶段要解决的核心问题是:将相对单位(%
、em
、vw
等)转化为绝对像素值,并确定每个元素的位置(x, y
)和尺寸(width, height
)。
以宽度计算为例,浏览器会:
- 处理
width
、min-width
、max-width
(优先级:min-width
>max-width
>width
); - 计算内边距(
padding
)、边框(border
)、外边距(margin
); - 对于块级元素,若未设置
width
,则默认占满包含块宽度(width: auto
); - 对于行内元素,宽度由内容决定(
shrink-to-fit
)。
复杂场景如浮动元素、Flex 布局,需要额外的算法:
- 浮动元素:脱离文档流,但会影响周围元素(文字环绕);
- Flex 布局:根据
flex-grow
、flex-shrink
、flex-basis
分配空间。
布局计算是流式的:父元素的尺寸会影响子元素,子元素的尺寸也可能反作用于父元素(如内容撑开父元素),这也是布局耗时的原因之一。
六、分层与合成
现代浏览器为优化渲染性能,会将 RenderTree 拆分为多个图层(Layer),利用 GPU 进行合成,这是复杂动画流畅运行的关键。
1. 为什么需要分层?
没有分层的情况下,任何元素的变化(如移动一个按钮)都可能导致整个页面重排重绘,效率极低。分层后:
- 每个图层独立渲染,某一图层变化不影响其他图层;
- GPU 擅长图层合成(并行处理像素),比 CPU 快得多;
- 图层可进行 3D 变换、透明度动画等,由 GPU 加速。
例如固定定位的导航栏、视频播放器、动画元素,通常会被分配到独立图层。
2. 图层创建的条件
浏览器自动创建图层的常见情况:
- 根元素(
<html>
); position: fixed/absolute
且z-index
不为auto
;- 有
opacity < 1
的元素; - 有
transform
且值不为none
的元素; - 视频元素(
<video>
); - Canvas 或 WebGL 绘制的元素;
- 带有
z-index
且覆盖在其他图层之上的元素。
我们也可以通过 CSS 强制创建图层(需谨慎使用,过多图层会占用更多内存):
.accelerate {
will-change: transform; /* 告诉浏览器该元素可能会变换,提前准备图层 */
transform: translateZ(0); /* 触发3D变换,强制创建图层 */
}
will-change
是更推荐的方式,它会让浏览器提前优化,但过度使用(例如给所有元素添加)会适得其反,导致内存占用过高。
3. 图层的生命周期
一个完整的图层生命周期包括:
- 创建:满足分层条件时,浏览器为元素创建新图层;
- 布局:每个图层独立进行布局计算(若图层内元素尺寸变化);
- 绘制:生成图层的位图(像素数据);
- 合成:将所有图层按
z-index
顺序合并,输出到屏幕。
我们可以在 Chrome 开发者工具的Layers
面板(More Tools → Layers)查看页面的图层分布,包括每个图层的大小、内存占用、绘制次数等信息。
4. 图层爆炸问题
虽然分层能提升性能,但图层数量过多(称为 "图层爆炸")会导致:
- 内存占用剧增:每个图层的位图需要占用内存(如一个 1920×1080 的图层约占用 8MB 内存);
- 合成开销增大:GPU 合成大量图层时,可能出现 "合成线程瓶颈",导致动画卡顿。
例如列表中的每个项都设置transform: translateZ(0)
,会创建大量图层,反而降低性能。解决方法是:
- 避免给过多元素创建独立图层;
- 及时清理不再需要的图层(如动画结束后移除
will-change
); - 对于长列表,使用虚拟滚动(只渲染可视区域的元素)。
七、绘制
绘制是将图层的矢量描述
(如 "画一个红色矩形,位置 (10,20),大小 100×50")转化为位图
(像素数据)的过程,由渲染引擎的绘制模块完成。
1. 绘制的本质
绘制的核心是根据元素的样式和几何信息,在图层的位图上填充像素
。例如一个background: red; width: 100px; height: 50px
的元素,绘制过程就是在对应位置的 100×50 个像素点上填充红色(RGB 值#ff0000
)。
复杂绘制包括:
- 渐变(线性渐变、径向渐变);
- 阴影(
box-shadow
、text-shadow
); - 边框样式(
dashed
、dotted
、圆角border-radius
); - 文本(涉及字体渲染、抗锯齿)。
这些操作都需要大量计算,例如阴影需要模糊算法,文本需要解析字体文件并计算每个字符的轮廓。
2. 绘制指令与绘制顺序
浏览器不会直接操作像素,而是生成绘制指令集(如 "绘制背景→绘制边框→绘制文本"),再由绘制引擎执行指令生成位图。
绘制顺序遵循 "painters algorithm
"(画家算法):先绘制底层元素,再绘制上层元素,确保上层元素覆盖下层元素。例如一个带背景的按钮:
- 绘制按钮背景(矩形填充);
- 绘制按钮边框(线条);
- 绘制按钮文本(字符轮廓填充)。
错误的绘制顺序会导致视觉错误(如文本被背景覆盖),因此浏览器会严格按 CSS 的z-index
和元素层级确定绘制顺序。
3. 绘制区域与重绘优化
绘制是按区域进行的:当元素部分变化时(如文本修改),只需重绘变化的区域(脏区域),而非整个图层。例如修改段落中的某个词,浏览器会计算该词的包围盒(bounding box),只重绘这个小区域。
优化绘制性能的关键是减少绘制区域和复杂度:
- 避免使用昂贵的绘制属性(如
box-shadow: 0 0 20px rgba(0,0,0,0.5)
会增加模糊计算); - 将频繁变化的元素(如动画按钮)放入独立图层,避免影响其他区域;
- 使用
transform
和opacity
实现动画(仅触发合成,不触发绘制)。
八、合成
合成是渲染流程的最后一步,由浏览器的合成线程(与渲染主线程分离)负责,将所有图层按正确顺序合并为最终的屏幕图像。
1. 合成的工作流程
- 分块 :合成线程将每个图层划分为 256×256 或 512×512 的小块(tiles),便于并行处理;
- 光栅化 :将每个块的矢量指令转化为位图(像素数据)。这一步由 GPU 的光栅化线程池完成,速度极快;
- 指引生成 :为每个块生成指引信息,包括在屏幕上的位置、变换(旋转、缩放等);
- 图层合成:GPU 根据指引信息,将所有块按
z-index
顺序合成,输出到帧缓冲区(frame buffer); - 屏幕刷新:显示器从帧缓冲区读取数据,刷新屏幕(通常 60 次 / 秒,即每 16.6ms 一次)。
这个过程中,GPU 的并行计算能力被充分利用,尤其是光栅化和合成步骤,效率远高于 CPU。
2. 为什么transform
动画更流畅?
transform
和opacity
是特殊的属性:它们的变化只影响合成阶段,不触发布局或绘制。例如:
/* 只触发合成,性能最优 */
.animated {
transition: transform 0.3s;
}
.animated:hover {
transform: translateX(100px);
}
而修改left
属性会触发布局→绘制→合成,流程更长:
/* 触发布局+绘制+合成,性能较差 */
.animated {
transition: left 0.3s;
position: relative;
}
.animated:hover {
left: 100px;
}
这就是为什么推荐用transform
实现动画的核心原因 —— 跳过昂贵的布局和绘制步骤。
3. 合成线程与主线程的协作
合成线程与渲染主线程是并行
工作的,但需要协作:
- 主线程完成布局和绘制后,将图层信息提交给合成线程;
- 合成线程完成合成后,通知主线程(通过
requestAnimationFrame
回调); - 若主线程任务过重(如长时间 JS 计算),会阻塞图层信息提交,导致合成延迟(卡顿)。
这也是为什么要避免长任务(执行时间 > 50ms 的 JS)的原因 —— 会导致动画掉帧。
九、总结
从 URL 到像素,网页渲染经历了网络请求→HTML 解析→CSS 解析→RenderTree 构建→布局→绘制→合成七个核心阶段,每个阶段都由浏览器内核的不同模块协同完成。这个过程的本质是:将结构化的文本(HTML/CSS)转化为可视化的像素,同时通过分层、GPU 加速等机制优化性能。
浏览器的渲染机制是为 "快速呈现内容" 设计的,我们的代码应顺应这一机制,而非对抗它。当你不确定某段代码的性能影响时,打开 Chrome 开发者工具的 Performance 面板,录制并分析渲染过程 —— 数据会告诉你答案。
网页渲染的世界还有很多细节值得探索,例如字体渲染的抗锯齿算法、GPU 与 CPU 的内存交互、不同浏览器内核的差异等。但掌握本文的核心流程和优化原则,已足够应对多数的前端性能问题。希望这篇文章能帮助你从 "知其然" 走向 "知其所以然",在前端开发的道路上走得更扎实。