← 返回画廊

一个 HTTP 请求的一生:从输入 URL 到页面渲染

读者画像:你能熟练写业务代码、调 API,但一被问到「在浏览器地址栏输入 www.example.com 回车之后,到底发生了什么」,就只能背出零散的几个词——DNS、三次握手、渲染——中间的因果链却串不起来。这也是最经典的面试题之一。<br><br>读完这篇文章,你会得到一条完整、按时间顺序、每一环都能量化耗时数量级的心智地图。核心思路是:一个 HTTP 请求的绝大部分时间不是花在「传数据」上,而是花在「等」上——等 DNS 查完、等握手来回、等阻塞的资源下载完。理解了「每一次网络往返(RTT)都是一次昂贵的等待」,这条链路的性能优化就几乎全都是「消灭往返」或「把往返藏起来」。

全文的数字都标了数量级和量纲,尽量给出「典型值」而非「精确值」——真实数字取决于物理距离、网络质量和服务端负载,但数量级的直觉(是 1ms 还是 100ms 还是 1s)比精确值重要得多。文中涉及协议细节处标注了来源。


目录

  1. 一张全景图与一个核心量纲:RTT
  2. URL 解析与 DNS:把域名翻译成 IP
  3. 建立连接:TCP 三次握手 + TLS 握手
  4. HTTP 请求与响应:请求行、状态码、缓存、Cookie
  5. 协议演进:HTTP/1.1 → HTTP/2 → HTTP/3,队头阻塞的三次围剿
  6. 到达服务器之前:CDN、反向代理、负载均衡
  7. 浏览器渲染:从字节流到屏幕像素
  8. 性能视角:每一环能怎么优化
  9. 常见困惑澄清
  10. 一句话收尾

1. 一张全景图与一个核心量纲:RTT

在钻进细节之前,先立一个全局框架。从你按下回车到页面「基本可看」,时间被切成这么几段:

输入 URL
  │
  ├─ ① DNS 解析         域名 → IP        (缓存命中 <1ms;冷查询 20~120ms)
  │
  ├─ ② 建立连接
  │      ├─ TCP 三次握手                (1 个 RTT)
  │      └─ TLS 握手(HTTPS)           (TLS 1.3 = 1 个 RTT;TLS 1.2 = 2 个 RTT)
  │
  ├─ ③ 发送 HTTP 请求 → 服务端处理 → 返回响应
  │      (首字节到达 = TTFB,含 1 个 RTT + 服务端处理时间)
  │
  ├─ ④ 下载 HTML,边下边解析
  │      └─ 遇到 CSS/JS/图片 → 回到 ②/③ 拉更多资源
  │
  └─ ⑤ 浏览器渲染:DOM → CSSOM → 渲染树 → 布局 → 绘制 → 合成 → 屏幕上有东西了

贯穿全文的核心量纲是 RTT(Round-Trip Time,往返时延):一个数据包从客户端到服务端再回来的时间。它由物理距离决定,且有硬下限——光在光纤里的速度约 20 万 km/s,北京到上海直线约 1000km,光纤绕路后一个来回的物理极限就有约 10ms,加上路由设备转发,实际同城 RTT 常在 5~30ms,跨国 RTT 轻松到 100~300ms。

为什么揪着 RTT 不放? 因为握手、请求都是「发出去 → 等回来」的往返模式,每多一个往返,就多一个 RTT 的墙上时钟时间,而这段时间里 CPU 和带宽基本是闲着的。带宽可以花钱买,RTT 买不来(受光速限制)。所以你会看到:这条链路上几乎所有严肃的优化,本质都是在减少 RTT 的个数,或者把 RTT 藏在用户察觉不到的地方。 记住这句话,后面每一节都是它的注脚。


2. URL 解析与 DNS:把域名翻译成 IP

2.1 先拆 URL

浏览器拿到 https://www.example.com:443/path?q=1#top,第一步是解析这个字符串:

部分作用
scheme(协议)https决定用 TLS、默认端口 443
host(主机名)www.example.com要做 DNS 解析的目标
port(端口)443(https 默认,可省略)TCP 连接到哪个端口
path(路径)/path发给服务器的资源路径
query(查询串)?q=1附带参数
fragment(锚点)#top只在浏览器本地生效,不会发给服务器

一个容易忽略的点:#top 锚点是纯客户端的,浏览器用它来滚动定位,永远不会出现在发往服务器的 HTTP 请求里。这也是为什么单页应用早期用 # 做路由能避免请求服务器。

