Hi, It's Ivan here!
Writing 从 URL 到页面渲染

从 URL 到页面渲染

浏览器如何把一段 URL 变成屏幕上的像素:从地址解析、DNS、TCP、TLS、HTTP,一直讲到 DOM、CSSOM、布局、绘制与合成。

在地址栏输入下面这段 URL 并按下回车:

https://www.example.com:443/products?id=42#reviews

几百毫秒后页面就可能出现在屏幕上了。但这中间发生的事情,远比”发个请求、显示一份 HTML”要复杂得多。浏览器得理解地址、检查缓存、查域名、建安全连接、收发 HTTP 消息;请求可能途经 CDN、网关,再到应用程序才开始返回内容;拿到第一批字节后,浏览器又要一边下载一边解析 HTML、CSS 和 JavaScript,最后经过样式计算、布局、绘制、栅格化与合成,屏幕上才真正出现像素。

下面这张图先把整条链路放在一起看。实际访问未必会经过所有节点——缓存命中、连接复用、Service Worker 或页面内跳转都会让浏览器跳过其中的若干步骤。

从 URL、缓存、DNS、建连、HTTP、解析到像素的完整流程

浏览器先拆解 URL

URL 不是一整块没有结构的字符串。浏览器会先按 URL 语法把它拆成几个部分:

https://www.example.com:443/products?id=42#reviews
└─┬─┘   └──────┬──────┘└┬┘└───┬───┘└─┬──┘└──┬──┘
 协议          主机名     端口   路径     查询   片段

https 决定使用 HTTPS 协议。www.example.com 是待解析的主机名。端口没写的时候,HTTPS 默认 443,HTTP 默认 80。路径和查询参数共同确定请求目标。

片段标识符 #reviews 只在浏览器内部用,不会随 HTTP 请求发给服务器。服务器收到的请求目标是 /products?id=42,它根本不知道地址栏里还有个 #reviews。文档加载完之后,浏览器再尝试滚动到 id="reviews" 的元素。

如果输入内容不像 URL,浏览器也可能把它当搜索词处理。这属于地址栏的产品逻辑,跟网页加载协议无关,这里不展开。

并非每次回车都会发起完整导航

浏览器会先判断这次操作属于哪种导航。

如果只有片段发生变化,比如从 page.html#intro 变成 page.html#reviews,这就是同文档导航。浏览器更新历史记录、触发相关事件、滚动页面,不需要重新请求文档。

如果协议、主机、端口、路径或查询参数变了,浏览器就要加载新文档。现代浏览器都是多进程架构:地址栏和标签页外壳由浏览器进程管理,网页的 HTML、CSS 和 JavaScript 在受限的渲染进程里运行,网络请求又由专门的网络服务统一处理。具体的进程划分因浏览器而异,但核心思路是把网络权限和不可信的网页代码隔离开。

在真正发请求之前,浏览器还会做安全检查。比如域名命中了 HSTS 规则,原来的 http:// 地址就会在本地直接升级成 https://,省去先发一次明文请求再等服务器重定向的开销。

缓存和 Service Worker 可能提前给出答案

一次导航不一定马上进入公网,浏览器手里可能已经有可用的响应了。

Service Worker 是站点安装在浏览器里的请求代理。如果当前页面在它的作用域内,且 Service Worker 已经激活,它就能拦截导航请求——可以从 Cache Storage 读一份离线页面,也可以自己访问网络后组装响应。缓存优先还是网络优先,由站点代码自己决定。不过首次访问时还没有可控制页面的 Service Worker,所以它并不是所有导航的固定前置步骤。

浏览器自身的 HTTP 缓存则依据响应头工作,常见的有两种情况:

  • 响应还在新鲜期内,浏览器直接复用缓存,不访问服务器。这就是强缓存,主要由 Cache-Control: max-age=...Expires 控制。
  • 缓存过期了,但带有 ETagLast-Modified。浏览器发 If-None-MatchIf-Modified-Since 去问一下,服务器确认内容没变就返回 304 Not Modified,浏览器继续用本地的响应体。这就是协商缓存。

