2014 年,百度 FEX 团队写过一篇流传很广的长文:从输入 URL 到页面加载完成的过程中都发生了什么事情?。它从手指按下屏幕讲起,一路讲到液晶屏上的像素亮起,覆盖了硬件、操作系统、网络、服务端和浏览器。

这篇文章借它的骨架,换成 Life of a Pixel 那样「跟着一个东西走」的讲法,并且去掉硬件:不讲触屏、CPU 中断、网卡、无线电和显示器,只沿着软件走。主角是一次请求:读者在浏览器里打开本站的一篇文章。

全程地图

每一站都按同样的顺序讲:你在这里、它长什么样、发生了什么、深入阅读。

本站的结构是这样的:读者的请求先到 HTTPS 反向代理,再交给 Next.js 做服务端渲染;Next.js 渲染页面需要的数据,向 Rust 写的后端接口去取,后端再查 PostgreSQL。下文假设本站部署在 syntomic.cn。

第一站:输入

它长什么样

读者在地址栏里敲下一行字,按下回车:

plaintext
syntomic.cn/contents/life-of-a-data#第二站采集

浏览器把它补全、拆开,变成一个结构化的 URL:

部分

值

说明

协议

https

用户没写,浏览器补上

主机

syntomic.cn

要去哪台服务器

端口

443

HTTPS 的默认端口

路径

/contents/life-of-a-data

要哪个资源

片段

#第二站采集

不会发给服务器,只在页面加载后用来滚动定位

发生了什么

  • 判断是网址还是搜索词:地址栏输入的东西也可能是要搜索的关键词。

  • 补全协议:如果这个域名在 HSTS(HTTP 严格传输安全)列表里,浏览器会直接用 HTTPS,不会先发一个明文的 HTTP 请求。

  • 提前准备:FEX 的文章提到,Chrome 会根据历史预测你要访问的网站,在你按回车之前就开始解析域名、甚至建立连接。

  • 先看缓存:如果这个资源在浏览器缓存里、而且还没过期,就不必再发请求了。本站的文章页面设置了不缓存(后面「返回」一站会看到),所以这次一定要发请求。

第二站:解析

它长什么样

网络只认 IP 地址,所以浏览器要先问:syntomic.cn 在哪里?问到最后,拿到的是一条 DNS 记录:

plaintext
syntomic.cn.    300    IN    A    <服务器的 IPv4 地址>

300 是这条记录的有效期(TTL,秒):5 分钟内再访问,就不用再问一遍了。

发生了什么

查找的顺序是从近到远,哪一层有答案就在哪一层停下:

前三层是缓存;真正「从头查」时,递归解析器替你逐级去问:根服务器只知道 .cn 由哪些服务器负责,.cn 的服务器只知道 syntomic.cn 由哪些服务器负责,最后由域名自己的权威服务器给出地址。每一层的答案都会按 TTL 缓存下来,所以大多数时候根本不用走到根服务器。

深入阅读

  • 从零搭建网站:给自己的服务器注册域名、添加 A 记录,以及备案。

第三站:连接

它长什么样

有了地址,就要建立一条加密的连接。下面是我在电脑上用 curl -v 访问 www.example.com 的真实输出(节选):

plaintext
*   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

如果设置了代理,开头会变成先连上本机的代理,再请它打通一条隧道:

plaintext
* 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 传的是二进制帧,含义相同):

http
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 为例,它的配置决定了请求往哪里去:

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 头里传过去,后端只信任来自本机代理的这个头。

深入阅读

第六站:处理

它长什么样

这一站,请求在服务端内部又分裂出了几个请求。Next.js 发现 /contents/life-of-a-data 对应的是文章页,于是在服务端调用 getServerSideProps,向 Rust 后端要渲染这个页面所需的数据:

plaintext
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:

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 秒,后端没有响应就返回错误页,不会一直挂着。

深入阅读

第七站:返回

它长什么样

按本站代码中的配置,生产环境下这个页面的响应大致是这样的:

http
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 何时完成水合。服务端渲染缩短的是前者。

之后:请求的余波

页面出现了,但请求的故事还没完全结束。

记下这次访问

水合完成后,页面会悄悄发出一个请求,告诉后端「有人看了这篇文章」:

http
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、样式、布局、画面

浏览器渲染流水线、水合

参考资料


全文评论

0

正在加载评论…