计算机之间只认 IP 不认域名,所以下一步必须把 www.example.com 翻译成一个 IP 地址(如 93.184.216.34)。这就是 DNS 干的事。

2.2 递归查询 vs 迭代查询:谁替谁跑腿

DNS 的完整解析涉及一整套层级服务器:根服务器(.)→ 顶级域 TLD 服务器(.com)→ 权威服务器(example.com 的 NS)。关键要区分两种查询方式:

一句话记牢:客户端 → 递归解析器是「递归」(我只要结果);递归解析器 → 各级权威服务器是「迭代」(一跳一跳自己问)。 面试里把这两个方向说反是高频失分点。

2.3 缓存层级:绝大多数查询根本走不到根服务器

如果每次访问网站都要跑一遍「根 → TLD → 权威」,DNS 早就崩了。真相是:这套完整流程极少发生,因为每一层都有缓存。一次解析请求会依次撞上这些缓存层,任一层命中就直接返回:

浏览器自身 DNS 缓存(Chrome 内部有几十条、几分钟)
      ↓ 未命中
操作系统 DNS 缓存(macOS 的 mDNSResponder / Windows 的 DNS Client 服务;本机 hosts 文件也在这一层)
      ↓ 未命中
路由器缓存(家用路由器常兼做本地 DNS 缓存)
      ↓ 未命中
ISP / 公共递归解析器缓存(命中率最高的一层,海量用户共享)
      ↓ 未命中(才真正开始迭代查询)
根服务器 → TLD 服务器 → 权威服务器

每一层缓存都受该记录的 TTL(Time To Live) 约束——权威服务器在返回记录时会附带一个 TTL(比如 300 秒),各级缓存最多缓存这么久。TTL 设短,切换 IP(比如故障转移)快,但缓存命中率低;设长则相反。这是运维配 DNS 时的经典权衡。

耗时数量级

情况典型耗时
浏览器/OS 缓存命中< 1ms(就是查个本地哈希表)
ISP 递归解析器缓存命中一个到解析器的 RTT,约 1~20ms
完全冷查询(走完根→TLD→权威)20~120ms,跨国或权威服务器远时更久

2.4 提前量:dns-prefetch 与 preconnect

既然 DNS 是链路开头的一段串行等待,一个自然的优化是:在真正需要之前就把它做掉。HTML 里可以放这两种提示:

<!-- 只提前做 DNS 解析,成本极低,适合"可能会用到"的第三方域名 -->
<link rel="dns-prefetch" href="https://cdn.example.com">

<!-- 一步到位:DNS + TCP + TLS 全部提前建好,适合"马上一定会用"的关键域名 -->
<link rel="preconnect" href="https://cdn.example.com">

区别在于「提前多少」:dns-prefetch 只提前做 DNS 解析(省 20~120ms),成本几乎为零,可以撒网式地对多个域名用;preconnect 把 DNS + TCP 握手 + TLS 握手整条建连流程都提前跑完(省的时间多得多,可达上百 ms),但每个 preconnect 都会真的占用一个连接和握手开销,所以只对「确定马上要用」的少数关键域名用(一般不超过 4~6 个),滥用反而拖慢首屏。

补充:DoH(DNS over HTTPS)/ DoT(DNS over TLS) 是近年 DNS 的重要演进——传统 DNS 查询是明文 UDP,任何中间人都能看到甚至篡改你在访问哪些域名。DoH 把 DNS 查询封装进 HTTPS,DoT 封装进 TLS,都为了隐私和防篡改。Chrome、Firefox 已默认或可选启用。代价是多了一层加密开销,且让运营商/企业更难做 DNS 层面的管控。


3. 建立连接:TCP 三次握手 + TLS 握手

拿到 IP,就要和服务器建立一条可靠的通信管道。对 HTTPS 网站,这分两步:先建 TCP 连接(可靠传输),再建 TLS 连接(加密)。

3.1 TCP 三次握手:确认「双方都能收发」

TCP 是可靠传输协议,通信前必须先「握手」,确认双方的收发能力都正常。经典的三次握手:

客户端                                服务端
  │  ── SYN (seq=x) ──────────────▶    │   "我想连你,我的初始序号是 x"
  │                                    │
  │  ◀──── SYN+ACK (seq=y, ack=x+1) ── │   "收到;我也想连你,我的序号是 y,
  │                                    │    确认你的 x 收到了"
  │  ── ACK (ack=y+1) ────────────▶    │   "收到你的 y 了,开始通信吧"
  │                                    │
  连接建立(客户端在发出第三个 ACK 后即可开始发数据)

