技术原理2026-03-17 16 分钟

浏览器渲染原理:从输入 URL 到页面展示,中间经历了什么

这道面试题的标准答案背后,藏着整个前端性能优化的知识体系。

「从输入 URL 到页面展示,中间经历了什么?」这道题我面试的时候答过不下 10 次,每次都背一遍 DNS 解析、TCP 握手、HTTP 请求那一套。直到后来做性能优化时发现,我只能背出流程,却不知道每一步对性能的具体影响——那一刻我才意识到,我从来没真正理解过这个过程。

这篇文章不是面试八股文。我会用「性能优化」这条线把整个渲染流程串起来——每一步不只告诉你「发生了什么」,还告诉你「你能优化什么」。

完整流程一览:

输入 URL
  ↓
1. DNS 解析 → 域名 → IP 地址
  ↓
2. TCP 连接 → 三次握手(HTTPS 再加 TLS 握手)
  ↓
3. HTTP 请求/响应 → 拿到 HTML 文档
  ↓
4. HTML 解析 → 构建 DOM 树
  ↓ (并行)
5. CSS 解析 → 构建 CSSOM 树
  ↓
6. DOM + CSSOM → 生成 Render Tree(渲染树)
  ↓
7. Layout(布局)→ 计算每个节点的几何信息
  ↓
8. Paint(绘制)→ 生成绘制指令
  ↓
9. Composite(合成)→ GPU 合成最终画面

1. 网络阶段:DNS + TCP + HTTP

DNS 解析

浏览器需要把域名(如 onekit.pages.dev)转换成 IP 地址。这个过程会依次查找:浏览器 DNS 缓存 → 操作系统 DNS 缓存 → 路由器缓存 → ISP DNS 服务器 → 递归查询根域名服务器。

优化手段

dns-prefetch<link rel="dns-prefetch" href="//cdn.example.com"> 提前解析第三方域名。
preconnect<link rel="preconnect" href="//cdn.example.com"> 不仅解析 DNS,还提前建立 TCP + TLS 连接。
减少域名数量:每个不同域名都需要一次完整的 DNS 解析,域名越少,开销越小。

TCP 连接与 HTTPS

TCP 三次握手本身需要 1 个 RTT(Round Trip Time)。如果是 HTTPS,还需要额外的 TLS 握手(TLS 1.2 需要 2 个 RTT,TLS 1.3 优化到了 1 个 RTT)。

HTTP/1.1 请求一个页面的网络开销:
DNS 解析        ~50ms(无缓存时)
TCP 握手        1 RTT ~30ms
TLS 握手        1-2 RTT ~30-60ms
HTTP 请求/响应   1 RTT + 传输时间
─────────────────────────
总计约 140-170ms(仅拿到 HTML 文档)

HTTP/2 的改进:
- 多路复用:一个 TCP 连接上可以并行发送多个请求
- 头部压缩:HPACK 算法压缩重复的 HTTP 头
- 服务器推送:服务器主动推送 CSS/JS 文件

HTTP/3 (QUIC) 的改进:
- 基于 UDP,消除了 TCP 队头阻塞
- 0-RTT 连接恢复(重复访问时跳过握手)

2. HTML 解析:构建 DOM 树

浏览器接收到 HTML 字节流后,会经过这样的转换链路:

字节 (Bytes)
  ↓ 解码 (UTF-8)
字符 (Characters)
  ↓ 词法分析 (Tokenizer)
令牌 (Tokens):<html>, <head>, <body>, <div class="app"> ...
  ↓ 语法分析 (Tree Builder)
DOM 树 (Document Object Model)

关键:解析阻塞

HTML 解析器在遇到 <script> 标签时会暂停解析,等待脚本下载和执行完毕。这就是为什么「把 script 放在 body 底部」是一条经典的优化建议。

// ❌ 阻塞渲染:浏览器停下来等 JS 下载 + 执行
<head>
  <script src="huge-bundle.js"></script>
</head>

// ✅ defer:并行下载,DOM 解析完成后按顺序执行
<script defer src="app.js"></script>
<script defer src="vendor.js"></script>
// 执行顺序:app.js → vendor.js(保持文档顺序)

// ✅ async:并行下载,下载完立即执行(顺序不保证)
<script async src="analytics.js"></script>
// 适合独立脚本(统计、广告等)

深入:预解析扫描器(Preload Scanner)

现代浏览器有一个「预解析扫描器」——当主解析器被 JS 阻塞时,预解析扫描器会继续扫描后续的 HTML,提前发现需要下载的 CSS、JS、图片等资源,并行发起请求。所以即使 script 阻塞了 DOM 解析,后面的资源仍然在下载。这就是为什么 Chrome DevTools 的 Network 面板里,很多资源是并行下载的。

3. CSS 解析:CSSOM 与渲染阻塞

CSS 的解析和 DOM 解析是并行的,但 CSS 有一个特殊性质:CSS 是渲染阻塞资源。浏览器在 CSSOM 构建完成之前,不会渲染任何内容。

为什么 CSS 要阻塞渲染?

如果不阻塞,会出现 FOUC(Flash of Unstyled Content):
1. HTML 解析完成,先用默认样式渲染一次(难看的无样式页面闪一下)
2. CSS 加载完成后,重新计算样式,再渲染一次