内存缓存、磁盘缓存、预加载缓存和后退前进缓存(bfcache)是不同的机制。它们是否参与某次导航、查询顺序怎样,都属于浏览器的实现细节,别把它们想象成一张从上到下固定查询的列表。bfcache 甚至会把整个页面状态留在内存里,用户点后退时,浏览器可以直接恢复文档和 JavaScript 堆,根本不走解析和渲染流程。

DNS 把主机名变成可连接的地址

网络层需要 IP 地址才能发数据。URL 里用的是域名,浏览器就得查对应的 IPv4 或 IPv6 地址;如果 URL 里写的就是 IP,这一步直接跳过。

浏览器先查自己和操作系统已有的 DNS 缓存。没命中的话,操作系统把问题交给配置好的递归解析器——这个解析器可能来自路由器、运营商、企业网络或公共 DNS 服务,也可能走 DNS over HTTPS 或 DNS over TLS 做加密查询。加密 DNS 改变的只是查询的传输方式,域名的层级委派关系并没有变。

递归解析器自己也没有缓存时,就沿着 DNS 层级一路往下查:

  1. 先问根域名服务器,拿到顶级域名服务器的线索。
  2. 再问 .com 之类的顶级域名服务器,拿到该域名权威服务器的线索。
  3. 最后问权威域名服务器,取得 AAAAACNAME 等记录。
  4. 如果拿到的是 CNAME,还要继续解析它指向的规范域名,直到拿到可连接的地址。

DNS 递归解析器依次查询根、顶级域和权威域名服务器

图中绿色箭头是查询,黄色箭头是答复。根服务器和顶级域服务器返回的是下一层的委派信息,最终的地址记录由权威服务器给出。递归解析器按记录的 TTL 缓存结果,然后把 IP 地址交还给浏览器。

一个域名经常同时返回好几条 IPv4 和 IPv6 地址。它们可能对应不同的 CDN 边缘节点,也可能用于负载均衡和故障切换。浏览器可以并行或错开尝试多个候选地址,挑最快建立成功的那个连接。DNS 给出的 IP 也不是”网站那台唯一服务器”的永久地址,它只代表当前这次查询把客户端引向了哪里。

拿到目标 IP 后,操作系统根据路由表选网络接口和下一跳。本地网络还可能通过 ARP 或 IPv6 邻居发现找到网关的链路层地址。数据包接下来经过无线接入点、路由器、运营商网络,在多跳路由中抵达 CDN 或源站。这一段对浏览器来说是透明的,但每一跳的排队、丢包和物理距离都会体现在延迟上。

HTTPS 连接要解决可靠传输与身份验证

知道 IP 只意味着知道数据该往哪发。浏览器还需要一条能承载 HTTP 消息的连接。

HTTP/1.1 和 HTTP/2 跑在 TCP 之上。新建 TCP 连接需要三次握手:

浏览器                              服务器
   | -------- SYN -----------------> |
   | <----- SYN + ACK -------------- |
   | -------- ACK -----------------> |

SYN 用来发起连接并同步序列号,ACK 确认收到了对方的数据。握手完成后双方就有了一条可靠、有序的字节流。TCP 还负责重传丢失数据、流量控制和拥塞控制。

HTTPS 还得在 TCP 之上做 TLS 握手。拿 TLS 1.3 的完整握手来说,客户端先发 ClientHello,带上支持的协议版本、密码套件、密钥交换参数、SNI 和 ALPN 协议列表。服务器回传自己的选择、证书和握手证明。浏览器验证证书链能不能追溯到受信任的根证书、有没有过期、域名是否匹配,还要验证服务器确实持有对应的私钥。双方从密钥交换结果导出会话密钥,之后的 HTTP 数据才开始加密传输。

