假设现在是早上九点半,你在电脑前坐定,手指在键盘上敲下一个网址,按回车,页面几乎是瞬间就弹了出来。白屏一闪,Logo出现,首屏内容铺满屏幕,整个过程快到你几乎感受不到任何延迟。但就在这不到一秒的时间里,你的电脑已经悄悄完成了一整套精密而复杂的动作:解析域名、建立连接、发送请求、等服务端返回字节流、解析HTML、构建渲染树、完成首屏绘制。
作为一个常年跟前端性能和网络问题打交道的人,我越来越觉得这条链路值得每个人认真过一遍。它不只是面试官最爱问的经典题,更是日常排查线上问题的基础功。不管你做前端、客户端还是后端,只要遇到首屏变慢、白屏、接口异常、资源加载失败这类问题,最终都要回到这条链路上来拆。今天这篇,我就从底层网络开始,一路聊到浏览器渲染管线的最后一个像素,把从 URL 输入到首屏渲染的全过程拆开揉碎,顺便把那些藏在细节里的坑也一并挖出来。
1. 地址栏的“小心思”:URL 在到达网络前经历了什么
很多人以为在地址栏里输入一串网址、按回车间隔里的时间可以忽略不计,但实际上,第一个关键环节已经在这里发生了。浏览器地址栏并不是一个诚实的传输管道,它会自作主张地做很多预处理。这一步没搞明白,后面所有环节都可能被带偏。
1.1 你输入的真的是 URL 吗
先问一个问题:当你在地址栏输入“baidu.com”的时候,浏览器到底是把它当 URL 访问,还是当搜索关键词?
答案是:都有可能。现代浏览器默认开启了“搜索与地址栏合一”的功能,输入的内容会先进入一个智能判断流程。Chrome 的做法是:如果输入的内容看起来像一个 URL(包含点号、斜杠、协议头、端口等特征),就走访问逻辑;否则就当作搜索词,直接拼上默认搜索引擎的地址发请求。这也是为什么你在地址栏输入“hello world”时,会直接跳到搜索结果页,而不是某个域名解析失败页面。
这个判断过程会引发一个让人很困惑的现象:有时你输入“localhost:8080”这种本意是访问本地服务的地址,浏览器却可能会因为某些设置把它当成搜索词。我碰到过不止一次,同事跑来问为什么访问本地一下就跳到百度了,一查是输入法把冒号自动改成了全角符号,浏览器根本没认出这是个 URL。
所以准确地说,从键盘输入到真正发起网络请求,这中间浏览器实际上做的是“URL 识别 + 补全 + 规范化”三步。
1.2 URL 规范化:浏览器悄悄给它“整容”
一旦判定这是合法的 URL,浏览器就会开始规范化处理,主要动作包括:
- 补全协议头:输入“www.example.com”时,浏览器会默认加上
http://或https://,具体加哪个由浏览器策略和 HSTS 规则决定。 - 拼接路径:如果 URL 没有路径,通常会自动补成
/。 - 处理默认端口:http 默认 80,https 默认 443,浏览器会隐式带上。
- 域名大小写归一化:域名部分不区分大小写,但路径是区分大小写的,系统会自动统一域名为小写。
- 非 ASCII 域名转换:中文域名会被转成 Punycode 编码,比如
例子.测试变成xn--fsqu00a.xn--0zwm56d。这个转换非常重要,因为 DNS 根本不认识非 ASCII 字符。
具体到浏览器实现层面,这个过程会对 URL 做解析、正则校验、编码等操作。比如 Chrome 内部会调用 Google URL 库来做标准化的流程,把不规范的 URL 处理成标准 URL 之后才会交给下一步。
1.3 URL 编码与解码:特殊字符的生存法则
说到 URL 规范化,就绕不开 URL 编码。这是整个链路里最容易踩坑、又经常被忽视的环节。URL 本身只允许一小部分 ASCII 字符出现,字母、数字以及-_.~等保留字可以直接使用,其余字符必须转成百分号编码(Percent-encoding)才能在 URL 里安全传输。比如空格会被编码成%20,中文会被编码成 UTF-8 字节后加百分号。
热词里出现了一堆%3a%2f%2f之类的字符串,这就是:/://的百分号编码表示。很多带回调逻辑的页面 URL 里会嵌一个完整的子链接,为了保证这个子链接里的特殊字符不被外层 URL 误解析,就必须先对它做编码,相当于给包含特殊字符的子串穿上一层“防护服”。前端如果忘了编码,直接做window.location.href = 'https://api.example.com/callback?target=' + targetUrl,碰到 targetUrl 里带&或?时,参数会被截断,服务端收到的参数就残缺了。正确做法是先用encodeURIComponent(targetUrl)编码,再拼到链接里。
这里有个常见认知误区:encodeURIComponent和encodeURI并不等价。encodeURI不会编码:/?&=#这类 URL 结构字符,适合编码整个 URL;而encodeURIComponent会把这些全部编码,适合编码某个参数值。我在对接第三方登录时踩过这个坑,回调地址没正确编码,服务端验收签名时死活校验不过,最后一层一层排查才发现是参数里多了个&导致签名串被截断。
在服务端处理链路中,URL 解码同样要留意。不同的 Web 框架默认的解码策略可能不一样,有的按 UTF-8 解码,有的按 ISO-8859-1,碰到中文参数会出现乱码。更隐蔽的是“二次解码”问题:经过反向代理或网关多层转发时,每一层都可能对 URL 做一次解码,原始参数里的%可能被层层解开,导致最终收到的内容和你编码前的不一致。我自己处理过一个线上事故,请求参数里含%2F,经过一层 Nginx 之后被解码成了/,结果路由把参数当成了路径,直接 404。
所以关于 URL 编码,记住一句经验:越靠近用户侧越要尽早编码,服务端只解码一次,网关层如果不做特殊处理,不要手动二次解码。规范化完成、编码正确之后,这个 URL 才真正准备好进入网络链路。下一步等待它的,就是域名解析。
2. 域名解析:一场遍布全球的接力跑
输入 URL 之后,浏览器首先要做的是找到目标服务器的 IP 地址。因为 URL 里的主机名对人类友好,但对网络底层来说毫无意义。IP 才是网络层的地址。这个过程叫 DNS 解析,本质上是一个多级缓存的命中与全球数据库的递归查询。
2.1 四级缓存:浏览器、操作系统、路由器、ISP
DNS 解析的第一步不是去问根服务器,而是先翻“本地存货”。浏览器里有自己的 DNS 缓存,Chrome 默认缓存时长约在 60 秒到几分钟之间,可以通过chrome://net-internals/#dns查看。如果没命中,就去操作系统层的 DNS 缓存查询,Windows 上可以用ipconfig /displaydns查看。还没命中,则查找 hosts 文件(macOS/Linux 是/etc/hosts,Windows 是C:\Windows\System32\drivers\etc\hosts)。接下来是路由器缓存,通常由本地路由器(如家用宽带路由器)维护一条近期解析记录。
这个四级缓存的顺序非常重要。每一层缓存命中都会直接省掉一次网络往返(RTT),所以很多性能分析工具在评估 DNS 解析耗时的时候,会区分“缓存命中耗时”和“实际递归查询耗时”。后者通常在几十毫秒到几百毫秒之间,前者几乎为零。
我在排查线上问题时经常看到一种假象:开发者的电脑上域名解析特别快,一查全是缓存命中,于是他们觉得 DNS 没问题。但真实用户在冷启动或缓存过期状态下,DNS 解析可能消耗好几百毫秒,拖慢首屏。这就是为什么很多站点会主动加上dns-prefetch预解析提示,让浏览器提前把用到的域名解析好。
2.2 递归查询与迭代查询:全球信息如何被找到
如果宿主机的缓存全部落空,浏览器就会向系统配置的 DNS 服务器发起查询,通常是运营商或者公共 DNS 服务商提供的 IP。这个服务器会充当递归解析器,替你一层一层去问。
递归解析器首先会把请求发给根域名服务器,根服务器会告诉你“.com的权威服务器在哪些 IP”。然后递归器去问.com的权威服务器,对方会告诉你“example.com的权威服务器在哪些 IP”。最后递归器去问example.com的权威服务器,对方才会返回最终的 A 记录(IPv4 地址)或 AAAA 记录(IPv6 地址)。这个过程中,域名信息从根到顶级域再到权威域,是一层层的树状结构,整个机制可以类比成:你向总台问某个部门的具体负责人,总台让你去找部门负责人,部门负责人再告诉你具体是谁。
每一层答复都会在递归器里缓存下来,TTL(生存时间)越长,缓存命中率越高,下次解析越快。但 TTL 也不是越长越好,因为太长的 TTL 意味着域名对应的 IP 变更后,用户还要很久才能拿到新地址。CDN 服务商通常会把记录切分成短 TTL 来保证调度的灵活性。
2.3 CDN 与就近调度:为什么同一个域名在不同城市解析出的 IP 不同
这是很多初学者的知识盲区。他们以为一个域名永远只对应一个 IP。实际上,www.baidu.com在不同地区会解析出完全不同的 IP 地址,因为 DNS 本身就是 CDN 最重要的流量调度手段。
当你拿着域名的 A 记录去问递归解析器时,CDN 厂商在权威服务器上配置了智能解析策略,它会看到递归解析器的 IP 地址——也就是你所在网络出口的运营商和大致地理位置,然后据此返回离你最近的边缘节点 IP。这样用户访问时能直接命中距离最近的 CDN 节点,把网络延迟降到最低。
这个机制对首屏渲染影响巨大。如果 DNS 解析返回的距离你很远,或者错误地指向了跨运营商节点,首屏加载的每一笔资源请求都要多跑几十甚至上百毫秒的网络延迟。这也就是为什么做性能评测时,要在不同地区、不同运营商网络上分别测试,而不是只在公司同一网络里测。
2.4 实战排错:解析慢、被改、失败的排查方法
我个人的排查经验里,DNS 问题大概分三类:
- 解析慢:用
dig example.com或nslookup example.com查看具体耗时,逐步检查系统的 DNS 服务器地址以及 TTL 设置。通过dig example.com +trace可以观察完整链路。 - 解析结果不对(被污染或劫持):在本机解析出的 IP 和
dig @8.8.8.8 example.com这个公共 DNS 服务查到的结果不一致,基本可以判定是本地网络或运营商侧出了问题。2024 年很多地区开始支持 DoH(DNS over HTTPS),浏览器里的“安全 DNS”开启后就能用加密通道发 DNS 请求,大幅降低被篡改的概率。 - 解析失败:大多数是域名过期、权威服务器异常、网络出口的 DNS 端口被限制。用
dig观察返回状态,NOERROR是正常,NXDOMAIN是域名不存在,SERVFAIL是权威侧故障。这种需要去域名注册商后台检查解析记录,或者联系权威服务器维护方。
如果你在 Chrome 的开发者工具里看到某个请求的Timing面板显示Stalled很长,或者DNS Lookup时间异常大,优先去检查这些 DNS 环节。解析成功后,浏览器拿着 IP 地址发起连接,就进入了下半场的硬核网络环节。
3. 三次握手不是终点:TCP 与 TLS 的底层关系网
现在浏览器已经从 DNS 拿到了服务器的 IP 地址,接下来要建立一条可以传输数据的通道。绝大多数 Web 流量走的是 TCP 协议,在 TCP 之上,还有一层用来加密数据的 TLS 协议。很多人把这两层揉在一起讲,但真正排查问题的时候,得把它们拆开看。
3.1 TCP 三次握手与连接成本
TCP 三次握手想必大家都背过:客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK。但真正要理解的是它的成本到底有多高。
每次往返有一个术语叫 RTT(Round-Trip Time,往返时延),指的是从一端发出数据到收到另一端确认消息所需的时间。在局域网里 RTT 可能只有 1 毫秒不到,在跨地域的互联网上,RTT 通常在 20 到 100 毫秒之间。三次握手需要 1.5 个 RTT:第一次 SYN 发出到收到 SYN+ACK 是 1 个 RTT,然后发送 ACK 不需要等到服务端的响应,剩下的 0.5 个 RTT 是为了数据传输做准备。
也就是说,仅仅建立 TCP 连接,就要消耗至少一个完整的网络往返。如果这个连接还牵扯到跨地域、跨运营商,一次握手的光延迟就可能让用户等上几百毫秒。更糟的是,如果网络丢包严重,TCP 重传机制会让这个过程雪上加霜。
有一个很有用的 TCP 特性叫 TCP Fast Open(TFO),它允许在握手阶段就携带应用数据,把首包数据的送达时间压缩到 0 RTT 内。但 TFO 需要客户端和服务端同时支持,而且需要双方第一次连接时拿到 Cookie,实际使用率并不高。
3.2 TLS 握手:TLS 1.2 的 2 个 RTT vs TLS 1.3 的 1 个 RTT
现代 Web 几乎全部使用 HTTPS,这意味着在 TCP 连接建立之后,还要进行 TLS 握手来协商加密密钥、验证服务器证书。这里有一个非常关键的性能点:TLS 1.2 的完整握手需要 2 个 RTT,TLS 1.3 把它压缩到了 1 个 RTT。
TLS 1.2 的流程大致是:客户端发送 ClientHello 和支持的加密算法列表,服务端返回 ServerHello、证书链、密钥交换参数,客户端验证证书并发送自己的密钥交换参数,最后双方生成会话密钥并握手完成。问题在于这个流程需要客户端和服务端之间来回两趟,也就是 2 个 RTT。在 TLS 1.3 里,流程被大幅简化:客户端在第一次消息里就带上自己的密钥共享参数,服务端一次性返回证书和密钥协商结果,整个握手只需要 1 个 RTT,而且还能实现 0-RTT 的会话恢复,让重访的客户端直接发送应用数据。
关注这些数字的意义在于:衡量一个站点的连接建造成本,不能只算 TCP 的 1.5 RTT,而要把 TLS 的 RTT 加进去。HTTP/1.1 + TLS 1.2 场景下,第一次请求发出前,光连接建立就需要至少 3.5 个 RTT——TCP 1.5 加 TLS 2。如果网络跨地区 RTT 是 80 毫秒,那就是 280 毫秒的开销,几乎占了普通用户首屏时间预算的一半。这也是为什么 HTTP/2 和 TLS 1.3 的组合能带来巨大的性能提升,它能把这个成本砍到不到一半。
3.3 证书链、SNI 和常见握手失败
TLS 握手过程里还有一个容易被忽略的环节:证书链验证。浏览器收到服务端证书后,要向上追溯信任链,一直追溯到操作系统预置的根证书,确认证书没有被吊销、域名匹配、有效期正常。如果中间证书没有正确配置,很多浏览器会拒绝连接,报NET::ERR_CERT_AUTHORITY_INVALID。
另一个非常重要的机制是 SNI(Server Name Indication)。一台服务器上可能同时托管多个域名的 HTTPS 服务,TLS 握手阶段客户端必须通过 SNI 告诉服务器自己访问的是哪个域名,服务器才能调出对应的证书。老浏览器如果不支持 SNI 或者某些网络环境剥落了 SNI 字段,就可能出现“握手了但证书不匹配”的诡异现象。
我排查过一个线上问题:客户反馈某个机上偶发出现ERR_SSL_PROTOCOL_ERROR,抓包发现部分运营商宽带会对 TLS 握手包做了深度检查,导致 ClientHello 中途被丢弃。这种情况从服务器配置角度完全看不出来,需要靠 Wireshark 抓包才能定位。
3.4 连接复用与 HTTP/2:尽量让“一次握手”解放更多请求
TCP 和 TLS 握手成本高昂,所以 HTTP 协议在设计上天然要尽量复用连接。HTTP/1.1 的 Keep-Alive 允许一条 TCP 连接上跑多个请求,但同一时间只能串行处理一个请求,浏览器为了并行加载资源,通常会对同一域名开 6 条 TCP 连接。
HTTP/2 则引入了多路复用,在一条 TCP 连接上并发传递多个请求和响应。配合头部压缩(HPACK),能让同一连接里的请求-响应效率大幅提升。但它也不是没有副作用:由于所有资源都在一条 TCP 连接上传输,一旦中间某个包丢失,TCP 的拥塞控制会导致连接整体阻塞,即“队头阻塞”。HTTP/3 改用基于 UDP 的 QUIC 协议,从根上规避了 TCP 层面的队头阻塞,还能做到 0-RTT 建连。目前大型互联网公司的站点都纷纷上线 HTTP/3,也正是看中了这个性能收益。
连接建立完成之后,真正承载 URL 路径和参数的请求才会发出。从现在开始,这条链路从“网络层”进入了“服务端应用层”。
4. 服务端的路由、鉴权与响应:请求怎么变成 HTML 字节流
浏览器开始发送 HTTP 请求了——请求方法、目标路径、请求头、可选的请求体。这个请求会离开你的电脑,穿过路由器、运营商骨干网、云厂商的负载均衡器,最终抵达一台真实服务器。它可能已经在内部网关和微服务之间跳转了好几次,才真正生成页面所需的 HTML。
4.1 HTTP 请求的结构与常见请求头
一个典型的 HTTP 请求长这样:
GET /product/123?utm_source=nav HTTP/1.1 Host: www.example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Accept: text/html,application/xhtml+xml,... Accept-Encoding: gzip, deflate, br Cookie: session_id=abc123 Referer: https://www.example.com/每一行请求头都在向服务端传递信息。Host说明你要访问的域名,User-Agent标识客户端类型,Accept-Encoding告诉服务端你能接受什么压缩格式,Cookie携带会话身份,Referer告诉服务端你从哪个页面跳转过来。
服务端拿到请求后,最先经过的通常是 Nginx、OpenResty、网关这类七层负载均衡器。它根据Host和 URL 路径做路由分发。很多大型系统在这里会再做一层 URL 重写或缓存判断:如果命中 CDN 或网关缓存,就直接返回缓存内容,不再进入后端应用。
一个让我印象深刻的坑是:前端代码里硬编码了http://的接口地址,但页面是通过 HTTPS 加载的,结果所有请求都变成了“混合内容”(Mixed Content),被浏览器直接拦截。这类问题在控制台里的报错很不显眼,而且只在 HTTPS 环境下发生,很多人排查了半天都没想到是协议头写死了。
4.2 重定向:用户看不到的穿梭门
很多 URL 的表面路径只是入口,实际内容在另一个地址上。这时候服务端返回 301/302/303/307/308 状态码,并在Location响应头里告诉浏览器“你要找的东西在别处”。
重定向对首屏渲染来说是一把双刃剑。一方面,无重定向是最理想的状态;另一方面,某些场景必须靠重定向完成跳转,比如分享链接、二维码扫码后的调度逻辑。热词里很多dps://p?url=...开头的深链,就是典型的重定向链路:短链服务或请求调度中心,把用户请求从服务端转发到 H5 页面,再转入客户端 App 的指定界面。这类 URL 的结构里经常带着一长串经过 URL 编码的目标地址,作用相当于在 Web 世界和 App 内页之间安了一道中转门。
重定向最隐蔽的性能杀手是“重定向链过长”。现实中我见过一个分享链接经历三次 302:缩短链接 → 营销活动页 → 登录跳转 → 最终内容页。每多一次重定向,意味着浏览器需要重新发起一次完整请求,等于重新执行一遍之前的 URL 解析、连接建立流程。这个环节肉眼根本看不到,但对首屏的拖累非常大。所以 Web 性能优化的时候,有一个专项就是“减少重定向链”。
4.3 状态码与响应头:决定渲染方式的关键信息
浏览器收到响应之后,第一件事是看状态码和响应头,再决定接下来干什么。这个阶段常见的问题我在实际工作里遇到了太多,列几个最有代表性的:
502 Bad Gateway(错误网关):网关或负载均衡器无法从上游服务器获得合法响应。常见原因是后端服务挂了、超时、或者上游服务地址配错。热词里就出现了unexpected status 502 bad gateway: unknown error,这种底噪在服务端日志分析里很常见,排查第一步是确认上游实例的健康检查是否通过、应用进程是否 OOM。304 Not Modified(未修改):浏览器带上If-None-Match或If-Modified-Since,服务端判定资源没有变化后返回 304,浏览器直接使用本地缓存,省去下载体积。Token exchange failed(令牌交换失败):这类报错多见于单点登录或 API 鉴权场景,发生在应用层拿到授权码后去向认证服务换取访问令牌的过程中。热词里的token exchange failed: error sending request for url (https://auth.openai.co...)就是典型的凭证交换链路网络异常。排查时先确认认证服务是否可达,再检查回调地址和凭证信息是否匹配,这两个是最常见原因。
响应头里非常重要的一项是对压缩和缓存的控制。Content-Encoding: br表示响应经过了 Brotli 压缩,Cache-Control: max-age=...表示资源可以在浏览器本地缓存多长时间,ETag用于协商缓存。这些头直接决定了后续页面重新加载时要不要走网络,对二次访问的首屏速度影响巨大。
4.4 TTFB:等待第一个字节的时间,是服务端性能的温度计
从发起请求到响应头第一个字节到达浏览器的时间,叫 TTFB(Time to First Byte)。这是衡量服务端性能最直观的指标之一。TTFB 如果大于 500 毫秒,就要开始警觉;大于 2 秒,基本等于用户还没看到内容就想关页面了。
前面说得那么复杂的 TCP/TLS/TTFB 概念,到这里就有了实际的用武之地。在 Chrome DevTools 的 Network 面板里看某个请求的 Timing,会详细列出Stalled、DNS Lookup、Initial connection、SSL、Request sent、Waiting for server response各阶段耗时。我常用的排查路径是:
- 如果
Waiting for server response很长——问题在服务端,检查数据库查询、业务逻辑、网关超时配置。 - 如果
SSL很长——问题可能在证书链过长或握手往返次数太多,尝试升级到 TLS 1.3。 - 如果
Stalled很长——通常是浏览器本地要排队,浏览器对同一域名连接数已满,需要检查 HTTP/2 是否开启,或者拆分域名。
服务端返回 HTML 字节流之后,浏览器正式进入渲染阶段。从这里开始,前面的网络链路只是一个舞台,接下来的每个步骤都会直接影响用户看到首屏内容的时间。
5. 渲染管线:从字节流到像素的五个阶段
终于到了真正让页面“显示出来”的环节。很多前端工程师对这里非常熟悉,但我还是想从底层逐层拆开,因为真正的性能瓶颈往往藏在一些不起眼的细节里。
5.1 第一步:把 HTML 字节流变成 DOM 树
浏览器在收到 HTML 的字节流之后,会先用字符解码把字节转成字符串,然后通过分词器把字符串转成一个个 Token,再根据 Token 构建节点,最终生成 DOM 树。这和编译原理里的词法分析、语法分析有很强对应关系:Token 是词法单位,DOM 节点是语法树节点。
这里的性能关键在于 HTML 解析器的工作方式是流式的。它不需要等到整个 HTML 下载完才开始构建 DOM,而是边下载边解析。浏览器会在收到一部分字节后就尝试解析,边解析变生成节点。这也意味着,不合理的 HTML 结构(比如特别深的嵌套、大量无用的标签)会直接拖慢解析速度。
对于首屏来说,最核心的阻塞因素是<head>里的外链资源。因为 HTML 解析器遇到不带async或defer的<script>标签时会停下来,先下载并执行脚本,再继续解析后面的 HTML。如果这个脚本放在<head>里且体积很大,首屏就会卡在原地,白白浪费等待时间。
5.2 第二步:CSSOM 与渲染阻塞
HTML 构成了页面的结构,CSS 则负责样式的计算。浏览器收到 CSS 内容后会构建 CSSOM(CSS Object Model)。这一步同样会阻塞渲染,而且比很多人想象的更加严格。
CSS 的渲染阻塞体现在:因为 CSS 有层叠特性,后续规则可能覆盖前面规则,所以浏览器必须等 CSSOM 完整构建之后,才敢开始渲染页面。否则可能会出现“先渲染出无样式内容,再突然闪变成完整样式”的尴尬场面。在等待 CSS 的期间,页面一直处于白屏状态。
我在实际操作中会特别注意两点:
- 不要在首屏 HTML 里引用过多 CSS 文件。每次 CSS 请求都是额外的网络往返,串行多了首屏肯定慢。
- 不要用
@import在 CSS 中继续引入 CSS。这会让一个文件变成多个串行请求,彻底打乱加载顺序。实测下来@import的性能危害极大,几乎可以一票否决。
5.3 第三步:JavaScript 的“插队”行为与执行策略
JavaScript 是整个渲染管线里的“调皮分子”,它既能读取和修改 DOM,又能读取和修改 CSSOM。由于它可能改变一切,浏览器在遇到普通<script>标签时会强制等待脚本下载、执行完毕后,再继续构建 DOM。这就是所谓“解析器阻塞脚本”。
为了避免这个问题,我们有三种加载策略:
defer:脚本会并行下载,但执行推迟到文档解析完成后,且按照出现顺序执行。async:脚本会并行下载,下载完成后立即执行,完全不等待 DOM 解析,也不保证多个 async 脚本的执行顺序。- 不添加任何属性:同步下载、同步执行,最影响性能。
一个常见的误伤场景是为了“保证顺序”把所有脚本都加上defer,但实际上如果页面中某些资源加载依赖早期脚本的执行,反而可能出问题。我在实际项目中更倾向的策略是:首屏关键逻辑内联成一个精简脚本,非关键的第三方脚本用async,业务公共脚本用defer。
5.4 第四步与第五步:样式计算、布局、绘制与合成
DOM 和 CSSOM 都就位之后,浏览器开始渲染过程:
- 样式计算:遍历 DOM 节点,匹配 CSS 选择器,计算出每个节点的最终样式。
- 布局:根据最终样式,计算每个元素的几何位置和尺寸,生成布局树。
- 绘制:把布局树中的节点逐个绘制成屏幕上的像素,绘制过程会分成多个图层。
- 合成:将多个图层在 GPU 上合成最终的画面,呈现到屏幕上。
这里面有一个非常重要但容易被忽视的点:JavaScript 的执行和布局、绘制是互斥的。JS 如果频繁读取或修改样式和布局属性(比如offsetWidth、clientHeight),就会触发强制同步布局,造成布局抖动,直接体现为交互卡顿和首屏延迟。开发者工具里的 Performance 面板能录制并定位这类问题,我处理过的不少线上性能问题,最终根因都是某个长列表的渲染循环里反复触发强制同步布局。
5.5 首屏渲染的判定标准:FP、FCP 与 LCP
聊渲染不能只聊“页面加载完”,还得定义什么是“首屏”。Web 性能标准里几个关键指标值得记牢:
- FP(First Paint,首次绘制):浏览器第一次在屏幕上绘制任何像素的时间。
- FCP(First Contentful Paint,首次内容绘制):第一次绘制出文本、图片、Canvas 等有实质内容的时间。
- LCP(Largest Contentful Paint,最大内容绘制):首屏内最大可见内容块出现的时间。这个指标通常用来代表用户感知的加载速度。
我喜欢把 FP 理解为“白屏是否结束了”,把 FCP 理解为“页面是否有东西了”,把 LCP 理解为“用户是否看到了最重要的内容”。一个站点如果 FP 很早但 LCP 很晚,说明虽然白屏消失了,但首屏的核心内容(比如图片、主标题)加载太慢,同样会让人感觉“网页卡了”。
这几个指标可以通过 PerformanceObserver API 在浏览器里直接采集,比如:
new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(entry.name, entry.startTime); } }).observe({ type: 'largest-contentful-paint', buffered: true });采集到的数据再结合前端监控平台聚合,就能建立一条真实的用户体验基准线。
6. 全链路耗时拆解与实际排查复盘
理论部分讲完了,现在把整条链路从头到尾串一遍,并且用一套可以操作的方法论,把每一个环节的耗时拆出来,看看一个慢的站点到底慢在哪。这一节我会结合自己的排查经验,分享一套从指标到根因的实战路径。
6.1 用 Navigation Timing 拆解每一个阶段
浏览器在渲染完成后,会暴露出完整的分阶段耗时数据,这就是 Navigation Timing API。通过它,你可以拿到从导航开始到 DOM 解析完成、页面加载完成的极其精细的时间点。
const perf = performance.getEntriesByType('navigation')[0]; const timing = { dns: perf.domainLookupEnd - perf.domainLookupStart, tcp: perf.connectEnd - perf.connectStart, tls: perf.secureConnectionStart ? perf.connectEnd - perf.secureConnectionStart : 0, request: perf.responseStart - perf.requestStart, ttfb: perf.responseStart - perf.navigationStart, dom: perf.domInteractive - perf.responseEnd, render: perf.loadEventStart - perf.domInteractive, }; console.table(timing);这段代码在线上环境抓数据时非常有价值。我经常看到的一种情况是:ANALYSTi 同学报“TTFB 正常、DOM 解析很快”,但用户还是感觉慢。一查render阶段耗时巨大,发现是某个内联脚本里做了大量同步计算,把解析好的 DOM 卡住了,压根没走到绘制环节。
另一个经常被忽略的数字是perf.redirectCount和perf.redirectStart到redirectEnd的时间。像前面提到的重定向链问题,这个字段可以直观地暴露出来。
6.2 白屏案例复盘:从用户报障到根因定位
去年我处理过一个典型案例,客户线上反馈“首页白屏、刷新偶尔能出”,监控平台显示白屏率在 5% 左右。我按全链路的顺序做了一遍排查:
第一步先看网络层。DNS 解析正常、TCP/TLS 建连正常,TTFB 在 300 毫秒内,说明前端网络和服务端响应都没问题。
第二步看 HTML 解析阶段。我抓了 HTML 源码,发现首页部分通过服务端直出,但在<head>里引了一个体积很大的 polyfill 脚本,没有加defer。这个脚本阻塞了 HTML 解析。正常情况它能跑完,但当浏览器缓存失效、用户网络较差时,脚本下载时间过长,首屏就一直保持白屏。
第三步我把脚本改成defer后,首屏显著改善。但是白屏率只降到了一半。继续排查发现,还有几个异步脚本会在 DOMContentLoaded 之后动态插入大量图片,这些图片的容器在 CSS 里占位不合理,导致页面先绘制出来,随后发生大规模回流和重绘。在弱设备上这个过程耗时严重,观感上依然接近“白屏”。
最终处理方案是:首屏资源做拆分,关键 CSS 内联,非关键脚本全部defer/async,图片容器固定宽高,并用 Content Visibility 优化离屏区域。改动完之后白屏率从 5% 降到了 0.2% 以下。整个过程如果没有全链路的拆解思路,几乎不可能定位得这么快。
6.3 各阶段的优化清单与实测优先级
根据这些年的经验,我把影响首屏的因素按优先级整理成一张清单,可以对照使用:
| 阶段 | 优化手段 | 优先级 | 说明 |
|---|---|---|---|
| 网络连接 | 开启 HTTP/2、TLS 1.3 | 高 | 直接减少 RTT,成本较低 |
| DNS | 添加 dns-prefetch,优化 TTL | 中 | 对跨域资源有显著收益 |
| 服务端 | 缩短 TTFB,开启缓存 | 高 | TTFB 是首屏感知的重要温度计 |
| HTML 解析 | 避免同步脚本,用 defer/async | 高 | 同步脚本是白屏的主要原因之一 |
| CSS | 内联首屏关键 CSS | 中 | 减少 CSS 请求数 |
| 图片 | 使用原生懒加载和正确尺寸 | 中 | 减少首屏网络负载 |
| 渲染 | 避免强制同步布局 | 中 | 影响弱机明显 |
这里再补一句重要性排序的经验:对首屏影响最大的往往是“同步脚本 + TTFB 过长”这两个因素。前者直接决定页面什么时候能开始渲染,后者决定服务端响应什么时候能回来。这两者只要有一个出问题,后面一切优化都会被掩盖。
6.4 我最后想说的几个细节
如果要说这条全链路里最容易被忽视的角落,我第一个想到的是浏览器“预连接”能力。很多人只知道 CDN 和压缩,却不知道浏览器可以通过<link rel="preconnect">提前和目标站点建立连接。如果你知道自己马上会请求某个跨域资源,在 HTML<head>里加上这一行,就能让连接建立阶段和 HTML 下载阶段并行,首屏快不少。
第二个容易被忽视的是 URL 有效性校验。每次收到一个 URL 输入,不要急着发请求,先用new URL()解析一次,能抛异常就说明 URL 不合法,避免走到 DNS 和连接阶段才发现问题。这不算什么高深技术,但在工程里非常实用。
第三个细节是:浏览器渲染管线不是恒定的,它会因为是否滚动、是否有动画、是否处于后台而改变策略。做性能测试时一定要在独立的无痕窗口、真实用户网络模拟下进行,否则测得的数据很容易骗人。
我自己每次排查完一个性能问题,都会把那一次全链路的数据截图存下来。做得久了,脑子里就会自然形成一张“毫秒级地图”——DNS 花了多少、连接花了多少、等待服务端花了多少、渲染花费了多少。不用开工具,看一眼用户反馈的延迟时间,就能大致猜出问题出在哪一段。这种直觉不是天生的,就是一条链路一条链路跑出来的。