为什么必须是三次,不能两次或四次?

耗时:三次握手中,客户端在收到第二个包(SYN+ACK)后就可以开始发数据了,所以建立 TCP 连接对首个请求的开销是「1 个 RTT」。 记住这个 1-RTT。

3.2 TLS 握手:协商加密,1.2 与 1.3 的往返差异

HTTP 是明文的,HTTPS = HTTP + TLS。TCP 管道建好后,还要在上面再做一次 TLS 握手,目的是:验证服务器身份(证书)+ 协商出一把双方都知道、别人不知道的对称加密密钥。 这一步是「HTTPS 比 HTTP 慢一点」的主要来源。

TLS 1.2(旧,2 个 RTT):完整握手要两个来回——第一个 RTT 交换 ClientHello/ServerHello、证书、密钥交换参数;第二个 RTT 完成密钥确认(Finished)。也就是说,光 TLS 握手就要等 2 个 RTT。

TLS 1.3(新,1 个 RTT,2018 年 RFC 8446 标准化):这是重大改进。TLS 1.3 把握手压缩到 1 个 RTT——客户端在第一个 ClientHello 里就直接把密钥交换参数(key share)一起发出去,服务端一个来回就能把密钥定下来并开始加密通信。它还砍掉了 TLS 1.2 里一堆不安全或冗余的东西:淘汰了 RSA 密钥交换、静态 DH、旧的分组密码模式,密码套件从上百种精简到寥寥几种,既更快又更安全。

0-RTT(TLS 1.3 的进阶,会话恢复场景):如果之前连过这台服务器,客户端可以用缓存的会话信息,在第一个包里就带上应用数据(early data),实现 0 个 RTT 的数据发送。代价是 0-RTT 数据有重放攻击风险(攻击者可以截获并重复发送这个包),所以协议规定只有幂等操作(如 GET)才适合用 0-RTT,服务端也要做重放防护。

量化「HTTPS 慢一点」到底慢多少

场景建连总往返相比纯 HTTP 多出的开销
HTTP(无 TLS)TCP 1 RTT
HTTPS + TLS 1.2TCP 1 RTT + TLS 2 RTT = 3 RTT+2 RTT
HTTPS + TLS 1.3TCP 1 RTT + TLS 1 RTT = 2 RTT+1 RTT
HTTPS + TLS 1.3 会话恢复(0-RTT)趋近 TCP 1 RTT几乎为零

假设同城 RTT 20ms,TLS 1.2 首次建连比 HTTP 多约 40ms,TLS 1.3 多约 20ms;跨国 RTT 150ms 时,这就是多 300ms vs 150ms 的差距——这也是为什么升级到 TLS 1.3、复用连接、就近接入 CDN 对 HTTPS 站点如此重要。

顺带说:证书里非对称加密(RSA/ECDHE)只用在握手阶段协商密钥,一旦密钥协商好,后续的实际数据传输用的是对称加密(AES 等),因为对称加密快得多。「非对称交换密钥 + 对称传数据」是所有 TLS 的基本套路。

3.3 连接复用与 Keep-Alive:不要每个请求都重新握手

上面算下来,一次 HTTPS 建连要 2~3 个 RTT,如果每请求一个资源就重新建连、断连,代价高得离谱(一个网页动辄几十上百个资源)。

HTTP Keep-Alive(持久连接) 就是解法:一个 TCP 连接在完成一次请求-响应后不立即关闭,而是保持一段时间,让后续请求复用同一条连接,从而只在第一个请求付握手的 RTT,后面的请求直接省掉建连开销。HTTP/1.1 默认开启持久连接(Connection: keep-alive),这是相对 HTTP/1.0 的一大改进。

但 HTTP/1.1 的连接复用有个天花板:同一条连接上,请求必须一个接一个串行发(一个响应没回来,下一个不能发), 这就引出了下一节的主角——队头阻塞。为绕开它,浏览器对同一个域名会并发建立多条连接(Chrome 等主流浏览器通常上限 6 条),让 6 个请求能同时跑。这也是「域名分片」(把资源分散到多个子域名以突破 6 连接限制)这种老优化手段的由来——不过在 HTTP/2 之后它已经过时甚至有害了。


4. HTTP 请求与响应:请求行、状态码、缓存、Cookie

连接建好,终于可以说「人话」了——发一个 HTTP 请求。

4.1 请求与响应的结构

一个 HTTP 请求由三部分组成:

GET /path?q=1 HTTP/1.1          ← 请求行:方法 + 路径 + 协议版本
Host: www.example.com           ← 请求头(多行 key: value)
User-Agent: Mozilla/5.0 ...
Accept: text/html
Cookie: session=abc123
                                ← 空行,分隔头和体
(请求体:GET 通常没有;POST/PUT 在这里放数据)

响应结构对称:

HTTP/1.1 200 OK                 ← 状态行:协议版本 + 状态码 + 原因短语
Content-Type: text/html; charset=utf-8   ← 响应头
Content-Length: 1256
Cache-Control: max-age=3600
Set-Cookie: session=abc123; HttpOnly
                                ← 空行
<html>...</html>                ← 响应体:真正的内容

从请求发出到响应第一个字节到达客户端,这段时间叫 TTFB(Time To First Byte)。它 ≈ 1 个 RTT(请求过去、响应回来)+ 服务端处理时间(查数据库、渲染模板等)。TTFB 是衡量「后端 + 网络」快不快的关键指标,前端优化管不到它,得靠后端和 CDN。

4.2 常见状态码:分类即语义

状态码的第一位数字就定了大类,记住分类比背具体码更有用:

类别含义高频具体码
1xx 信息请求已收到,处理中100 Continue(可以继续发请求体)、101 Switching Protocols(如升级到 WebSocket)
2xx 成功请求成功处理200 OK201 Created(POST 新建成功)、204 No Content(成功但无返回体)
3xx 重定向需要进一步动作,通常是跳转301 Moved Permanently(永久重定向,会被缓存)、302 Found(临时重定向)、304 Not Modified(协商缓存命中,见下文)
4xx 客户端错误请求本身有问题400 Bad Request401 Unauthorized(未认证)、403 Forbidden(已认证但无权限)、404 Not Found429 Too Many Requests(限流)
5xx 服务端错误服务器处理时出错500 Internal Server Error502 Bad Gateway(网关/代理拿到上游坏响应)、503 Service Unavailable(过载/维护)、504 Gateway Timeout(上游超时)

几个高频混淆点:401(你没登录/凭证无效)vs 403(你登录了但没权限);502(代理连上游但上游给了坏响应)vs 504(代理连上游但上游超时没响应);301(永久,浏览器会缓存跳转、对 SEO 传递权重)vs 302(临时,不该被长期缓存)。

4.3 缓存:强缓存与协商缓存

缓存是 Web 性能的重头戏,核心是避免重复下载没变的资源。分两级:

① 强缓存(本地直接用,连请求都不发) —— 由响应头 Cache-Control 控制:

Cache-Control: max-age=3600      ← 3600 秒内,浏览器直接用本地副本,不发任何请求

常见指令:

指令含义
max-age=N资源在 N 秒内视为新鲜,直接用缓存
no-cache可以缓存,但每次用前必须找服务器验证(走协商缓存,别被名字骗了)
no-store彻底不缓存,每次都完整重新下载(用于敏感数据)
public允许任何中间节点(如 CDN)缓存
private只允许浏览器缓存,中间代理不能缓存(含用户私有数据时用)
must-revalidate缓存过期后,必须去服务器验证,不能用过期副本凑合

② 协商缓存(发个请求问服务器"变了没",没变就回 304) —— 当强缓存过期(或设了 no-cache),浏览器带着「缓存标识」去问服务器,服务器判断资源没变就回一个 304 Not Modified(不带响应体),浏览器继续用本地副本。省的是响应体的下载(往返还是有的,但不用重传 body)。两套机制:

一图看懂缓存决策流:

请求资源
  │
  ├─ 强缓存(Cache-Control/max-age)未过期?── 是 ──▶ 直接用本地副本(连请求都不发,最快)
  │                                    否 │
  │                                       ▼
  ├─ 带 ETag/Last-Modified 去问服务器(协商缓存)
  │        │
  │        ├─ 服务器:没变 → 304 Not Modified ──▶ 用本地副本(省了 body 下载)
  │        └─ 服务器:变了 → 200 + 新内容      ──▶ 下载新副本并更新缓存

4.4 Cookie:给无状态的 HTTP 补上"记忆"

HTTP 本身是无状态的——服务器默认不记得你上个请求是谁。Cookie 就是补丁:服务器通过响应头 Set-Cookie 塞一小段数据给浏览器,浏览器存下来,之后每次请求同一站点都自动带上 Cookie 请求头,服务器借此识别用户(如登录态)。关键属性:HttpOnly(JS 读不到,防 XSS 窃取)、Secure(只在 HTTPS 下发送)、SameSite(限制跨站携带,防 CSRF)。注意 Cookie 会随每个请求上行,塞太多会拖慢每个请求,敏感/大数据应放别处(如 localStorage、服务端 session)。