ALPN 在 TLS 握手中协商上层协议,h2 代表 HTTP/2,http/1.1 代表 HTTP/1.1。如果服务器没法提供双方都支持的协议,连接就会失败或退回到可用的方案。浏览器也会复用已建立且满足安全条件的连接,所以同一站点的后续请求不一定每次都重新跑 TCP 和 TLS 握手。TLS 会话恢复还能进一步减少回访时的握手开销。

TCP、TLS 与 HTTPS 请求响应的顺序关系

这张图表达的是协议层次和先后关系,没有展开 TLS 1.3 每条握手消息的细节。HTTP/3 走了另一条路:它把 HTTP 语义放在 QUIC 之上,QUIC 用 UDP 承载数据,并把 TLS 1.3 握手整合进连接建立过程,所以不存在一段独立的 TCP 握手。UDP 本身不保证可靠性,可靠传输、拥塞控制和多路复用都由 QUIC 在用户态实现。

三代 HTTP 在连接层面的主要差别:

协议常见传输方式并发请求丢包的主要影响
HTTP/1.1TCP,HTTPS 时再加 TLS一条连接按顺序处理;浏览器常开多条连接来增加并发会阻塞该 TCP 连接后续字节
HTTP/2一条 TLS + TCP 连接上的多个流多个请求和响应可交错传输TCP 层丢包时,同一连接上的所有流都得等缺失字节
HTTP/3QUIC + UDP,安全握手内置多个独立 QUIC 流一个流丢包不影响其他流交付已有数据

HTTP/2 解决了 HTTP 层的队头阻塞,但 TCP 必须按序交付整条字节流,这个限制还在。HTTP/3 把不同请求放在独立的 QUIC 流里,从传输层面减少了队头阻塞。至于实际用哪个版本,取决于网络是否放行 UDP、服务端有没有开 HTTP/3、浏览器之前有没有收到过 Alt-Svc 等信息。

HTTP 请求描述浏览器想要什么

安全连接建好之后,浏览器开始构造 HTTP 请求。用 HTTP/1.1 的文本格式表示大概长这样:

GET /products?id=42 HTTP/1.1
Host: www.example.com
Accept: text/html,application/xhtml+xml
Accept-Encoding: gzip, br
Cookie: session=...
If-None-Match: "page-v8"
Sec-Fetch-Dest: document

请求行包含方法、目标和协议版本。导航用的是 GET,表达”取得这个资源的表示”。Host 指明目标主机,让同一个 IP 和端口可以承载多个站点。Accept 告诉服务器客户端能处理哪些内容类型,Accept-Encoding 表示支持的压缩算法。Cookie 是否附带,要看域名、路径、有效期、SecureSameSite 等规则。前面提到的 #reviews 不会出现在请求中。

HTTP/2 和 HTTP/3 不用这种纯文本报文,而是走二进制帧和压缩后的字段,但 GET、状态码、请求头、缓存这些语义基本一样。

请求在到达业务应用之前,可能先过一遍代理、CDN、WAF、负载均衡器或反向代理。CDN 如果缓存命中就直接返回内容;没命中的话,请求继续前往源站,由应用程序查缓存或数据库、调内部服务、跑模板,最终生成 HTML。

服务端不一定要把整份 HTML 都生成完才开始返回。流式服务端渲染可以先发响应头和页面外壳,再逐步发后续片段,让浏览器更早开始解析。不过提前发一段没有实际内容的字节,并不会自动让用户更快看到页面。

HTTP 响应带回状态、元数据和正文

服务器返回的响应也由状态信息、字段和正文组成:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Encoding: br
Cache-Control: public, max-age=300
ETag: "page-v9"

<!doctype html>
<html>...</html>

200 表示成功。Content-Type 告诉浏览器正文是 HTML,并给出字符编码。Content-Encoding 说明正文用了 Brotli 压缩,浏览器要先解压。缓存相关的字段决定这份响应以后能不能复用。

