浏览器渲染原理:从输入 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 属性(transform、opacity、will-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
动画性能黄金法则
做动画时只用 transform 和 opacity。这两个属性可以完全在 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 内,浏览器无法响应用户输入,也无法更新画面。
解决方案:把长任务拆分成小块,用 requestAnimationFrame 或 scheduler.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 工作原理。