5. 协议演进:HTTP/1.1 → HTTP/2 → HTTP/3,队头阻塞的三次围剿

HTTP 协议这些年的演进,可以用一条主线串起来:不断地消灭「队头阻塞(Head-of-Line Blocking)」——排在队伍最前面的那个慢家伙,卡住了后面所有人。这个问题在三个层次上出现,也被三代协议依次解决。

5.1 HTTP/1.1 的痛:应用层队头阻塞

HTTP/1.1 一条 TCP 连接上,请求-响应必须严格串行:发出请求 A,必须等 A 的响应完全回来,才能发请求 B。(曾有个叫「管线化 Pipelining」的机制想让请求连续发,但响应仍须按序返回,且实现问题多,基本没被采用。)

结果:如果响应 A 是个很大的、很慢的资源,它就卡住了后面所有请求——这就是应用层的队头阻塞。浏览器的应对是「人海战术」:对同一域名并发开 6 条 TCP 连接(见 3.3),让 6 个请求并行。但一个页面几十个资源,6 条连接还是不够用,剩下的仍得排队,且每条连接都有独立的握手成本。

5.2 HTTP/2:二进制分帧 + 多路复用,干掉应用层队头阻塞

HTTP/2(2015 年 RFC 7540)的核心武器是多路复用(Multiplexing)

但 HTTP/2 没解决更底层的一个问题:它所有的流都跑在同一条 TCP 连接上。而 TCP 为了保证「可靠、按序」,一旦某个 TCP 数据包丢了,TCP 就必须停下来等它重传,在它到达之前,后面所有已经到达的数据都不能交给上层——哪怕那些数据属于别的、完全无关的流。这就是TCP 层面的队头阻塞:应用层不阻塞了,传输层又来阻塞。丢包率越高(弱网、移动网络),这个问题越致命,HTTP/2 在弱网下甚至可能比 HTTP/1.1 还慢。

5.3 HTTP/3(QUIC):换掉 TCP,连传输层队头阻塞一起消灭

HTTP/3(2022 年 RFC 9114)的做法很激进:抛弃 TCP,改用基于 UDP 的 QUIC 协议。QUIC 在 UDP 之上自己重新实现了可靠传输、拥塞控制、加密。它带来三个关键好处:

  1. 真正独立的流,消灭传输层队头阻塞:QUIC 里每个流的丢包重传是互相独立的——流 A 的包丢了,只有流 A 停下来等重传,流 B、C 的数据照常向上层交付,互不影响。这就从根上解决了 HTTP/2 的 TCP 队头阻塞。
  2. 建连更快,内置 TLS 1.3:QUIC 把「传输层握手」和「TLS 1.3 加密握手」合并成一次,首次连接就是 1-RTT,会话恢复时甚至 0-RTT(对比 HTTPS over TCP 的 TCP 1-RTT + TLS 1-RTT = 2-RTT)。加密不再是可选项,而是协议内建。
  3. 连接迁移(Connection Migration):传统 TCP 连接由「源IP+源端口+目的IP+目的端口」四元组标识,你从 WiFi 切到 4G,IP 变了,连接就断了、得重连。QUIC 用一个独立的 Connection ID 标识连接,与 IP 无关,所以网络切换时连接不中断——这对手机用户体验是巨大提升。

代价:QUIC 在用户态实现(不像 TCP 在内核),CPU 开销略高;且 UDP 在一些老旧网络设备上会被限速或拦截。但收益远大于代价,目前 Google、Cloudflare、主流 CDN 和 Chrome/Firefox/Edge 都已广泛支持 HTTP/3,它已经是新一代 Web 的默认方向。

三代协议对比

HTTP/1.1HTTP/2HTTP/3
底层传输TCPTCPQUIC(基于 UDP)
应用层队头阻塞❌ 有(串行请求)✅ 多路复用解决✅ 解决
传输层队头阻塞有(但每连接一请求,感知弱)仍有(TCP 层丢包阻塞所有流)流独立,解决
首次建连TCP 1-RTT (+TLS)TCP 1-RTT (+TLS)1-RTT(含加密)
头部压缩HPACKQPACK
网络切换断连重连断连重连连接迁移,不断连

6. 到达服务器之前:CDN、反向代理、负载均衡

前面默认「请求直达源服务器」,但真实世界里,请求在到达业务代码之前,往往先经过好几层基础设施。

6.1 CDN:把内容搬到离用户最近的地方