浏览器也可能收到别的结果:

  • 301302307308 配合 Location 触发重定向。浏览器解析新 URL,重新走安全检查、缓存、DNS 和连接步骤。
  • 304 表示条件请求验证通过,正文不再重传,浏览器用已有缓存。
  • 204 表示没有响应正文。
  • 4xx5xx 仍然是正常的 HTTP 响应,浏览器会根据内容类型显示服务器返回的错误页面,或者在没有可显示内容时呈现自带的错误界面。

HTTP 消息在网络上会被拆成很多包,响应正文也可能分好几帧到达。浏览器不用等到最后一个字节才开始工作——只要响应头通过检查、收到了一部分正文,解析器就可以开始消费解压、解码后的字符流了。

HTML 是边下载边解析的

浏览器先根据 HTTP 头、BOM 和文档中的 meta charset 确定字符编码,把原始字节解码成字符。HTML 分词器再根据当前状态识别开始标签、结束标签、属性和文本等 token,树构建器拿这些 token 来建 DOM。

这个过程不是拿字符串切割标签那么简单。HTML 解析器有一套明确的状态机和错误恢复规则:缺失的 htmlheadbody 元素会被自动补上,错误嵌套的标签也可能被重新排列。所以最终的 DOM 不一定和源代码逐字符对应。

解析器碰到新的资源引用就把请求交给网络层。浏览器还有预加载扫描器——在主解析器被脚本阻塞的时候,它会继续往前扫描,尽早发现样式表、脚本、图片和字体。preloadfetchpriority、资源类型、视口位置以及浏览器自己的调度策略,会一起影响请求的优先级排序。

脚本会不会阻塞解析

脚本标签的写法决定了下载和执行的时机:

<script src="classic.js"></script>
<script async src="analytics.js"></script>
<script defer src="app.js"></script>
<script type="module" src="main.js"></script>
  • 普通外部经典脚本会暂停 HTML 解析,等脚本下载并执行完才继续。它可以调 document.write(),也可能读写解析器刚建好的 DOM。
  • async 脚本并行下载,下好就执行,执行时仍会打断解析。多个 async 脚本不保证按文档顺序执行。
  • defer 脚本并行下载,等 HTML 解析完再按文档顺序执行,在 DOMContentLoaded 之前跑完。
  • 模块脚本默认行为类似 defer,还要解析和加载依赖的模块图。加上 async、动态导入或顶层 await,时序又会有变化。

内联经典脚本不用下载,但解析器还是得停下来执行它。脚本跑得久的话,DOM 构建和首次渲染都会被推迟。

CSS 不阻塞 HTML 解析,但会阻塞渲染

HTML 解析器碰到 <link rel="stylesheet"> 后会继续构建 DOM,同时下载并解析 CSS。但浏览器要等关键样式表形成 CSSOM 之后才敢做首次渲染——不然页面先以无样式状态闪一下,再突然变样,体验很差。

样式表还会间接拖慢脚本。如果一个同步脚本排在样式表后面,又要读取元素尺寸或计算样式,浏览器就得保证前面的样式已经就绪,于是脚本执行可能被 CSS 下载卡住。

图片一般不阻塞 DOM 构建。但没有明确宽高的图片加载完成后可能撑开布局,造成页面跳动。字体也是由 CSS 和实际用到的文本触发加载,字体替换策略会影响文字何时可见,以及替换时是否发生布局偏移。

DOM 和 CSSOM 共同决定渲染对象

DOM 保存文档的内容与层级。CSS 解析器把样式规则组织成 CSSOM。浏览器做级联计算时,要综合样式来源、选择器匹配、层叠层、优先级、继承和源码顺序,算出每个元素的计算样式。

浏览器根据 DOM 和计算样式建立内部渲染结构。很多资料管它叫 Render Tree,但实际浏览器内部会拆成更细的布局树、绘制属性树和图层结构。这个渲染结构不是 DOM 的一一映射:

  • headscript 这类没有视觉盒子的节点不会出现在布局结果里。
  • display: none 的元素不参与布局,也不绘制。
  • visibility: hidden 的元素仍然占位,只是内容不可见。
  • ::before::after 等伪元素可以生成可见内容,但在 DOM 里没有对应节点。

