从 URL 请求到屏幕成像,网页渲染全过程拆解

0 阅读18分钟

当我们在浏览器地址栏敲下回车,短短几百毫秒内,屏幕上就魔术般地呈现出丰富的网页内容。这个看似简单的过程,背后藏着浏览器内核几十年来不断优化的复杂机制。作为前端开发者,只有深入理解从 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" 的翻译过程,类似查通讯录找电话号码:

  1. 浏览器先查本地 DNS 缓存(Chrome 可通过chrome://net-internals/#dns查看),如果最近访问过该域名,直接返回 IP;
  2. 缓存未命中则查操作系统 DNS 缓存(如 Windows 的ipconfig /displaydns);
  3. 仍未找到则向本地 DNS 服务器(通常是运营商提供的,如 114.114.114.114)请求;
  4. 本地 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>)。若响应头未指定编码,浏览器会:

  1. 检查 HTML 中的<meta charset="utf-8">
  2. 尝试自动检测编码(通过字节序列特征推测)。

编码错误会导致 "乱码",例如将 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]
};

image.png

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 来源包括:

  1. 外部样式表(<link rel="stylesheet" href="style.css">);
  2. 内部样式表(<style>标签内的 CSS);
  3. 内联样式(style属性,如<div style="color: red">);
  4. 用户代理样式(浏览器默认样式,如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 解析也分为词法分析和语法分析:

  1. 词法分析:将 CSS 文本拆分为 Token(如selector { property: value; }中的选择器、属性、值);
  2. 语法分析:将 Token 转化为 CSS 规则CSSRule),每个规则包含选择器和声明块

例如:

.box {
  width: 100px;
  color: red;
}

会被解析为:

CSSRule = {
  selectorText: ".box",
  style: CSSStyleDeclaration {
    width: "100px",
    color: "red"
  }
}

CSS 解析器是上下文无关文法解析器,比 HTML 解析器更严格,错误的 CSS(如width: 100px,多了逗号)会被直接忽略。

image.png

3. CSSOM 树的构建

CSSOM 树与 DOM 树结构类似,但每个节点存储的是该节点的所有 CSS 规则。例如body节点的 CSSOM 节点会包含所有匹配body的规则(如body { margin: 0 }.container body { ... }等)。

构建 CSSOM 树时,浏览器会:

  1. 收集所有 CSS 规则;
  2. 对每个 DOM 节点,找出所有匹配的规则;
  3. 按优先级排序规则,计算最终生效的样式(样式计算)。

样式计算是耗时操作,因为选择器匹配需要遍历 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 属性(如idclass)。

例如一个简单的 HTML:

<div style="display: none">隐藏</div>
<p>可见文本</p>

其 RenderTree 会包含p节点(带计算后的样式),但不包含div节点。

2. 构建过程

RenderTree 的构建步骤:

  1. 从 DOM 树的根节点开始遍历;
  2. 对每个节点,检查是否匹配 CSSOM 规则,计算最终样式;
  3. 若节点display: none,跳过该节点及其子节点;
  4. 否则,创建 RenderNode,并加入 RenderTree;
  5. 递归处理子节点。

这个过程可以理解为 "给 DOM 树穿衣服",只有穿好衣服(计算完样式)的可见节点,才能进入下一步的布局阶段。

image.png

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 轴向下为正。元素的位置通常用topleft(相对于包含块)或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,因此其leftwidth都是相对于parent计算的。

3. 布局计算

布局阶段要解决的核心问题是:将相对单位(%emvw等)转化为绝对像素值,并确定每个元素的位置(x, y)和尺寸(width, height)。

以宽度计算为例,浏览器会:

  1. 处理widthmin-widthmax-width(优先级:min-width > max-width > width);
  2. 计算内边距(padding)、边框(border)、外边距(margin);
  3. 对于块级元素,若未设置width,则默认占满包含块宽度(width: auto);
  4. 对于行内元素,宽度由内容决定(shrink-to-fit)。

复杂场景如浮动元素、Flex 布局,需要额外的算法:

  • 浮动元素:脱离文档流,但会影响周围元素(文字环绕);
  • Flex 布局:根据flex-growflex-shrinkflex-basis分配空间。

布局计算是流式的:父元素的尺寸会影响子元素,子元素的尺寸也可能反作用于父元素(如内容撑开父元素),这也是布局耗时的原因之一。

六、分层与合成

现代浏览器为优化渲染性能,会将 RenderTree 拆分为多个图层(Layer),利用 GPU 进行合成,这是复杂动画流畅运行的关键。

1. 为什么需要分层?