回想第 1 节的结论:RTT 由物理距离决定,且无法突破光速。 如果你的服务器在美国,中国用户每个请求都要跨太平洋(RTT 150ms+),体验很差。CDN(内容分发网络) 的核心思想就是:在全球各地部署大量边缘节点(edge),把静态内容(图片、CSS、JS、视频,乃至缓存的页面)复制到离用户最近的节点,用户就近取用,把长距离 RTT 变成短距离 RTT。

「就近接入」通常靠两种技术实现:基于 DNS 的调度(DNS 解析时根据用户地理位置返回最近节点的 IP)或 Anycast(多个节点共用一个 IP,网络路由天然把用户导向最近的那个)。当边缘节点没有缓存所需内容时(缓存未命中),它会去回源——向源服务器请求一次并缓存下来,供后续用户直接命中。

CDN 带来的不只是快:它还挡在源站前面吸收了绝大部分流量(减轻源站压力)、抵御 DDoS、就近做 TLS 握手(缩短握手 RTT)。这也是为什么「静态资源全上 CDN」是几乎所有网站的标配。

6.2 反向代理与负载均衡

到达数据中心后,请求通常先撞上一个反向代理(Reverse Proxy,如 Nginx)。它对外是唯一入口,对内把请求转发给后面一堆真实的应用服务器。它顺手干很多活:TLS 终结(在这里解密,后端跑明文省 CPU)、静态文件直接返回、gzip 压缩、限流、缓存。

反向代理背后往往连着负载均衡(Load Balancer),负责把海量请求均匀分发到多台后端服务器,避免某台被打爆。常见分发算法:

算法做法适用
轮询(Round Robin)依次轮流分给每台后端配置一致时最简单
加权轮询(Weighted RR)按机器性能配权重,强的多分后端配置不均时
最少连接(Least Connections)分给当前连接数最少的请求处理时长差异大时
IP Hash按客户端 IP 哈希固定分到某台需要会话保持(同一用户总落同一台)
一致性哈希(Consistent Hashing)哈希环分配,增删节点时只影响相邻缓存类服务,减少节点变动导致的缓存失效

这一整套(CDN → 反向代理 → 负载均衡 → 应用服务器)就是「服务端处理时间」这段黑盒的展开。它们能让 TTFB 更短、系统更抗压。


7. 浏览器渲染:从字节流到屏幕像素

HTML 字节流终于回到浏览器了。但「拿到 HTML」离「屏幕上出现页面」还差一整套流程,这套流程叫关键渲染路径(Critical Rendering Path)

7.1 六个步骤:解析 → 构建 → 布局 → 绘制 → 合成

HTML 字节流 ──解析──▶ DOM 树(文档对象模型,页面的结构)
                          │
CSS 字节流 ──解析──▶ CSSOM 树(每个节点的样式)
                          │
        DOM + CSSOM ──合并──▶ 渲染树(Render Tree,只含"要显示"的节点)
                          │
                    ──布局(Layout/Reflow)──▶ 算出每个节点的几何位置和大小(在哪、多大)
                          │
                    ──绘制(Paint)──▶ 把每个节点画成一层层像素(颜色、文字、边框、阴影)
                          │
                    ──合成(Composite)──▶ 把多个图层按正确顺序合并、交给 GPU 输出到屏幕

逐个说清楚每一步「在干什么」:

  1. 构建 DOM:浏览器边接收 HTML 字节,边把标签解析成一棵树状结构 DOM(Document Object Model),它是页面结构的内存表示。这个过程是增量的——不用等 HTML 全下载完就开始。
  2. 构建 CSSOM:把 CSS 解析成 CSSOM(CSS Object Model),记录每个节点最终的样式。注意 CSS 是"渲染阻塞资源"——因为在样式没算全之前渲染出来的东西可能是错的(比如背景色、布局全变),浏览器宁愿等 CSSOM 就绪也不愿先渲染再重画。所以慢的 CSS 会拖住首屏。
  3. 合成渲染树:把 DOM 和 CSSOM 合并。关键:display: none 的节点不进渲染树(它压根不占位、不显示);而 visibility: hidden 的节点仍在渲染树里(它占位,只是看不见)。渲染树只包含「需要显示的内容」。
  4. 布局(Layout / Reflow):计算渲染树里每个节点的精确几何信息——在视口的什么位置、多宽多高。这一步很贵,因为一个节点的位置可能牵动一大片。
  5. 绘制(Paint):把每个节点转成实际的像素——填色、画文字、描边框、画阴影,通常分成多个图层。
  6. 合成(Composite):把这些图层按正确的层叠顺序合并成最终画面,交给 GPU 快速输出到屏幕。像 transformopacity 这类动画之所以流畅,正是因为它们只触发合成、不触发布局和绘制,可以走 GPU,不用重算几何。