HTML 与 CSS 形成 DOM 和 CSSOM,再经过布局、绘制和合成得到像素

图中的 JavaScript 扳手表示脚本可以修改 DOM 或样式。每次修改不一定让整条流水线全部重跑——浏览器会记录哪些部分失效,尽量只更新受影响的节点、区域或图层。

从渲染对象到屏幕像素

浏览器把页面画到屏幕上,大致可以拆成这么几步。

样式计算

浏览器匹配选择器,算出每个元素最终用什么样式。改 class、插入节点、改视口尺寸、加载新样式表,都可能让一部分样式失效需要重算。选择器匹配和继承关系越复杂,重算范围越大。

布局

布局根据视口、盒模型、普通文档流、Flexbox、Grid、定位、文字度量等规则,算出每个盒子的尺寸和位置。父元素宽度一变,下面很多后代的换行和高度都可能跟着变;网页字体到达后文字也可能重新排版。

布局的依赖关系很多,浏览器会推迟并批量处理修改。但如果 JavaScript 刚写了样式,紧接着就读 offsetWidthgetBoundingClientRect() 或某些计算样式,浏览器为了返回正确的值,就不得不立刻跑一遍尚未执行的样式计算和布局。这种读写交替的代码会导致布局抖动(layout thrashing)。

绘制

布局只回答了元素在哪、有多大。绘制阶段把背景、边框、文字、阴影和图片等视觉操作整理成绘制记录,按正确顺序处理层叠与裁剪。某块区域的颜色或阴影变了,需要重绘受影响的区域,但不一定要重新布局。

栅格化与合成

绘制记录还不是屏幕上的位图。浏览器把页面或图层切成若干瓦片,由 CPU 或 GPU 把绘制指令栅格化成像素纹理。合成线程再根据每个图层的位置、裁剪、透明度和变换,把纹理拼成最终帧交给操作系统显示。

浏览器按需创建合成图层,不是每个 DOM 元素都有独立图层。transformopacity 动画在条件合适时只改合成参数,不必重新布局和绘制,所以动画会更流畅。但图层太多也会占额外显存和管理开销,别把强制提升图层当成万能优化。

JavaScript、事件循环和渲染帧共享时间

网页加载期间,网络、解析和脚本执行并不是排成一条队依次进行的。下载和部分解析可以在其他线程或进程里跑,但 DOM 操作、大部分 Web API 回调和页面生命周期事件最终都要跟渲染主线程协调。

浏览器通过事件循环来安排这些工作。一轮循环大致是:取一个任务执行,清空微任务队列,在合适的时机处理动画回调和渲染更新,然后进入下一轮。计时器、用户输入、网络回调和消息事件产生任务;Promise 回调进入微任务队列;requestAnimationFrame 回调安排在下次渲染更新前执行。

但这不是一份每轮都机械执行的固定清单。浏览器会根据页面是否可见、屏幕刷新率和性能状态决定要不要更新渲染,后台标签页还会被节流。关键限制在于:主线程同一时刻只能干一件事,要么执行 JavaScript,要么处理渲染工作。一个跑了 200 毫秒的任务,输入响应和下一帧画面都得等它结束。

DOM 修改经常只是先记下来,等当前 JavaScript 任务结束后再统一做样式计算、布局和绘制,这样多次改动可以合并。但强制同步布局会打断这种批处理,迫使浏览器在脚本执行中途提前完成渲染工作。

页面”加载完成”有好几种说法

用户看到内容、DOM 可操作、所有资源下载完——这三件事不是同一时刻发生的。

DOMContentLoaded 在 HTML 解析完成、延迟脚本和模块脚本执行到满足条件后触发,它不等图片下完。样式表虽然不是这个事件直接等待的对象,但它可能因为阻塞了相关脚本而间接推迟事件触发。

