从输入 URL 到页面显示
本节目标
- 掌握「输入 URL 到页面显示」的完整链路
- 用一段真实发起网络请求的 Node 代码,实测每一阶段花了多少时间
- 建立「全链路性能优化」的视角,而不是死记流程
完整流程(面试与实战都高频):
① 输入 URL:浏览器判断是「搜索词」还是「网址」
② DNS 解析:域名 → IP(有缓存就跳过这步)
③ 建立 TCP 连接(三次握手)
④ TLS 握手(HTTPS,协商出对称密钥)
⑤ 发送 HTTP 请求(带 cookie、缓存相关头)
⑥ 服务器响应 → 检查缓存 → 解析 HTML
⑦ 渲染进程解析 HTML/CSS/JS → 布局绘制(下一章详讲)
⑧ 页面显示,其余资源继续加载几个关键细节:
- DNS 解析:浏览器缓存 → 系统缓存 → 本地 hosts → 递归 DNS 服务器。一次解析通常几十到几百毫秒。
- TCP 三次握手:SYN → SYN+ACK → ACK;HTTPS 在此基础上还要 TLS 握手(1~2 个 RTT)。所以 HTTPS 比 HTTP 慢在握手,HTTP/2 的「连接复用」能大幅缓解。
- 资源加载顺序:HTML 里遇到普通
<script>会阻塞解析(必须下载并执行完才继续),<link>的 CSS 会阻塞首次绘制。现代写法是用defer或把脚本放尾部。
下面这段 Node 代码会真实发起一次请求,并分别打印 DNS 解析、TCP 握手、TLS 握手、首字节、下载的耗时,让你「看见」链路每一步:
// 运行环境:Node.js 14+(仅用内置 https,无需装包)
// 步骤:把下面内容存为 brw-l2.js,执行 node brw-l2.js
// 它会真实请求 https://example.com,用 socket 事件精确打印 DNS/TCP/TLS/首字节/下载 各阶段耗时(毫秒)
const { performance } = require('node:perf_hooks')
const https = require('node:https')
// 发起一次 HTTPS 请求,用 socket 的 lookup/connect/secureConnect 事件把「链路」精确分段计时
function measure(url) {
return new Promise((resolve) => {
const timings = {}
timings.start = performance.now() // ① 开始
const req = https.request(url, (res) => {
timings.ttfb = performance.now() // ⑤ 收到首字节(TTFB)
res.resume()
res.on('end', () => { timings.end = performance.now(); resolve({ timings, status: res.statusCode }) })
})
// socket 事件能拿到 fetch 拿不到的三个细分阶段:
req.on('socket', (socket) => {
socket.on('lookup', () => { timings.dns = performance.now() }) // ② DNS 解析完成
socket.on('connect', () => { timings.tcp = performance.now() }) // ③ TCP 三次握手完成
socket.on('secureConnect', () => { timings.tls = performance.now() }) // ④ TLS 握手完成
})
req.on('error', (e) => resolve({ error: e.message }))
req.end()
})
}
;(async () => {
const { timings, status, error } = await measure('https://example.com/')
if (error) { console.error('请求失败:', error); process.exit(1) }
const ms = (a, b) => (b - a).toFixed(1)
console.log(`DNS 解析 : ${ms(timings.start, timings.dns)} ms`)
console.log(`TCP 三次握手 : ${ms(timings.dns, timings.tcp)} ms`)
console.log(`TLS 握手 : ${ms(timings.tcp, timings.tls)} ms`)
console.log(`首字节(TTFB) : ${ms(timings.tls, timings.ttfb)} ms(状态 ${status})`)
console.log(`下载完成 : ${ms(timings.ttfb, timings.end)} ms`)
console.log(`总耗时 : ${ms(timings.start, timings.end)} ms`)
})()优化视角(对应后面「首屏性能」章节):
DNS 预解析(dns-prefetch) → 提前把域名解析好,省去用户等待
HTTP/2 + CDN + 缓存 → 减少网络往返与传输耗时
关键 CSS/JS 内联或 defer → 减少阻塞解析
压缩 + Tree Shaking → 减少传输体积名词解释
DNS(域名系统,Domain Name System):把人类好记的域名(example.com)翻译成机器用的 IP 地址(93.184.216.34)的分布式系统。它的「方法」可理解为:先查本地缓存(lookup 命中就返回)、未命中再向递归解析器查询。你可以把它类比成「电话簿」。
RTT(往返时延,Round Trip Time):数据从你这里到服务器再回到你这里花的时间。握手每多一个 RTT,用户就多等一份。方法/手段:用「连接复用(HTTP/2)」「0-RTT(HTTP/3/QUIC)」来减少 RTT 次数。
三次握手(Three-way Handshake):TCP 建立连接的三步确认(SYN → SYN+ACK → ACK),目的是双方都确认「我能发、你能收」。它不提供「方法调用」,而是一组状态变迁(CLOSED → SYN_SENT → ESTABLISHED)。
课后练习
练习 1:为什么第二次刷新同一个页面,往往比第一次快很多?请至少说出两个原因。
答案:① DNS 缓存:域名已解析过,浏览器/系统缓存了 IP,跳过 DNS 查询;② 网络/HTTP 缓存:静态资源命中强缓存(Cache-Control 未过期)直接读本地,或命中协商缓存拿到 304,不必重新下载。此外 TCP 连接也可能被复用(HTTP/2 多路复用)。
练习 2:把上面 brw-l2.js 里的 example.com 换成你常用的一个网站(如 https://www.baidu.com),运行后观察「建连+首字节」耗时。如果这一项特别高,最可能卡在哪?
答案:最可能卡在「网络往返(RTT)」或「服务器处理慢」。如果是跨地域/跨境访问,物理距离导致的 RTT 无法靠代码消除,只能靠 CDN(让用户就近访问边缘节点)来缓解;如果是服务器响应慢,那就是后端优化问题,前端爱莫能助。
总结
我觉得「输入 URL 到显示」这种题,背流程是最低级的学法,真正值钱的是建立**「耗时归因」的直觉**。当你看到页面慢,脑子里要能立刻拆出:是 DNS 慢?是握手 RTT 多?是首字节晚(后端慢)?还是下载体积大?每一种慢,解法完全不同——DNS 慢上 dns-prefetch,握手慢上 HTTP/2,体积大上压缩和分包。这一节你跑的那段 Node 代码,核心价值不是「发了个请求」,而是让你亲眼确认:慢,是慢在哪一个可测量的数字上。性能优化的全部艺术,就是「先测量、再归因、后对症」。