导航流程 — 从按下回车到看见页面,中间到底发生了什么
用户在地址栏输入 URL 按下回车,到页面内容完整呈现在屏幕上,中间经历了数十个步骤、跨越了多个进程。这条链路是浏览器工作原理的”主干道”,理解它,后面所有章节的知识都能串起来。
📋 开篇自测:你已经知道多少?
- 从输入 URL 到页面显示,你能说出至少 5 个关键步骤吗?
- DNS 解析是在哪个进程中完成的?为什么需要 DNS 缓存?
- 什么是”提交导航”?浏览器进程和渲染进程是如何交接数据的?
一、导航流程全景图
在深入每个步骤之前,先建立一个完整的全景视图。整个导航流程可以分为七个阶段:
导航流程七大阶段
用户输入 → URL解析 → DNS解析 → TCP连接 → HTTP请求/响应
│
页面渲染完成 ← 提交导航 ← 响应处理
│
┌───▼───┐
│ 完成! │
└───────┘
详细流程(跨进程协作):
浏览器主进程 网络进程 渲染进程
┌──────────┐ ┌──────────┐ ┌──────────┐
│1.用户输入 │ │ │ │ │
│2.URL解析 │──────│→3.DNS解析│ │ │
│ │ │ 4.TCP连接│ │ │
│ │ │ 5.HTTP │ │ │
│ │◄─────│──6.响应 │ │ │
│7.提交导航 │──────│──────────│──────│→8.解析 │
│ │ │ │ │ 9.渲染 │
│ │◄─────│──────────│──────│──10.完成 │
└──────────┘ └──────────┘ └──────────┘
接下来逐一拆解。
二、第一阶段:用户输入与 URL 解析
2.1 地址栏的智能判断
当你在 Chrome 地址栏输入内容时,浏览器主进程中的 UI 线程首先要判断:这是一个 URL,还是一个搜索词?
判断规则大致如下:
- 包含
.且符合域名格式 → 作为 URL 处理 - 以
http://或https://开头 → 明确是 URL - 其他情况 → 当作搜索关键词,拼接到默认搜索引擎的 URL 模板中
例如输入 github.com,浏览器会自动补全为 https://github.com;输入 浏览器原理,则会被转换为 https://www.google.com/search?q=浏览器原理。
2.2 beforeunload 事件
如果当前页面有注册 beforeunload 事件处理函数,浏览器会在开始导航前先触发它。这是页面的最后一次机会来提醒用户”你有未保存的修改,确定要离开吗?”。
// 页面中的代码
window.addEventListener('beforeunload', (event) => {
event.preventDefault();
event.returnValue = ''; // Chrome需要这行才会显示提示
});
只有当用户确认离开(或页面没有注册该事件),导航才会继续。
2.3 URL 的组成结构
一个完整的 URL 包含多个部分,每个部分在导航流程中都有特定的用途:
URL 结构分解
https://www.example.com:443/path/page.html?key=value#section
│ │ │ │ │ │
│ │ │ │ │ └── Fragment(片段)
│ │ │ │ │ 不发送到服务器
│ │ │ │ └── Query(查询参数)
│ │ │ └── Path(路径)
│ │ └── Port(端口, HTTPS默认443)
│ └── Host(主机名, 需要DNS解析)
└── Scheme(协议)
三、第二阶段:DNS 解析 — 把域名翻译成 IP 地址
3.1 为什么需要 DNS
计算机之间的通信基于 IP 地址,但人类记不住一长串数字。DNS(Domain Name System)就是互联网的”电话簿”,负责把人类友好的域名(如 github.com)翻译成机器友好的 IP 地址(如 140.82.121.4)。
3.2 DNS 解析的完整链路
DNS 解析并不是一步到位的,它有一套精密的多级缓存和递归查询机制:
DNS 解析查找顺序(逐级回退)
1. 浏览器 DNS 缓存
│ 命中? ──→ 直接返回 IP
│ 未命中 ↓
2. 操作系统 DNS 缓存 (如 /etc/hosts)
│ 命中? ──→ 直接返回 IP
│ 未命中 ↓
3. 路由器 DNS 缓存
│ 命中? ──→ 直接返回 IP
│ 未命中 ↓
4. ISP DNS 服务器 (如运营商的DNS)
│ 命中? ──→ 直接返回 IP
│ 未命中 ↓
5. 递归查询:
根DNS服务器 → 顶级域服务器(.com) → 权威DNS服务器(example.com)
│
└──→ 返回最终 IP 地址,并沿途缓存
每一级缓存都有 TTL(Time To Live),即缓存的有效期。TTL 到期后,下次查询会重新走递归流程。
3.3 DNS 对性能的影响
DNS 解析通常耗时 20~120 毫秒。对于首次访问一个新域名,这个时间是无法避免的。但浏览器提供了优化手段:
DNS 预解析(dns-prefetch):告诉浏览器”我稍后可能会访问这个域名,请提前解析”。
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="dns-prefetch" href="//api.example.com">
DNS over HTTPS(DoH):传统 DNS 查询是明文的,任何中间人都能看到你访问了什么网站。DoH 通过 HTTPS 加密 DNS 查询,提升了隐私性,但可能增加少量延迟。
🤔 想一想 如果一个页面引用了 10 个不同域名的资源(CDN、API、字体、统计等),DNS 解析会对首次加载产生多大的影响?你会如何优化?
四、第三阶段:TCP 连接 — 建立可靠的传输通道
4.1 三次握手
DNS 解析拿到了服务器的 IP 地址后,浏览器需要与服务器建立 TCP 连接。TCP 是面向连接的可靠传输协议,建立连接需要经过”三次握手”:
TCP 三次握手
客户端(浏览器) 服务器
│ │
│──── SYN (seq=x) ──────────→│ 第1次: 客户端发起连接请求
│ │
│←── SYN+ACK (seq=y,ack=x+1)─│ 第2次: 服务器确认并发起反向连接
│ │
│──── ACK (ack=y+1) ────────→│ 第3次: 客户端确认反向连接
│ │
│ 连接建立,可以传输数据 │
│ │
耗时: 1 RTT(第三次握手的 ACK 可以携带数据,因此严格来说是 1 RTT;部分资料计为 1.5 RTT,取决于是否将最后的 ACK 计入)
为什么是三次而不是两次?因为两次无法确认双方的收发能力都正常。第三次握手确保了服务器知道客户端已经收到了自己的确认。
4.2 TLS 握手(HTTPS)
如果使用 HTTPS(现代网站的标配),在 TCP 连接建立之后,还需要进行 TLS 握手来协商加密方式和交换密钥:
TLS 1.3 握手(简化)
客户端 服务器
│ │
│── ClientHello + 密钥共享参数 ───→│ 客户端发送支持的加密套件
│ │
│←─ ServerHello + 密钥共享 + 证书 ─│ 服务器选择加密套件,发送证书
│ │
│ 客户端验证证书,计算密钥 │
│── Finished ─────────────────────→│ 加密通道建立
│ │
TLS 1.3: 1 个 RTT (比TLS 1.2的2个RTT快了一轮)
4.3 连接复用与 HTTP/2
TCP + TLS 握手是昂贵的。对于 HTTP/1.1,浏览器通常会对同一域名建立 6 个并行连接来提高并发性。HTTP/2 的一大优势是多路复用(Multiplexing):在同一个 TCP 连接上同时传输多个请求和响应,彻底消除了连接数的限制。
HTTP/1.1 vs HTTP/2 连接模型
HTTP/1.1:
┌─────┐ 连接1 → 请求A → 响应A
│ │ 连接2 → 请求B → 响应B
│浏览器│ 连接3 → 请求C → 响应C (最多6个并行连接/域名)
│ │ 连接4 → 请求D → 响应D
└─────┘ 连接5 → 请求E → 响应E
连接6 → 请求F → 响应F
HTTP/2:
┌─────┐ ┌──────┐
│ │ 单一连接 ═══════════ │ │
│浏览器│ ├ 流1: 请求A/响应A │服务器│
│ │ ├ 流2: 请求B/响应B │ │
└─────┘ ├ 流3: 请求C/响应C │ │
├ 流4: 请求D/响应D └──────┘
└ ...(无并发限制)
五、第四阶段:HTTP 请求与响应
5.1 构造 HTTP 请求
TCP(或 TLS)连接建立后,网络进程开始构造 HTTP 请求报文:
HTTP 请求报文结构
┌─ 请求行 ──────────────────────────────┐
│ GET /index.html HTTP/1.1 │
├─ 请求头 ──────────────────────────────┤
│ Host: www.example.com │
│ User-Agent: Mozilla/5.0 ... │
│ Accept: text/html │
│ Accept-Encoding: gzip, br │
│ Accept-Language: zh-CN,zh;q=0.9 │
│ Cookie: session_id=abc123 │
│ Connection: keep-alive │
├─ 空行 ────────────────────────────────┤
│ │
├─ 请求体 (GET请求通常为空) ──────────────┤
│ │
└───────────────────────────────────────┘
5.2 服务器处理与响应
服务器收到请求后进行处理(路由匹配、数据库查询、模板渲染等),然后返回 HTTP 响应:
HTTP 响应报文结构
┌─ 状态行 ──────────────────────────────┐
│ HTTP/1.1 200 OK │
├─ 响应头 ──────────────────────────────┤
│ Content-Type: text/html; charset=utf-8│
│ Content-Length: 3256 │
│ Content-Encoding: gzip │
│ Cache-Control: max-age=3600 │
│ Set-Cookie: theme=dark │
├─ 空行 ────────────────────────────────┤
│ │
├─ 响应体 ──────────────────────────────┤
│ <!DOCTYPE html> │
│ <html>... │
└───────────────────────────────────────┘
5.3 关键状态码与重定向
网络进程收到响应后,首先检查状态码:
| 状态码 | 含义 | 浏览器行为 |
|---|---|---|
| 200 | 成功 | 继续处理响应体 |
| 301 | 永久重定向 | 读取 Location 头,重新发起请求 |
| 302 | 临时重定向 | 同上,但不缓存重定向 |
| 304 | 资源未修改 | 使用本地缓存 |
| 404 | 资源不存在 | 显示错误页面 |
| 500 | 服务器内部错误 | 显示错误页面 |
如果是 301 或 302,浏览器会从响应头中读取 Location 字段,然后从 DNS 解析阶段重新开始整个流程。这就是为什么过多的重定向会严重拖慢页面加载速度。
5.4 响应类型判断
网络进程还需要根据 Content-Type 头判断响应体的类型:
text/html→ 交给渲染进程处理application/octet-stream→ 触发下载application/pdf→ 使用 PDF 查看器打开(如果有的话)
只有当 Content-Type 指明是 HTML 文档时,流程才会进入下一阶段——提交导航。
六、第五阶段:提交导航 — 进程间的交接仪式
这是整个导航流程中最关键、也最容易被忽视的环节。它涉及浏览器主进程和渲染进程之间的一次正式”交接”。
6.1 提交导航的流程
提交导航详细流程
浏览器主进程 渲染进程
│ │
│ 1. "准备好了,我要给你发数据" │
│──── CommitNavigation ──────────→│
│ │
│ 2. 渲染进程确认收到
│←─── 确认消息 ──────────────────│
│ │
│ 3. 浏览器更新UI: │ 4. 渲染进程开始接收数据
│ - 地址栏URL更新 │ 并开始解析HTML
│ - 安全锁图标显示 │
│ - 标签页标题更新 │
│ - 前进/后退列表更新 │
│ │
│ 5. 渲染完成
│←─── DidFinishLoad ─────────────│
│ │
│ 6. 标签页loading图标停止旋转 │
│ │
6.2 为什么需要”提交”这个步骤
你可能会问:为什么不直接把数据扔给渲染进程?因为在导航流程中,浏览器主进程需要协调多件事情:
- 安全检查:在把响应数据交给渲染进程之前,浏览器要确认该站点没有被标记为恶意站点(Safe Browsing 检查)
- CORB(Cross-Origin Read Blocking)检查:确保跨域资源不会被错误地加载到渲染进程中
- 渲染进程选择:根据站点隔离策略,决定是复用已有的渲染进程还是创建新的
七、第六阶段:页面解析与渲染(概览)
提交导航完成后,渲染进程开始接收 HTML 数据流(注意:不需要等到全部数据到达才开始解析,而是边接收边解析)。
渲染的详细流程将在第 3 章和第 4 章深入讲解,这里给出一个简化的概览:
渲染流程概览
HTML字节流
│
▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│HTML解析 │───→│ DOM树 │ │ CSSOM树 │
│(主线程) │ │ │ │ │
└─────────┘ └────┬─────┘ └────┬─────┘
│ │
└───────┬───────┘
│ 合并
▼
┌──────────────┐
│ 渲染树 │
│ (Render Tree)│
└──────┬───────┘
│
▼
┌──────────────┐
│ 布局 │
│ (Layout) │
└──────┬───────┘
│
▼
┌──────────────┐
│ 绘制 │
│ (Paint) │
└──────┬───────┘
│
▼
┌──────────────┐
│ 合成显示 │
│ (Composite) │
└──────────────┘
7.1 关键资源对渲染的阻塞
在解析 HTML 的过程中,并不是所有资源都会被平等对待:
CSS 阻塞渲染:浏览器必须等到 CSSOM 构建完成才能进行渲染。如果 CSS 文件很大或加载很慢,用户会看到白屏。
JavaScript 阻塞解析:当 HTML 解析器遇到 <script> 标签时,它必须暂停解析,等待脚本下载和执行完毕后才能继续。这是因为 JavaScript 可能会通过 document.write() 修改 DOM 结构。
阻塞行为示意
HTML解析: ████████░░░░░░░░░████████████████
↑ ↑
│ └── 脚本执行完毕,解析恢复
└── 遇到<script>标签,解析暂停
解决方案:
<script defer src="app.js"> → 异步下载,HTML解析完后执行
<script async src="analytics.js"> → 异步下载,下载完立即执行
7.2 预加载扫描器(Preload Scanner)
浏览器的工程师们当然不会坐视 JavaScript 阻塞带来的性能损失。Chrome 有一个预加载扫描器,它在主线程的 HTML 解析器暂停时,会继续向前扫描 HTML,提前发现后续需要加载的资源(CSS、JavaScript、图片等),并启动下载。
预加载扫描器工作原理
主线程: [HTML解析] [暂停等JS] [恢复解析]
↑
预加载扫描器: [继续扫描HTML,发现后续资源]
│
网络进程: [提前下载CSS][提前下载图片]
这个优化对页面加载性能的提升是巨大的。这也是为什么你不应该用 JavaScript 动态生成 <link> 或 <script> 标签来加载关键资源——预加载扫描器无法发现它们。
八、第七阶段:完成加载
8.1 DOMContentLoaded vs load
当所有同步 JavaScript 执行完毕、DOM 树构建完成后,浏览器触发 DOMContentLoaded 事件。当页面所有资源(包括图片、样式表、iframe)都加载完毕后,触发 load 事件。
页面加载事件时序
HTML接收开始 DOM树构建完成 所有资源加载完毕
│ │ │
▼ ▼ ▼
─────┼───────────────────┼─────────────────────┼──→ 时间
│ │ │
│ HTML解析+JS执行 │ 图片/字体等加载 │
│ │ │
DOMContentLoaded load
│ │
适合执行DOM操作 适合统计性能指标
8.2 首次内容绘制(FCP)和最大内容绘制(LCP)
在用户体验层面,有两个关键时刻:
- FCP(First Contentful Paint):浏览器第一次在屏幕上绘制出任何内容(文字、图片、SVG 等)的时刻
- LCP(Largest Contentful Paint):页面中最大的内容元素完成渲染的时刻
这两个指标是 Google 的 Core Web Vitals 的核心组成部分,也是衡量用户”感知加载速度”的关键指标。
九、完整时序:一次导航的生命周期
把所有阶段串联起来,一次完整导航的时间轴如下:
完整导航时序(假设首次访问 HTTPS 站点)
时间 →
│
├─ 用户输入 + URL解析 ~1ms
│
├─ DNS解析 ~20-120ms
│ (如果命中缓存则 ~0ms)
│
├─ TCP三次握手 ~1 RTT (~30-100ms)
│
├─ TLS握手 ~1 RTT (TLS 1.3)
│
├─ HTTP请求发送 ~0.5 RTT
│
├─ 服务器处理 ~50-500ms (取决于后端)
│
├─ HTTP响应首字节 (TTFB) ~服务器处理时间 + 0.5 RTT
│
├─ 响应体下载 取决于文件大小和带宽
│
├─ 提交导航 ~几ms
│
├─ HTML解析 + DOM构建 + CSSOM构建 ~取决于文档复杂度
│
├─ JavaScript执行 ~取决于JS体积和复杂度
│
├─ 布局 + 绘制 + 合成 ~取决于页面元素数量
│
├─ FCP (First Contentful Paint) 理想: <1.8s
│
├─ LCP (Largest Contentful Paint) 理想: <2.5s
│
└─ load 事件 页面完全加载
🤔 想一想 在上面的时序中,哪些阶段是可以通过前端优化来缩短的?哪些阶段需要后端或运维配合?
十、导航的特殊场景
10.1 同站点导航(Same-site Navigation)
当用户从 a.example.com/page1 导航到 a.example.com/page2 时,浏览器可以复用已有的渲染进程和 TCP 连接,跳过很多步骤。这就是为什么同站点的页面跳转通常比跨站点快得多。
10.2 Service Worker 拦截
如果目标页面注册了 Service Worker,导航流程会多出一个步骤:网络进程在发起真正的网络请求之前,会先检查是否有 Service Worker 可以处理这个请求。如果有,Service Worker 可以直接从缓存中返回响应,完全不需要访问网络。
Service Worker 拦截流程
正常流程: URL → DNS → TCP → HTTP → 响应
SW拦截流程: URL → 检查SW → SW返回缓存 → 响应 (跳过网络!)
10.3 浏览器缓存命中
如果请求的资源被浏览器缓存命中(强缓存),网络进程甚至不需要发起网络请求:
- 强缓存(
Cache-Control: max-age=3600):直接从磁盘/内存缓存返回,状态码 200(from cache) - 协商缓存(
ETag/Last-Modified):发送条件请求到服务器,如果资源未变,服务器返回 304,浏览器使用本地缓存
十一、本章知识脉络总结
导航流程知识地图
从URL到页面
├── 用户输入阶段
│ ├── URL vs 搜索词判断
│ ├── beforeunload事件
│ └── URL结构解析
│
├── 网络阶段
│ ├── DNS解析 (多级缓存 + 递归查询)
│ ├── TCP三次握手
│ ├── TLS握手 (HTTPS)
│ ├── HTTP请求/响应
│ └── 状态码处理 + 重定向
│
├── 提交导航
│ ├── 安全检查 (Safe Browsing)
│ ├── 渲染进程选择
│ └── 进程间数据交接
│
├── 渲染阶段 (概览)
│ ├── HTML解析 → DOM树
│ ├── CSS解析 → CSSOM树
│ ├── JS执行 (阻塞解析)
│ ├── 预加载扫描器
│ └── 布局 → 绘制 → 合成
│
├── 加载完成
│ ├── DOMContentLoaded
│ ├── load
│ ├── FCP / LCP
│ └── 性能指标
│
└── 特殊场景
├── 同站点导航 (进程复用)
├── Service Worker 拦截
└── 浏览器缓存命中
📝 结尾自测:检验你的学习成果
- 完整描述从输入 URL 到页面显示的七个主要阶段。
- DNS 解析有哪些层级的缓存?一个完整的递归查询经过哪些服务器?
- TCP 三次握手的每一步各确认了什么?为什么不能只握手两次?
- 什么是”提交导航”?它在浏览器主进程和渲染进程之间起什么作用?
<script>标签的defer和async属性分别是如何影响 HTML 解析的?预加载扫描器又是如何弥补阻塞造成的性能损失的?
下一章预告:导航流程走到”提交导航”之后,数据就交给了渲染进程。下一章我们将深入渲染进程内部,详细拆解 HTML 解析器如何一个字节一个字节地构建 DOM 树,CSS 如何被解析和计算,以及布局树是如何生成的。
购买课程解锁全部内容
前端进阶第一课:11 章掌握浏览器核心
¥29.90