load 事件要等文档和大部分依赖资源加载完成,包括样式表、脚本、图片和 iframe。标了 loading="lazy" 的资源不一定在 load 之前完成。广告、分析请求、长连接、按需加载的模块和用户操作触发的请求还会在 load 之后继续进行,所以网络面板不一定会真正归零。

性能指标从用户体验的不同角度观察这条时间线:

指标它观察什么
DNS、连接、TLS 时间找地址和建立安全连接花了多久
TTFB从导航请求开始到收到响应首字节的耗时
FCP页面第一次绘制出文字、图片或其他内容的时间
LCP视口内最大内容元素完成绘制的时间
CLS页面可见元素发生了多少非预期位移
INP用户交互到下一次画面反馈之间的延迟

TTFB 很短不代表页面很快能用——大量阻塞脚本仍可能把渲染卡住。load 很晚也不一定说明主要内容出现得晚,可能只是还在下载一张无关紧要的图片。分析性能时,要把网络阶段、主线程工作和用户真正看到的内容拉到同一条时间线上一起看。

服务端渲染、客户端渲染与 Hydration

传统服务端渲染返回的 HTML 里已经有内容。浏览器解析完就能较早绘制出文字和结构,JavaScript 随后再绑定交互。

纯客户端渲染的初始 HTML 可能就一个根节点加几个脚本引用。浏览器要先下载、解析、执行 JavaScript,再请求数据、创建 DOM,主要内容才会出现。请求数量少不等于路径短——一份体积很大的脚本会把等待时间从网络阶段搬到主线程上。

很多框架采用服务端渲染加 Hydration 的方式:服务器先给出可显示的 HTML,客户端 JavaScript 再接管这棵 DOM,建立组件状态和事件处理。页面看起来已经完整的时候,Hydration 可能还没跑完,这段间隔里按钮能不能响应点击,取决于框架的事件重放和调度策略。

流式渲染、局部 Hydration 和服务端组件在继续拆分这条路径。它们改变的是内容、代码和交互分别在什么时间到达客户端,但浏览器最终该做的解析、样式计算、布局和绘制工作一样也不会少。

把整条时间线串起来

以一次没有缓存、没有可复用连接的 HTTPS 文档导航为例,大致经过这些步骤:

  1. 浏览器解析 URL,判断这是新文档导航,做本地安全检查。
  2. Service Worker 和 HTTP 缓存都没有直接返回可用响应,请求继续走网络。
  3. 查 DNS,拿到一个或多个候选 IP 地址。
  4. HTTP/1.1 或 HTTP/2 先建 TCP 再做 TLS;HTTP/3 通过 QUIC 建连,TLS 握手集成在里面。
  5. 发送文档请求。CDN、代理和应用服务器处理请求后开始返回响应。
  6. 浏览器检查响应头,解压解码正文,把字节流交给 HTML 解析器。
  7. HTML 解析器建 DOM,CSS 建 CSSOM,预加载扫描器并行发现子资源,脚本按各自规则下载和执行。
  8. 样式计算、布局完成后,绘制指令栅格化,合成器生成屏幕帧。
  9. 后到的图片、字体、脚本和数据继续更新页面。事件循环在 JavaScript、输入和渲染工作之间调度时间。

这个顺序是为了建立整体认知。真实场景里很多步骤是重叠进行的:DNS 查询可能和其他域的连接并行,HTML 没下完就开始解析了,CSS 下载时 DOM 还在增长,图片解码和栅格化也可能分散到工作线程。性能优化的重点往往不是让某个单独的步骤变快,而是缩短用户所需内容通过这条依赖链的总时间。

参考资料

  1. HTML Standard:解析、脚本与事件循环
  2. RFC 9110:HTTP Semantics
  3. RFC 9111:HTTP Caching
  4. RFC 9113:HTTP/2
  5. RFC 9114:HTTP/3
  6. RFC 9000:QUIC
  7. RFC 8446:TLS 1.3
  8. Chromium:多进程架构
  9. web.dev:Critical Rendering Path