2014 年,百度 FEX 团队写过一篇流传很广的长文:从输入 URL 到页面加载完成的过程中都发生了什么事情?。它从手指按下屏幕讲起,一路讲到液晶屏上的像素亮起,覆盖了硬件、操作系统、网络、服务端和浏览器。
这篇文章借它的骨架,换成 Life of a Pixel 那样「跟着一个东西走」的讲法,并且去掉硬件:不讲触屏、CPU 中断、网卡、无线电和显示器,只沿着软件走。主角是一次请求:读者在浏览器里打开本站的一篇文章。
全程地图
每一站都按同样的顺序讲:你在这里、它长什么样、发生了什么、深入阅读。
本站的结构是这样的:读者的请求先到 HTTPS 反向代理,再交给 Next.js 做服务端渲染;Next.js 渲染页面需要的数据,向 Rust 写的后端接口去取,后端再查 PostgreSQL。下文假设本站部署在 syntomic.cn。
第一站:输入
它长什么样
读者在地址栏里敲下一行字,按下回车:
syntomic.cn/contents/life-of-a-data#第二站采集浏览器把它补全、拆开,变成一个结构化的 URL:
部分 | 值 | 说明 |
|---|---|---|
协议 |
| 用户没写,浏览器补上 |
主机 |
| 要去哪台服务器 |
端口 |
| HTTPS 的默认端口 |
路径 |
| 要哪个资源 |
片段 |
| 不会发给服务器,只在页面加载后用来滚动定位 |
发生了什么
判断是网址还是搜索词:地址栏输入的东西也可能是要搜索的关键词。
补全协议:如果这个域名在 HSTS(HTTP 严格传输安全)列表里,浏览器会直接用 HTTPS,不会先发一个明文的 HTTP 请求。
提前准备:FEX 的文章提到,Chrome 会根据历史预测你要访问的网站,在你按回车之前就开始解析域名、甚至建立连接。
先看缓存:如果这个资源在浏览器缓存里、而且还没过期,就不必再发请求了。本站的文章页面设置了不缓存(后面「返回」一站会看到),所以这次一定要发请求。
第二站:解析
它长什么样
网络只认 IP 地址,所以浏览器要先问:syntomic.cn 在哪里?问到最后,拿到的是一条 DNS 记录:
syntomic.cn. 300 IN A <服务器的 IPv4 地址>300 是这条记录的有效期(TTL,秒):5 分钟内再访问,就不用再问一遍了。
发生了什么
查找的顺序是从近到远,哪一层有答案就在哪一层停下:
前三层是缓存;真正「从头查」时,递归解析器替你逐级去问:根服务器只知道 .cn 由哪些服务器负责,.cn 的服务器只知道 syntomic.cn 由哪些服务器负责,最后由域名自己的权威服务器给出地址。每一层的答案都会按 TTL 缓存下来,所以大多数时候根本不用走到根服务器。
深入阅读
从零搭建网站:给自己的服务器注册域名、添加 A 记录,以及备案。
第三站:连接
它长什么样
有了地址,就要建立一条加密的连接。下面是我在电脑上用 curl -v 访问 www.example.com 的真实输出(节选):
* Trying [2606:4700:10::ac42:93f3]:443...
* Trying 104.20.23.154:443...
* Connected to www.example.com (104.20.23.154) port 443
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* subject: CN=example.com
* issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3
* using HTTP/2如果设置了代理,开头会变成先连上本机的代理,再请它打通一条隧道:
* Connected to 127.0.0.1 (127.0.0.1) port 10808
> CONNECT www.example.com:443 HTTP/1.1
< HTTP/1.1 200 Connection established发生了什么
一条 HTTPS 连接要先后完成两件事:
TCP 三次握手:确认双方都能收发,建立一条可靠、有序的字节流。
TLS 握手:服务器出示证书证明自己确实是
example.com,双方协商出只有彼此知道的会话密钥。TLS 1.3 只需要一个来回。ALPN:在 TLS 握手里顺便商量好用哪个 HTTP 版本,这里选的是 HTTP/2。
IPv6 优先、IPv4 兜底:上面的输出里,
curl先试了 IPv6 地址,没连上又试 IPv4。客户端同时准备两种地址、哪个先通用哪个,叫作 Happy Eyeballs。
FEX 的文章还提到了 TCP 的队头阻塞:TCP 保证按顺序交付,前面一个包丢了,后面已经到达的包也只能等着。HTTP/2 在一条连接上同时传很多个请求,一个包丢了会拖住所有请求。HTTP/3 改用基于 UDP 的 QUIC,每个请求各自保证顺序,丢包只影响自己。
深入阅读
第四站:发送
它长什么样
连接建好,浏览器发出真正的请求。为了好读,这里写成 HTTP/1.1 的文本形式(HTTP/2 传的是二进制帧,含义相同):
GET /contents/life-of-a-data HTTP/1.1
Host: syntomic.cn
User-Agent: Mozilla/5.0 (Macintosh; ...) Chrome/...
Accept: text/html,application/xhtml+xml,...
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: <本站的访客标识>注意地址栏里的 #第二站采集 不在里面:片段只留在浏览器。
发生了什么
方法和路径:
GET表示读取,路径说明要哪个资源。Host:同一台服务器、同一个 IP 上可能有很多网站,靠它区分。
Accept-Encoding:告诉服务器「我能解压这些格式」,服务器就可以压缩后再发。
Cookie:浏览器自动带上这个网站之前写下的 Cookie。本站用它在不登录的情况下认出同一个访客,统计访客数(见最后一站)。
第五站:入口
它长什么样
请求到达服务器,首先遇到的不是应用,而是反向代理。以 Nginx 为例,它的配置决定了请求往哪里去:
server {
listen 443 ssl;
server_name syntomic.cn;
ssl_certificate cert/server.crt;
ssl_certificate_key cert/server.key;
location / {
# 转给本机上的 Next.js 服务
proxy_pass http://127.0.0.1:5173;
proxy_set_header Host $host;
# 把读者的真实地址带给后面的服务
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}发生了什么
FEX 的文章把入口分成两类:
工作在哪一层 | 能做什么 | 例子 | |
|---|---|---|---|
四层负载均衡 | TCP | 只看 IP 和端口,把连接分给后面的机器,速度快 | LVS |
七层反向代理 | HTTP | 能看懂请求:按路径、Cookie 分流,统一处理 HTTPS、压缩、缓存、防攻击 | Nginx、HAProxy |
大网站往往两层都有:先用四层负载均衡把流量分给一组 Nginx,再由 Nginx 按请求内容转给不同的应用。本站规模小,一层反向代理就够了:它在这里卸下 HTTPS(解密),再用普通的 HTTP 把请求转给本机的 Next.js 服务。
因为后面的服务看到的连接都来自反向代理,读者的真实 IP 要放进 X-Forwarded-For 头里传过去,后端只信任来自本机代理的这个头。
深入阅读
从零搭建网站:用 Nginx 做反向代理和 HTTPS。
第六站:处理
它长什么样
这一站,请求在服务端内部又分裂出了几个请求。Next.js 发现 /contents/life-of-a-data 对应的是文章页,于是在服务端调用 getServerSideProps,向 Rust 后端要渲染这个页面所需的数据:
GET http://127.0.0.1:8000/api/public/site ← 站点名称、导航
GET http://127.0.0.1:8000/api/public/contents/life-of-a-data ← 文章本身
GET http://127.0.0.1:8000/api/public/tree?knowledgeBase=data ← 左侧的目录树后端返回的文章数据是 JSON:
{
"id": "e917fdfe-...",
"slug": "life-of-a-data",
"title": "Life of a Data:一条数据的一生",
"publishedAt": "2026-10-19T...",
"content": {"type": "doc", "content": [{"type": "heading", ...}, ...]},
"knowledgeBase": {"slug": "data", "name": "数据"}
}发生了什么
Web 框架与路由:FEX 那篇讲的是 Apache 调用 PHP,本站换成了 Next.js 和 Rust 的 axum,但思路一样:框架根据路径找到对应的处理代码。
中间件:请求进入后端的处理函数之前,要依次经过一层层中间件:记录日志、检查跨域、限制请求体大小、识别会话。
读数据:处理函数查询 PostgreSQL,并且只返回读者可以看的内容:已经发布、所在的知识库也公开了。草稿和不存在的文章得到的是同一个「找不到」,不会泄露草稿的存在。
服务端渲染:Next.js 拿到数据后,在服务端把 React 组件渲染成 HTML。读者收到的第一份 HTML 里就已经有正文了,不用等 JavaScript 加载完再去取数据。
服务端发出的请求更克制:Next.js 向后端取数据时只访问公开接口,不转发读者的 Cookie,并且最多等 8 秒,后端没有响应就返回错误页,不会一直挂着。
深入阅读
SQL Is All Your Need:SQL2API:数据接口怎样把请求参数变成 SQL、把结果变成 JSON。
Life of a Data:这些数据在被查询之前,又经历了什么。
第七站:返回
它长什么样
按本站代码中的配置,生产环境下这个页面的响应大致是这样的:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
X-Content-Type-Options: nosniff
Content-Encoding: gzip
Vary: Accept-Encoding
<!DOCTYPE html>
<html lang="zh-CN">
<head>... 标题、样式、脚本的地址 ...</head>
<body>
<div id="__next">... 已经渲染好的正文 ...</div>
<script id="__NEXT_DATA__" type="application/json">... 渲染用到的数据 ...</script>
<script src="/_next/static/chunks/..."></script>
</body>
</html>发生了什么
状态码:
200表示成功;文章不存在时是404;地址换了是308跳转到新地址。Cache-Control:文章页面告诉浏览器和中间的任何缓存「不要存」,因为作者随时可能更新发布,读者每次都应该拿到最新的版本。但页面引用的 JavaScript 和 CSS 文件名里带着内容的哈希,内容一变文件名就变,所以它们可以放心地长期缓存。
压缩:浏览器说过能解压 gzip,服务器就把 HTML 压缩后再发,文本通常能压缩到原来的几分之一。
HTML 里带着数据:
__NEXT_DATA__里是服务端渲染时用到的那份数据,浏览器接管页面时直接用它,不必再请求一遍。
第八站:渲染
它长什么样
浏览器收到的只是一串字节。以「中」字为例,FEX 的文章写道:它在 UTF-8 编码下是 E4 B8 AD 三个字节,在 GBK 编码下却是 D6 D0 两个字节。所以第一步是按 Content-Type 里的 charset=utf-8 把字节解码成字符,然后再一层层变成屏幕上的画面:
发生了什么
解析 HTML:边下载边解析,构建 DOM 树。解析时遇到外链的 CSS 和 JavaScript,会去下载它们;浏览器还会提前扫描后面的 HTML,尽早发现要下载的资源。
样式、布局、绘制、合成:给每个元素算出样式,算出它在页面上的位置和大小,再画出来、拼成最终的画面。这几步的细节,正是 Life of a Pixel 讲的内容。
执行 JavaScript,接管页面:页面的 JavaScript 下载完成后,React 会把
__NEXT_DATA__里的数据和已经渲染好的 HTML 对上,给按钮、评论框等挂上事件。这一步叫水合(hydration)。从这时起,页面就可以交互了。
读者看到正文的时间,取决于 HTML 何时到达和解析;能点能用的时间,取决于 JavaScript 何时完成水合。服务端渲染缩短的是前者。
之后:请求的余波
页面出现了,但请求的故事还没完全结束。
记下这次访问
水合完成后,页面会悄悄发出一个请求,告诉后端「有人看了这篇文章」:
POST /api/public/views
Content-Type: application/json
{"path": "/contents/life-of-a-data"}后端用 Cookie(登录的读者用账号)认出这是哪位访客,把浏览量加一;访客数则写进一个 HyperLogLog,而不是保存每一次访问的明细。作者自己的访问不计入。HyperLogLog 怎样用很少的空间估计「有多少个不同的人」,见 大数据去重统计。
站内跳转
读者接着点了目录里的另一篇文章。这一次,浏览器不会重新加载整个页面:Next.js 只向服务器要下一页的数据(一个 JSON),在浏览器里渲染出新的正文,地址栏同步更新。导航、侧栏和已经加载的脚本都留在原地。
第二次访问
如果读者明天再来,很多步骤都可以省掉:
站 | 第二次 |
|---|---|
解析 | DNS 记录可能还在缓存里(取决于 TTL) |
连接 | 短时间内再来,可以复用还开着的连接;新连接也能恢复之前的 TLS 会话,省去证书验证,开启 0-RTT 时还能在第一个来回就带上请求 |
返回 | HTML 不缓存,每次重新取;JavaScript 和 CSS 直接从浏览器缓存里读 |
渲染 | 解析、布局、绘制每次都要重新做 |
这篇没讲的
和 FEX 的原文一样,这里也省略了很多内容:
硬件:按键和触屏怎样变成操作系统的输入事件,数据包怎样经过网卡、无线电和光纤,画面怎样从显存到达屏幕。这是本文刻意去掉的部分,感兴趣可以读原文的第一、三、六个问题。
各种缓存:CDN、反向代理缓存、后端缓存。
监控与日志:一次请求在每一站都会留下日志,出问题时靠它们定位。
回顾
站 | 请求的样子 | 关键技术 |
|---|---|---|
输入 | 一个结构化的 URL | URL 解析、HSTS、预连接、浏览器缓存 |
解析 | 一条 A 记录 | DNS 逐级查询与缓存 |
连接 | 一条加密的连接 | TCP、TLS 1.3、ALPN |
发送 | 请求行加请求头 | HTTP/1.1、HTTP/2 |
入口 | 被转发的请求 | 四层负载均衡、七层反向代理 |
处理 | 服务端内部的几个子请求和 SQL | Web 框架、中间件、服务端渲染 |
返回 | 状态码、响应头和 HTML | 缓存控制、压缩 |
渲染 | DOM、样式、布局、画面 | 浏览器渲染流水线、水合 |
参考资料
百度 FEX 团队,从输入 URL 到页面加载完成的过程中都发生了什么事情?,2014。
Steve Kobes,Life of a Pixel,Chrome University 2020。
Ilya Grigorik,High Performance Browser Networking,O'Reilly,2013。
Tali Garsiel、Paul Irish,How Browsers Work。
RFC 8446:TLS 1.3;RFC 9113:HTTP/2;RFC 9000:QUIC;RFC 8305:Happy Eyeballs 第 2 版。
全文评论
0正在加载评论…