7.2 为什么 JS 会阻塞渲染,以及 defer / async

HTML 解析过程中遇到 <script>(无标记的普通脚本)时,浏览器会暂停 HTML 解析,先下载并执行完这段 JS,再继续解析 HTML。这叫解析器阻塞(parser-blocking)。原因是:JS 可能通过 document.write 等改写文档结构,浏览器不敢一边改一边解析,只能停下来等它跑完。所以把大 <script> 放在 <head> 里会严重拖慢首屏——HTML 还没解析到 body,就卡在脚本上了。经典建议「把 script 放 body 底部」正是为此。

现代做法是用 deferasync 属性,让脚本下载不阻塞 HTML 解析:

下载时机执行时机保证顺序?
<script>(普通)遇到就下载下载后立即执行,阻塞解析
<script async>并行下载,不阻塞解析下载完立即执行(可能打断解析),谁先下完谁先跑❌ 不保证
<script defer>并行下载,不阻塞解析等 HTML 解析完成后、DOMContentLoaded按顺序执行✅ 保证书写顺序

选择:有依赖关系、要按顺序跑、要操作 DOM 的脚本用 defer(比如你自己的应用代码);独立、不依赖别人、不依赖 DOM 的用 async(比如埋点统计脚本)。

顺带两个高频面试点——两个生命周期事件的区别:

7.3 预扫描器:浏览器偷偷提前下资源

前面说 JS 会阻塞解析,那岂不是解析一停,下面的图片、CSS 都不下载了?现代浏览器(Chrome 的 Blink 引擎、V8 执行 JS)有个聪明的优化叫预加载扫描器(Preload Scanner):当主解析器被某个脚本卡住时,有一个轻量的辅助扫描器会继续往后扫 HTML,把后面出现的 CSS、JS、图片等资源的 URL 提前发现、提前开始下载。这样即使主解析被阻塞,网络也没闲着。这是浏览器为「阻塞」打的一个重要补丁,也是为什么把资源写在 HTML 里(而非 JS 动态插入)更利于被提前发现。

7.4 衡量渲染快不快:几个关键指标

真正被 Google 用来给网站体验打分的是 Core Web Vitals(核心网页指标)。这里有一个重要的时效性坑:2024 年 3 月起,Core Web Vitals 三件套是 LCP / INP / CLS——原来的 FID 已被 INP 正式取代,很多老文章还写 FID,别跟着错。

指标衡量什么「良好」阈值(75 分位)
LCP(Largest Contentful Paint,最大内容绘制)页面主要内容(通常是最大的图或标题块)渲染完成的时刻——「加载快不快」2.5s
INP(Interaction to Next Paint,交互到下次绘制)用户交互后页面响应的延迟——「点了有没有反应」(2024 年替代了 FID)200ms
CLS(Cumulative Layout Shift,累积布局偏移)页面元素意外跳动的程度——「稳不稳、会不会点错」0.1

这些指标把「渲染」和「交互」这些抽象过程变成了可量化、可优化的数字,是前端性能工作的抓手。


8. 性能视角:每一环能怎么优化

把整条链路铺开,每一环都有对应的优化手段。通读这张表,你会发现绝大多数手段的本质,都是本文开头那句话——减少 RTT,或者把 RTT 藏起来(提前做/并行做/就近做/复用连接/干脆用缓存跳过)。