没有分层的情况下,任何元素的变化(如移动一个按钮)都可能导致整个页面重排重绘,效率极低。分层后:

  • 每个图层独立渲染,某一图层变化不影响其他图层;
  • GPU 擅长图层合成(并行处理像素),比 CPU 快得多;
  • 图层可进行 3D 变换、透明度动画等,由 GPU 加速。

例如固定定位的导航栏、视频播放器、动画元素,通常会被分配到独立图层。

2. 图层创建的条件

浏览器自动创建图层的常见情况:

  • 根元素(<html>);
  • position: fixed/absolutez-index不为auto
  • opacity < 1的元素;
  • transform且值不为none的元素;
  • 视频元素(<video>);
  • Canvas 或 WebGL 绘制的元素;
  • 带有z-index且覆盖在其他图层之上的元素。

我们也可以通过 CSS 强制创建图层(需谨慎使用,过多图层会占用更多内存):

.accelerate {
  will-change: transform; /* 告诉浏览器该元素可能会变换,提前准备图层 */
  transform: translateZ(0); /* 触发3D变换,强制创建图层 */
}

will-change是更推荐的方式,它会让浏览器提前优化,但过度使用(例如给所有元素添加)会适得其反,导致内存占用过高。

3. 图层的生命周期

一个完整的图层生命周期包括:

  1. 创建:满足分层条件时,浏览器为元素创建新图层;
  2. 布局:每个图层独立进行布局计算(若图层内元素尺寸变化);
  3. 绘制:生成图层的位图(像素数据);
  4. 合成:将所有图层按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-shadowtext-shadow);
  • 边框样式(dasheddotted、圆角border-radius);
  • 文本(涉及字体渲染、抗锯齿)。

这些操作都需要大量计算,例如阴影需要模糊算法,文本需要解析字体文件并计算每个字符的轮廓。

2. 绘制指令与绘制顺序

浏览器不会直接操作像素,而是生成绘制指令集(如 "绘制背景→绘制边框→绘制文本"),再由绘制引擎执行指令生成位图。

绘制顺序遵循 "painters algorithm"(画家算法):先绘制底层元素,再绘制上层元素,确保上层元素覆盖下层元素。例如一个带背景的按钮:

  1. 绘制按钮背景(矩形填充);
  2. 绘制按钮边框(线条);
  3. 绘制按钮文本(字符轮廓填充)。

错误的绘制顺序会导致视觉错误(如文本被背景覆盖),因此浏览器会严格按 CSS 的z-index和元素层级确定绘制顺序。

3. 绘制区域与重绘优化

绘制是按区域进行的:当元素部分变化时(如文本修改),只需重绘变化的区域(脏区域),而非整个图层。例如修改段落中的某个词,浏览器会计算该词的包围盒(bounding box),只重绘这个小区域。

优化绘制性能的关键是减少绘制区域和复杂度

  • 避免使用昂贵的绘制属性(如box-shadow: 0 0 20px rgba(0,0,0,0.5)会增加模糊计算);
  • 将频繁变化的元素(如动画按钮)放入独立图层,避免影响其他区域;
  • 使用transformopacity实现动画(仅触发合成,不触发绘制)。

八、合成

合成是渲染流程的最后一步,由浏览器的合成线程(与渲染主线程分离)负责,将所有图层按正确顺序合并为最终的屏幕图像。

1. 合成的工作流程

  1. 分块 :合成线程将每个图层划分为 256×256 或 512×512 的小块(tiles),便于并行处理;
  2. 光栅化 :将每个块的矢量指令转化为位图(像素数据)。这一步由 GPU 的光栅化线程池完成,速度极快;
  3. 指引生成 :为每个块生成指引信息,包括在屏幕上的位置、变换(旋转、缩放等);
  4. 图层合成:GPU 根据指引信息,将所有块按z-index顺序合成,输出到帧缓冲区(frame buffer);
  5. 屏幕刷新:显示器从帧缓冲区读取数据,刷新屏幕(通常 60 次 / 秒,即每 16.6ms 一次)。

这个过程中,GPU 的并行计算能力被充分利用,尤其是光栅化和合成步骤,效率远高于 CPU。

2. 为什么transform动画更流畅?

transformopacity是特殊的属性:它们的变化只影响合成阶段,不触发布局或绘制。例如:

/* 只触发合成,性能最优 */
.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 的内存交互、不同浏览器内核的差异等。但掌握本文的核心流程和优化原则,已足够应对多数的前端性能问题。希望这篇文章能帮助你从 "知其然" 走向 "知其所以然",在前端开发的道路上走得更扎实。