1. 回车键按下去的那几百毫秒,浏览器在偷偷做判断
有人问我"页面打开慢到底卡在哪一步",我一般不会直接回答,因为这个问题本身太粗。从手指按下回车到屏幕上出现像素,中间至少跨了七个环节,任何一个环节拖后腿,用户体感都是"这网站真卡"。我习惯的做法是把这条链路拆开,一段一段量,量完再谈优化。你如果只会说"加个 CDN 吧",那基本等于蒙。
从输入一个网址到看见完整页面,这条路走的是域名解析、连接建立、请求发送、响应接收、页面渲染、资源补全这一整套流程,涉及浏览器、操作系统、网络协议栈、中间链路和服务端。它不是一个知识点,而是一条流水线,任何一段出问题都会体现在最终的用户体验上。适合谁看?前端在排查首屏卡顿的、后端在定位接口慢的、刚接触网络基础想搞清楚"整条链路"的,都能对着这篇一段段比对。
我先说一个反直觉的结论:很多时候你敲的那个网址,根本没有产生任何网络请求。不是玄学,是缓存。下面第一章就从这个"没发生的事"说起。
1.1 先判断你输入的是网址还是搜索词
地址栏不是纯粹的输入框,它是个带判断逻辑的分发器。你敲进去一串字符,浏览器要在一瞬间决定三件事:这到底是一个合法的 URL、一个可以补全的域名、还是一个该丢给搜索引擎的关键词。
判定规则大概是这样的:如果字符串里出现了明确的协议头(http://、https://、file://),或者带端口号、带路径,直接当 URL 处理;如果没有协议头但看起来像域名(含点、后缀符合常见顶级域),浏览器会先尝试补全成https://再发请求;如果中间带空格、或者明显是自然语言,就走搜索。这里有个细节很多人不知道——浏览器并不是"猜",它会先做一次异步的预判请求,如果域名解析失败,才回退到搜索,所以你看到的"搜到了"和"打开了"在后台是两条不同的路。
我在实际排查中遇到过一个典型的坑:内网系统的域名是oa这种不带后缀的单标签名,用户直接在地址栏敲oa回车,浏览器一律当搜索词处理,跳去搜索引擎,用户以为系统挂了。解决办法是把单标签域名写进系统的 hosts 或者配置内网域名后缀,别指望用户每次都手打完整地址。
还有一个容易忽略的动作是协议升级。现在绝大多数站点都强制 HTTPS,浏览器在你敲完域名后,会先在本地维护的升级列表里查一下,如果这个域名之前声明过"以后都用 HTTPS 访问",浏览器会直接把http://改写成https://,压根不发那次明文的请求。这一步省掉了一整次往返,是纯粹的白赚。
1.2 缓存命中:一次"什么也没发生"的加载
在真正发起网络请求前,浏览器要过好几道缓存闸门,顺序大致是:Service Worker → 内存缓存 → 磁盘缓存 → 推送缓存。这个顺序不是随便排的,越靠前越快、越靠前越"新"。
内存缓存存活在渲染进程的内存里,页面刷新后基本就没了,但同一个页面内跳转、回退时命中率很高,特征是响应时间接近 0 毫秒,在开发者工具里显示为(memory cache)。磁盘缓存才是持久的那层,它听谁的话?听响应头里的Cache-Control。max-age=3600意味着这一小时内再来访问,浏览器连请求都不发,直接从硬盘读,这是强缓存;如果过期了,才会带上If-None-Match或If-Modified-Since去问服务端"我这份还能用吗",服务端回304 Not Modified就说明内容没变、继续用本地副本,这是协商缓存。
这两种缓存的差别值多少钱?我给个直观数据:一个首屏需要 40 个资源的页面,如果全部走强缓存,整个加载过程可能压缩到 200 毫秒以内;如果全部回源,光排队和往返就能吃掉两三秒。所以当有人抱怨"第二次打开还是慢",第一件事不是查网络,是打开 Network 面板看 Size 那一列写的是不是(disk cache)。
提示:调试缓存问题时,务必勾上"禁用缓存"再刷新,否则你改了半天代码看不见效果,最后发现是浏览器读的旧文件。
2. 域名换成 IP:DNS 这条链比大多数人想的要长
浏览器知道自己要去哪台机器拿东西,靠的是 IP 地址,而不是你敲的那串字母。域名转 IP 这一步就是 DNS 解析,它是整条链路的第一个真实网络动作,也是"第一次打开慢、后面就快"这个现象的主要来源。
2.1 从本地缓存一路问到权威服务器
DNS 查询不是"发一个请求问一下"这么简单,它是一层一层往下找的。完整顺序是这样的:
- 浏览器自己的 DNS 缓存,命中就直接用,通常只有几十毫秒的 TTL,用来扛住页面内大量重复域名的查询;
- 操作系统缓存,Windows 上还有一份 hosts 文件,这是很多人做本地联调时最常用的手段,改优先级很高;
- 本地递归服务器,一般是运营商或公司内网提供的那台,它会替你去问,问到了再缓存下来;
- 根域名服务器,告诉你"
.com这一片你去找谁"; - 顶级域名服务器,告诉你"
example.com这个域名的权威服务器在哪"; - 权威服务器,最终返回那条 A 记录或 AAAA 记录,也就是真正的 IP。
从第 4 步开始到第 6 步,是典型的迭代查询:本地服务器挨个去问,别人只告诉它"下一步找谁",不给最终答案。而浏览器到本地服务器之间是递归查询:浏览器只说"我要这个域名的 IP",本地服务器负责把活干完、把结果带回来。这两个概念经常被混淆,区分点就在于"谁在跑腿"。
为什么会慢?因为如果本地递归服务器上也没有缓存,一次完整的解析可能要跨越大半个互联网,几百毫秒很正常。更麻烦的是,DNS 大多跑在 UDP 上,丢包就超时重试,超时时间还常常是 2 秒起步。你偶尔遇到的"页面白屏五六秒然后突然全出来了",很多就是卡在这一步。
2.2 自己动手把解析过程看清楚
别光听我说,实测一遍最快。在命令行里跑一条:
dig www.example.com +trace+trace会从根开始,把每一跳的问询过程完整打印出来,你能亲眼看到根服务器返回的 referrals、顶级域返回的授权、权威服务器给出的最终答案。看完这一遍,DNS 的层级结构就再也忘不掉了。
如果只想看本机用的哪台递归服务器、以及缓存里的结果:
nslookup www.example.comWindows 上还可以用ipconfig /displaydns把本地 DNS 缓存整个列出来,排查"为什么我改了记录还不生效"时特别有用。
这里有个我从踩坑里总结的经验:改 DNS 记录后不生效,九成不是没生效,是你本机或中间某一层还在缓存。TTL 设的是 600 秒,那就要等 10 分钟,任何"强制刷新"都跳不过这个时间。做上线切换时,正确的做法是提前一两天把 TTL 调小(比如调到 60 秒),等旧记录自然过期,再改指向,改完观察一阵再把 TTL 调回去。这个操作顺序我见过太多团队搞反,结果切流量那天全员加班。
2.3 CDN 是怎么借 DNS 做调度的
还有一个绕不开的现实:同一个域名,不同地区解析出来的 IP 往往不一样。这不是 DNS 出错了,是 CDN 在干活。它的原理是在权威解析这一层做判断——根据请求来源的网段,返回离用户最近的那个边缘节点的地址。
这件事对排查问题影响很大。你在这台机器上ping出来是 203.0.113.20,同事那边ping出来是 198.51.100.8,两边都说"我这是对的"。这时候别争,各自dig一下,看权威服务器到底给谁回了什么,问题立刻就清楚了。我一般会建议团队把"不同地区解析结果可能不同"写进排查手册第一条,能省掉大量无效沟通。
3. 连接建立与握手:数据出发前要先敲门
拿到 IP 之后,故事才刚进入正题。TCP 连接建立和 TLS 握手,是这条链路上最"仪式感"也最容易被优化掉的部分。
3.1 三次握手为什么不能减成两次
TCP 建立连接要三次往返,标准叫法是 SYN、SYN+ACK、ACK。为什么不能两次?核心在于双方都要确认"我发的对方能收到,对方发的我也能收到"。
第一次客户端发 SYN,服务端收到了,服务端只能确认"客户端到服务端这条路是通的"。第二次服务端回 SYN+ACK,客户端收到了,客户端能确认两条路都通。但这时候服务端还不知道"自己发的东西客户端收到没有",所以需要第三次客户端发 ACK 回去。少一次,服务端就没法确认自己这一侧发送通道的可靠性,历史遗留的重复连接请求也没法被正确识别和丢弃。
这一来一回,在同城网络里大约 10 到 30 毫秒,跨洲可能就是 150 毫秒往上。所以"减少往返次数"是网络优化的第一性原理——后面所有的协议演进,本质上都在干这一件事。
3.2 TLS 握手在谈什么,为什么版本差异这么大
HTTPS 比 HTTP 多的那一层,就是在 TCP 建好之后再加一次 TLS 握手。它的目的有三个:验证对方身份(证书)、协商加密算法(密码套件)、安全地交换密钥(让双方得到同一把会话密钥,而这把密钥从没在网络上明文传输过)。
握手要几个往返,取决于协议版本:
| 版本 | 典型往返次数 | 关键特征 |
|---|---|---|
| TLS 1.2 完整握手 | 2-RTT | 需要先协商再交换密钥,步骤多 |
| TLS 1.2 会话复用 | 1-RTT | 复用之前的会话票据 |
| TLS 1.3 完整握手 | 1-RTT | 合并消息,客户端第一趟就把密钥共享带上 |
| TLS 1.3 会话恢复 | 0-RTT | 直接带数据,但存在重放风险,只适合幂等请求 |
这个表格解释了一个很实际的问题:一个站点从 TLS 1.2 升到 1.3,首屏能快多少?按一次往返 40 毫秒算,省一趟就是 40 毫秒,加上握手消息本身变少,实测收益通常在 50 到 100 毫秒之间。单看不惊人,但这是每个新建连接都要付的成本,页面上的第三方域名一多,收益就乘起来了。
想自己看握手细节,curl就够:
curl -v https://www.example.com -o /dev/null加-v会打印出协商的 TLS 版本、密码套件、证书链信息。如果只想看握手耗时:
curl -w "dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" -o /dev/null -s https://www.example.com这条命令输出的是各阶段耗时,我从 2015 年用到现在,排查"到底是 DNS 慢还是服务端慢"时它比任何工具都快。time_appconnect减time_connect就是 TLS 握手花的钱,time_starttransfer减time_appconnect大概就是服务端处理时间。
3.3 连接复用与队头阻塞
既然握手这么贵,那就别老握手——这是 HTTP/1.1 引入keep-alive的思路,一条 TCP 连接上跑多个请求。但 HTTP/1.1 有个硬伤:请求是串行的,前一个响应没回来,后面的只能等着,这叫应用层队头阻塞。浏览器为了绕过它,对同一个域名开了 6 条并发连接,代价是更多的握手和更多的资源消耗。
HTTP/2 用多路复用解决了应用层的问题:一条连接上跑很多个并行的流,各自独立,互不阻塞。但 TCP 本身是有序的字节流,一旦某个包丢了,整条连接上所有的流都得等重传——这就是传输层队头阻塞,HTTP/2 没解决,只是在多数网络质量好的场景下不明显。
HTTP/3 干脆换掉了 TCP,跑在基于 UDP 的 QUIC 上,每个流独立重传,一丢包只影响那一个流;同时把 TLS 握手合并进去,建连更快。这也是为什么弱网环境下 HTTP/3 的体感提升格外明显——它不是"更快",它是"卡的时候不会全卡住"。
| 协议 | 连接层 | 队头阻塞 | 建连成本 |
|---|---|---|---|
| HTTP/1.1 | TCP | 应用层存在 | 需要多连接并发 |
| HTTP/2 | TCP | 传输层仍存在 | 单连接多路复用 |
| HTTP/3 | QUIC/UDP | 基本消除 | 合并握手,恢复可 0-RTT |
注意:升级到 HTTP/2 或 HTTP/3 不是改个配置就完事,得确认中间链路(负载均衡、代理层)都支持,否则会出现前端以为在用新协议、实际被降级回 1.1 的情况。
4. 请求报文出去以后,服务端和中转环节在做什么
连接建好了,浏览器终于可以把那条 HTTP 请求报文发出去。这一章讲报文本身长什么样、路上会遇到谁、回来的时候该看什么。
4.1 一条请求报文里到底装了什么
一个完整的 HTTP 请求由三部分组成:请求行、请求头、请求体。
请求行是GET /index.html HTTP/2这样的格式,method + 路径 + 版本。请求头是键值对,承担了绝大部分"元信息"的传递职责。几个必须认识的:
Host:目标域名,HTTP/1.1 起强制要求,因为一台服务器可能挂多个站点;User-Agent:客户端标识,服务端常用来做兼容性判断,但注意它太长了会浪费带宽;Accept/Accept-Encoding/Accept-Language:内容协商三件套,告诉服务端"我能接受什么格式、什么压缩、什么语言";Cookie:身份凭证,每次同域请求都会自动带上,也是最容易被忽视的带宽消耗源;Referer:来源页,用于统计和防盗链。
请求体在 GET 里通常为空,POST 才带。这里有个常见误解:很多人以为 GET 不能带 body,其实协议上没禁止,只是语义上不该这么做,中间设备和缓存可能直接忽略它,所以别干这种事。
Accept-Encoding值得单拎出来说。它决定了服务端会不会用 gzip 或 br 压缩响应。一个没压缩的 300KB 的 JS 文件,压缩后可能只有 90KB,省下的那 210KB 在移动网络下就是实打实的一两秒。我排查性能问题时,第一步永远是看响应头里有没有Content-Encoding,没有就先补上,这是投入产出比最高的一项优化。
4.2 路上遇到的那些中间层
请求发出去之后,不一定直接打到你的应用服务器。中间可能隔着好几层:
CDN 边缘节点在最前面,负责把静态资源直接返回,压根不回源。它判断的依据是缓存策略和文件是否变更。静态资源配上带哈希的文件名(比如app.3f9a2c.js)加上Cache-Control: max-age=31536000, immutable,就能实现"一年不用回源",改版本靠改文件名。
负载均衡在下一层,负责把请求分到多台后端机器上。这里最容易出问题的是会话粘性——如果用户登录状态存在单机内存里,请求被分到另一台机器,用户就"莫名其妙掉登录了"。正确做法是状态外置,别指望负载均衡帮你记住谁是谁。
反向代理在最内层,负责路径重写、头部修改、超时控制、限流熔断。很多人调试时发现"本地好使,线上 502",八成是代理层的超时时间或缓冲区配置和预期不一致。
我做排查时有个固定动作:拿到一个可疑请求,先确认它到底停在哪一层。方法很简单,在响应头里塞一个标识(很多代理支持自动加Via或自定义头),看一眼就知道请求穿过了几层。这比盲猜快十倍。
4.3 响应回来时,先看状态码再看头
服务端处理完,返回响应。响应的结构和请求对称:状态行、响应头、响应体。
状态码分五类,实际工作中高频出现的就那么几个:
| 状态码 | 含义 | 排查时该关注什么 |
|---|---|---|
| 200 | 成功 | 看响应体大小和 TTFB |
| 301 / 302 | 重定向 | 是否有多余的跳转链,每条都要付往返成本 |
| 304 | 未修改 | 协商缓存生效,正常,但说明没走强缓存 |
| 403 / 401 | 无权限 | 通常是凭证或跨域配置问题 |
| 404 | 找不到 | 静态资源 404 要警惕,可能是路径配错 |
| 500 / 502 / 504 | 服务端错误 | 502 是上游不可达,504 是上游超时 |
重定向链是我特别想强调的一点。一个http://的地址,如果没做 HSTS,先 301 到https://,再 301 到www.前缀,再 302 到登录页,这一串下来就是三四次完整往返,用户体感就是"点了没反应,过两秒才动"。这类问题在移动端弱网下尤其致命。治理办法是把跳转合并成一次,或者直接在入口层做掉,让浏览器只经历一次重定向。
响应头里除了缓存相关的,还要盯Content-Length和Transfer-Encoding。如果响应是分块传输的,浏览器要等第一块数据到了才能开始解析,这时候TTFB(首字节时间)就特别关键。TTFB 高通常意味着服务端处理慢或者网络链路长,和前端代码没太大关系——搞清楚这一点,能避免团队之间互相甩锅。
5. 字节流到了浏览器:从 HTML 文本变成屏幕上的像素
前面的章节全是"把数据弄到手",从这一章开始才是浏览器真正的主场。这一段的特点是:它在主线程上发生,任何一个环节被阻塞,用户看到的都是白屏。
5.1 解析建树:DOM、CSSOM 与渲染树
浏览器拿到的是一串字节流。它先按编码把它转成字符,再按 HTML 语法切成一个个 token,然后根据 token 之间的关系构建出一棵树——这就是DOM 树。这个过程是渐进式的,不是等整个 HTML 下载完才开始,而是收一段解析一段,这也是为什么把首屏内容放在 HTML 前面能更快显示。
与此同时,<link rel="stylesheet">引的 CSS 也在下载和解析,生成CSSOM 树。CSSOM 的构建是完全阻塞渲染的,因为浏览器不知道后面的样式会不会覆盖前面的,没法边解析边渲染,只能等。这就是"CSS 阻塞渲染"的真实含义,也是为什么关键样式要内联、非关键样式要异步加载。
DOM 和 CSSOM 都准备好之后,两者合并成渲染树(Render Tree)。注意渲染树里不包含display: none的元素(它们不占位不绘制),但包含visibility: hidden的元素(占位但不显示)。这个细节在做"隐藏元素还要不要参与布局计算"的判断时很关键。
5.2 布局、绘制与合成:为什么动画要用 transform
渲染树建好后进入**布局(Layout / Reflow)**阶段:计算每个盒子的位置和大小。这一步的输出是每个元素的精确几何信息。布局的成本和树的规模强相关,节点越多、层级越深、越依赖父级尺寸(比如百分比宽度),算起来越慢。
布局之后是绘制(Paint):把每个盒子填充成像素,生成绘制指令。再往后是合成(Composite):把页面拆成若干图层,交给 GPU 合成最终画面。
这条流水线上,不同属性的修改触发的阶段不一样:
| 修改的属性 | 触发的阶段 | 成本 |
|---|---|---|
width、height、font-size、margin | 布局 + 绘制 + 合成 | 最高 |
background-color、box-shadow | 绘制 + 合成 | 中等 |
transform、opacity | 仅合成 | 最低 |
这就是"做动画用transform和opacity,别用left和top"的真正原因,不是什么编码风格洁癖,而是后者每一帧都要重新算布局。我给一个实际体感:一个全屏元素从左边滑到右边,用left做动画,在中低端手机上帧率掉到 20 出头;换成transform: translateX(),稳稳 60 帧。差别就在这一张表上。
5.3 关键渲染路径上的那些阻塞点
首屏白屏时间长,十有八九是这几个阻塞点没处理:
同步脚本<script>默认会阻塞 HTML 解析——因为脚本里可能有document.write,浏览器不敢往下猜。解决方法是给非关键脚本加defer或async,或者挪到 body 末尾。
样式表阻塞脚本。这一点知道的人少一些:如果<script>前面有一个还没加载完的样式表,浏览器会等样式表加载完再执行脚本,因为脚本可能读取元素的样式。所以一条慢 CSS 能把后面所有脚本都拖住。
字体阻塞文字显示。自定义字体没加载完时,文字可能在一段时间内不可见(取决于font-display的取值)。默认行为下这个不可见期可能长达 3 秒,用户看到的就是"页面框架出来了,字没有"。把非关键字体设成font-display: swap,先用系统字体顶上,能显著改善体感。
图片缺少尺寸。<img>如果不写width和height,图片加载完才知道多大,一加载就把下面的内容全推下去,这就是布局偏移。现在浏览器支持在属性里写宽高比,让它在加载前就预留好位置,这个改动几乎零成本,效果立竿见影。
提示:排查阻塞时,在开发者工具的性能录制里看主线程的时间轴,那个标着"Parse HTML"的长条如果被一截一截地打断,打断它的那个东西就是罪魁祸首。
6. 首屏之后的连续动作:资源、脚本与事件时序
首屏出来不代表加载结束。页面上的图片、字体、异步请求、埋点脚本还在陆续到位,这一阶段的行为决定了"能不能交互"和"事后统计准不准"。
6.1 defer、async 与模块脚本的差别
这三个东西经常被混用,但行为完全不同:
| 写法 | 是否阻塞解析 | 执行时机 | 顺序保证 |
|---|---|---|---|
<script> | 是 | 立即 | 按文档顺序 |
<script defer> | 否 | HTML 解析完成后 | 按文档顺序 |
<script async> | 否 | 下载完立即执行 | 无保证 |
<script type="module"> | 否 | 默认相当于 defer | 按文档顺序 |
选型的逻辑很清楚:脚本之间有依赖关系的,用defer,因为它保证按出现顺序执行;完全独立的第三方脚本,用async,比如统计代码,早执行晚执行都不影响业务。至于type="module",除了默认 defer 之外,它还有自己的作用域、严格模式、支持import,现代项目的入口脚本基本都用它。
我踩过的坑:给两个有依赖的脚本都加了async,本地测试时快慢刚好合适,上线后因为网络抖动导致执行顺序颠倒,报了个莫名其妙的"方法未定义"。这种 bug 的恶劣之处在于它只在特定网络条件下复现,抓日志抓了半天。从那以后我给自己定了条规矩:只要两个脚本有依赖,就老老实实用defer,不图那点速度。
6.2 DOMContentLoaded 和 load 到底差在哪
这两个事件是面试高频题,但很多人只知道定义,不知道用。
DOMContentLoaded在HTML 文档被完全解析、且所有 defer 脚本执行完毕之后触发,此时图片、子框架、异步脚本可能还没加载完。load则在页面所有资源(图片、样式、字体、iframe)都加载完毕后才触发。
实际含义是:DOMContentLoaded代表"DOM 可操作了",load代表"页面整体完整了"。如果你想在脚本里操作 DOM,等前者就够了,等后者纯属浪费时间——一张大图没加载完,你的初始化逻辑要白白等好几秒。反过来,如果要做需要精确测量整页体积的统计,那确实得等load。
还有个常被忽略的:readystatechange里的interactive和complete,分别对应上面两个事件的时机。埋点 SDK 里经常用这个来做兜底,防止事件绑定得太晚错过时机。
6.3 首屏之后的加载策略调整
首屏之外的内容不该和首屏抢带宽。我常用的三条策略:
图片懒加载。给首屏外的图片加loading="lazy",浏览器会在图片接近视口时才去请求。这一条能把首屏的并发请求数砍掉一大半,尤其在列表页效果惊人。
路由级拆分。单页应用不要把所有页面的代码打进一个包,按路由拆成多个 chunk,用户访问哪页加载哪页。打包工具都支持动态import(),写法很简单,收益是首屏 JS 体积能降 50% 以上。
预加载与预连接。对确定会用到的下一个页面或关键资源,可以用<link rel="preload">、<link rel="prefetch">、<link rel="preconnect">提前打招呼。preconnect用在跨域资源上收益最大,它提前把 DNS 解析、TCP 连接、TLS 握手都做掉,真正需要时直接发请求。但要克制,预连接太多反而会占用连接资源、拖慢真正的关键请求。
7. 把这套流程照进现实:用开发者工具逐段验证
讲了这么多原理,最后落到能上手操作的部分。我排查任何一个"慢"的问题,都是按下面这套流程走的。
7.1 Network 面板里每个字段对应上面哪一章
打开开发者工具的 Network 面板,把鼠标悬停在某条请求的 Timing 标签上,会看到一串阶段划分,它们和前面的章节是一一对应的:
| 面板字段 | 对应阶段 | 慢的话说明什么 |
|---|---|---|
| Queueing | 排队 | 同域名并发连接数打满,或者被更高优先级请求挤占 |
| Stalled | 等待 | 浏览器在等可用连接,常见于大量同域资源 |
| DNS Lookup | DNS 解析 | 首次访问、缓存未命中,或 DNS 服务本身慢 |
| Initial Connection | TCP 握手 | 跨地域、链路质量差 |
| SSL | TLS 握手 | 证书链太长、版本老旧、没做会话复用 |
| Request Sent | 发送请求 | 一般极小,基本可忽略 |
| Waiting (TTFB) | 等服务端首字节 | 服务端处理慢或网络往返长,与前端代码无关 |
| Content Download | 下载响应体 | 资源太大、没压缩、带宽不足 |
这张表我建议打印出来贴在工位上。当有人问"页面为什么慢",你打开面板看一眼哪个字段最长,答案就出来一半了。TTFB 长但下载快,找后端;下载慢但 TTFB 短,看资源体积和压缩;Stalled 长,看并发和域名分片。
7.2 用代码把各阶段耗时精确量出来
面板看着直观,但要做监控和对比,还得靠代码。浏览器提供了一套导航时序接口:
window.addEventListener('load', () => { const nav = performance.getEntriesByType('navigation')[0]; const t = { dns: nav.domainLookupEnd - nav.domainLookupStart, tcp: nav.connectEnd - nav.connectStart, tls: nav.secureConnectionStart ? nav.connectEnd - nav.secureConnectionStart : 0, ttfb: nav.responseStart - nav.requestStart, download: nav.responseEnd - nav.responseStart, domReady: nav.domContentLoadedEventEnd - nav.startTime, firstPaint: performance.getEntriesByName('first-contentful-paint')[0]?.startTime || 0, }; console.table(t); });这段脚本能直接把 DNS、TCP、TLS、TTFB、下载、DOM 就绪、首次内容绘制全部打出来。我在做性能基线时会把它挂到测试环境,每次发版跑一遍,数值有异常立刻能定位到具体阶段,比事后翻日志高效得多。
注意:
secureConnectionStart在非 HTTPS 页面上是 0,算 TLS 耗时时记得先判断,否则会算出个负数或者离谱的大数。
7.3 常见"慢"的归因对照表
最后把高频问题整理成一张对照表,遇到现象直接查:
| 现象 | 最可能的阶段 | 处理方向 |
|---|---|---|
| 首次打开慢,刷新就快 | DNS 或缓存未命中 | 减小 TTL、加预解析、检查缓存头 |
| 白屏好几秒然后突然全出来 | 阻塞脚本或慢 CSS | 拆分、加 defer、内联关键样式 |
| 页面出来了但点不动 | 主线程被长任务占满 | 拆分长任务、减少同步计算 |
| 首屏快但图片区域一片空白 | 图片懒加载阈值或尺寸未预留 | 调整加载策略、设置宽高比 |
| 部分用户偶尔 502 | 中间层超时或上游扩容跟不上 | 调代理超时、检查健康检查配置 |
| 内容更新了用户看不到 | 强缓存时间过长 | 文件名加哈希、缩短 HTML 缓存时间 |
这套排查思路的核心是永远先定位阶段,再谈优化手段。我见过太多团队一上来就说"上 CDN""做个骨架屏",结果改完之后该慢还是慢,因为压根没搞清楚慢在哪一段。把上面这七章对应的环节在心里过一遍,绝大多数"玄学慢"都能被拆成具体的问题。
最后分享一个我自己养成的小习惯:每次接手一个新项目,先在新环境里完整跑一遍curl的分阶段计时,再录一次页面加载的性能面板,两份数据对着看。这十几分钟花下去,后面排查问题时你会感谢自己。因为它给你留了一条"正常长什么样"的基线,没有基线,所有"慢"都是凭感觉。