环节瓶颈所在优化手段本质
DNS 解析冷查询要跑多级服务器(20~120ms)dns-prefetch/preconnect 提前解析;合理设 TTL;用快的公共 DNS提前做 / 用缓存
TCP 握手每次建连 1 RTTKeep-Alive 复用连接;HTTP/2 一条连接跑多请求复用,消灭重复往返
TLS 握手TLS 1.2 要 2 RTT升级 TLS 1.3(1 RTT);会话恢复用 0-RTT;OCSP Stapling减少往返
建连整体首次访问握手开销大CDN 就近接入(缩短每个 RTT);HTTP/3(1-RTT 含加密)缩短 RTT / 合并往返
TTFB后端处理慢后端缓存、DB 优化、CDN 缓存整页、边缘计算减少服务端处理时间
传输内容体积大、传得慢gzip/Brotli 压缩;图片用 WebP/AVIF;按需加载;HTTP/2 多路复用减少字节 + 并行
重复请求没变的资源反复下载强缓存(Cache-Control) + 协商缓存(ETag);文件名带 hash 做长缓存用缓存跳过往返
协议层HTTP/1.1 队头阻塞、6 连接上限升级 HTTP/2(多路复用)→ HTTP/3(消灭 TCP 队头阻塞、弱网友好、连接迁移)消灭队头阻塞
弱网/移动丢包、网络切换断连HTTP/3 的流独立重传 + 连接迁移传输层解耦
渲染-CSSCSS 阻塞渲染关键 CSS 内联、其余异步加载;减小 CSS 体积缩短阻塞时间
渲染-JSJS 阻塞 HTML 解析defer/async;代码分割;脚本放底部;SSR 首屏直出不阻塞解析
渲染-布局频繁 reflow 卡顿减少强制同步布局;动画用 transform/opacity(只触发合成)避免重排重绘
首屏体感关键资源发现晚<link rel="preload"> 关键资源;资源写进 HTML 让预扫描器提前发现提前下载

9. 常见困惑澄清

9.1 「递归查询」和「迭代查询」到底谁递归谁迭代

再强调一次这个高频失分点:递归发生在「客户端 ↔ 本地递归解析器」之间——你只管要最终结果,解析器全权负责查到底。迭代发生在「递归解析器 ↔ 各级 DNS 服务器(根/TLD/权威)」之间——解析器一跳一跳地问,每个服务器只回「下一跳去哪问」而不直接给答案。方向记反就全错了。

9.2 为什么 HTTPS 首次慢,但整体不一定慢

HTTPS 首次建连比 HTTP 多 1~2 个 RTT 的握手(TLS)。但这只在首次建连付出,一旦连接建立并复用(Keep-Alive / HTTP/2 多路复用),后续请求都不再握手。而且 HTTPS 是 HTTP/2、HTTP/3 的前提(浏览器只在 HTTPS 上启用它们),HTTP/2 的多路复用带来的收益,往往远超那点握手开销。所以「HTTPS 慢」只在冷启动的第一个请求上成立,全局看现代 HTTPS 站点通常更快。

9.3 HTTP/2 已经多路复用了,为什么还需要 HTTP/3

因为 HTTP/2 只解决了应用层队头阻塞,没解决传输层(TCP)队头阻塞:所有流共用一条 TCP 连接,TCP 一旦丢包就得停下来等重传,把所有流一起卡住。HTTP/3 换用基于 UDP 的 QUIC,让每个流独立重传,才真正把队头阻塞连根拔起——在丢包率高的弱网/移动场景,这个差别尤其明显(HTTP/2 在弱网下甚至可能不如 HTTP/1.1)。

9.4 no-cache 不是「不缓存」

这是命名坑:Cache-Control: no-cache 的意思是「可以缓存,但每次用之前必须去服务器验证」(走协商缓存),真正的「完全不缓存」是 no-store。想让敏感数据不落地,用 no-store,别用 no-cache

9.5 display:nonevisibility:hidden 在渲染上的区别

display: none 的节点不进渲染树——不占位、不布局、不绘制,改它会触发重排。visibility: hidden 的节点仍在渲染树里——正常占位和布局,只是绘制成「透明看不见」。所以前者是「彻底不存在于布局」,后者是「占着位置但隐身」,这在性能和布局行为上是两回事。

9.6 deferasync 别用混

都不阻塞 HTML 解析的下载,区别在执行async 下载完立刻执行(可能打断解析、且多个 async 谁先下完谁先跑,不保证顺序);defer 等 HTML 解析完再按书写顺序执行(在 DOMContentLoaded 之前)。要顺序、要操作 DOM 的用 defer;纯独立的埋点/统计用 async


10. 一句话收尾

如果这篇长文只能留下一句话,那就是:

一个 HTTP 请求的一生,大部分时间不是在「算」,而是在「等」——等 DNS 查完、等握手来回、等阻塞的资源下完。每一次网络往返(RTT)都是一堵受光速限制、花钱也买不穿的墙。所以从 DNS 预解析、连接复用、TLS 1.3、HTTP/3,到 CDN 就近接入、缓存、defer——这条链路上几乎所有优化,本质都是同一件事:减少往返的次数,或者把往返藏在用户察觉不到的地方。

下次再被问到「输入 URL 之后发生了什么」,你不用背流程了——顺着「解析地址 → 建立连接 → 收发数据 → 渲染画面」这四步走,每一步问自己「这里有几个 RTT,能不能省」,整条链路和它的优化就都长在同一棵逻辑树上了。