所以浏览器选择等 CSS 加载完再渲染——宁可白屏 100ms,也不闪一下丑的。

优化手段

Critical CSS:把首屏需要的 CSS 内联到 HTML 的 <style> 标签中,剩余 CSS 异步加载。这样浏览器不需要等额外的 CSS 文件下载就能开始渲染。

媒体查询分离<link rel="stylesheet" href="print.css" media="print">。不符合当前媒体条件的 CSS 不会阻塞渲染。

减小 CSS 体积:用 PurgeCSS / UnoCSS 移除未使用的 CSS 规则。一个典型的 Tailwind 项目,构建后 CSS 从几 MB 压缩到几十 KB。

4. 渲染树:DOM + CSSOM 合体

DOM 和 CSSOM 都准备好后,浏览器会将它们合并成一棵「渲染树」。渲染树只包含可见的节点——display: none 的元素不会出现在渲染树中,<head> 标签也不会。

DOM 树                    CSSOM 树
  html                     body { font: 16px }
  ├─ head                  .hidden { display: none }
  │   └─ title             p { color: #333 }
  └─ body                  span { color: blue }
      ├─ p
      │   └─ "Hello"
      ├─ div.hidden
      │   └─ "Secret"
      └─ span
          └─ "World"

           ↓ 合并

渲染树(只包含可见节点)
  body     { font: 16px }
  ├─ p     { color: #333 }
  │   └─ "Hello"
  └─ span  { color: blue }
      └─ "World"

// div.hidden 被跳过(display: none)
// head 被跳过(不可见)

5. 布局、绘制与合成:最终呈现

Layout(布局 / Reflow)

布局阶段计算每个渲染树节点的精确位置和大小。这是一个递归过程——父节点的大小会影响子节点,某些情况下子节点也会反过来影响父节点(比如 height: auto)。

Paint(绘制)

布局完成后,浏览器知道了每个节点的位置和大小,接下来要把它们「画」出来。绘制阶段会生成一系列绘制指令(Draw Commands),比如「在 (10, 20) 位置画一个 200x50 的矩形,填充颜色 #fff」。

Composite(合成)

现代浏览器会把页面分成多个「图层」(Layers),每个图层独立绘制,最后由 GPU 合成最终画面。某些 CSS 属性(transformopacitywill-change)会创建独立的合成层。

触发不同阶段的 CSS 属性:

重排(Layout)← 最贵,触发后面所有阶段
  width, height, margin, padding, top, left, display, font-size ...

重绘(Paint)← 跳过 Layout,但仍然贵
  color, background, border-color, box-shadow, visibility ...

仅合成(Composite)← 最便宜,只在 GPU 上操作
  transform, opacity

动画性能黄金法则

做动画时只用 transformopacity。这两个属性可以完全在 GPU 合成层上处理,不触发布局和绘制,帧率稳定 60fps。

反面教材:用 left/top 做位移动画 → 每一帧都触发 Layout + Paint + Composite。
正确做法:用 transform: translateX() → 每一帧只触发 Composite。性能差距可以是 10 倍以上。

6. 核心性能指标:你该关注什么

理解了渲染流程,就能理解 Web Vitals 这些性能指标的含义了:

FCP (First Contentful Paint)
  第一个文本或图片渲染到屏幕的时间
  ← 优化:减少 CSS 阻塞、内联 Critical CSS

LCP (Largest Contentful Paint)
  最大内容元素(通常是 hero 图片或标题)渲染完成的时间
  ← 优化:预加载 hero 图片、使用 CDN、图片格式优化

CLS (Cumulative Layout Shift)
  页面加载过程中元素意外移动的累积量
  ← 优化:给图片/视频设置固定宽高、字体加载策略

INP (Interaction to Next Paint)
  用户交互到视觉反馈的延迟
  ← 优化:减少 JS 主线程阻塞、使用 requestIdleCallback

深入:为什么 JavaScript 会阻塞渲染

浏览器的渲染和 JavaScript 执行共享同一个主线程。当 JS 在执行时,渲染线程是暂停的。这就是为什么一个执行 200ms 的 JS 任务会让页面「卡顿」——在这 200ms 内,浏览器无法响应用户输入,也无法更新画面。

解决方案:把长任务拆分成小块,用 requestAnimationFramescheduler.yield() 把控制权交还给浏览器,让它有机会处理渲染和用户输入。

全链路优化清单

网络层   → dns-prefetch / preconnect / HTTP2 / CDN / 缓存策略
解析层   → defer/async / CSS 精简 / Critical CSS 内联
渲染层   → 避免强制同步布局 / 减少 DOM 操作 / 虚拟滚动
动画层   → 只用 transform + opacity / will-change / GPU 加速
资源层   → 图片压缩 / 字体子集化 / Tree Shaking / Code Splitting

性能优化的本质就是在渲染流水线的每一步「减少工作量」。理解了这条流水线,优化就不再是玄学。如果你对具体的 Vue3 性能优化感兴趣,推荐看看 Vue3 性能优化技巧; 如果想了解 Vite 如何优化构建产物,看看 Vite 工作原理