浏览器缓存机制与 CDN

本节目标

两级缓存:

① 强缓存:不请求服务器,直接用本地缓存
   Expires(绝对时间,老)/ Cache-Control(相对时间,推荐)
   → Cache-Control: max-age=31536000 表示一年内直接用
② 协商缓存:还是发请求,服务器说「没变」→ 返回 304,浏览器用本地副本
   Last-Modified / If-Modified-Since(秒级,可能不精确)
   ETag / If-None-Match(内容指纹,推荐)

优先级:Cache-Control > Expires;协商缓存里 ETag 优先于 Last-Modified。

流程:

请求资源
  → 命中断言缓存(Cache-Control 未过期)?→ 直接 200(from cache)
  → 否则发请求带 If-None-Match / If-Modified-Since
      → 服务器 304 → 用本地缓存
      → 服务器 200 + 新资源 → 更新缓存

下面这段 Node 代码真实实现了 ETag 协商缓存:第一次返回 200+ETag,第二次带 If-None-Match 请求时返回 304。

// 运行环境:Node.js 14+(仅用内置 http,无需装包)
// 步骤:node brw-l11.js  (一个文件搞定:先起服务,再自动连发两次请求)
//       第一次打印 200(带 ETag),第二次打印 304(命中协商缓存)

const http = require('node:http')
const crypto = require('node:crypto')
const DATA = 'hello cache'
const etag = '"' + crypto.createHash('md5').update(DATA).digest('hex') + '"'

const server = http.createServer((req, res) => {
  const clientETag = req.headers['if-none-match']
  if (clientETag === etag) {
    res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'max-age=0' })
    return res.end()                      // 没响应体,浏览器用本地缓存
  }
  res.writeHead(200, { 'ETag': etag, 'Cache-Control': 'max-age=0' })
  res.end(DATA)
})

// 先等服务器真正启动成功,再发起请求(放进 listen 回调,避免「服务还没起来就请求」的竞态)
server.listen(3000, async () => {
  console.log('server on http://localhost:3000')
  const r1 = await fetch('http://localhost:3000/')
  const gotEtag = r1.headers.get('etag')
  console.log('第一次:', r1.status, 'ETag=', gotEtag)
  const r2 = await fetch('http://localhost:3000/', { headers: { 'If-None-Match': gotEtag } })
  console.log('第二次(带ETag):', r2.status, '=> 304 即命中协商缓存')
  server.close()
})

hash 命名与缓存策略(工程关键):

<!-- 文件名带内容 hash:内容变了 hash 变 → 强制走新文件 -->
<script src="/assets/index-CRw3c4JD.js"></script>
静态资源(带 hash):Cache-Control: max-age=31536000, immutable  (一年强缓存)
HTML(不带 hash)  :Cache-Control: no-cache                    (每次协商,拿到最新引用)

CDN 原理:用户 → 就近边缘节点(DNS 解析到最近 CDN)→ 命中缓存直接返,未命中回源站拉取并缓存。优势:就近省时、边缘扛流量、源站压力小;配合 hash 文件名可「更新即全网生效」。

名词解释

强缓存(Strong / Force Cache):浏览器根本不发起网络请求,直接用本地副本。它的「判定方法」是看响应头 Cache-Control: max-age=N 或 Expires 是否还有效——有效就直接 200(from disk/memory cache)。优点:最快(零网络);缺点:资源更新后用户可能拿到旧的(所以用 hash 文件名破局)。

协商缓存(Conditional / Revalidation Cache):浏览器还是会发请求,但带上「我本地这份的版本标识」(ETag 或 Last-Modified),服务器判断「没变」就回 304(空体),浏览器继续用本地。方法:If-None-Match: <etag>(问「和这个指纹一样吗」)、If-Modified-Since: <时间>。它是「强缓存过期后」的第二道防线。

CDN(内容分发网络,Content Delivery Network):把内容缓存到离用户近的「边缘节点」的网络。方法/能力:就近解析(DNS 把用户导向最近节点)、边缘缓存(节点存副本)、回源(未命中时去源站取)。你可以把它想成「在家门口开了个便利店,不用每次都跑总仓」。

课后练习

练习 1:你的网站把 app.js 设了 Cache-Control: max-age=3600,一小时后你改了代码重新部署,但用户反馈「还是旧逻辑」。可能原因和解决办法?

答案:强缓存一小时内未过期,浏览器直接用本地旧 app.js,不请求服务器,所以拿到旧的。若文件名不带 hash,用户得等一小时才更新。正确做法:构建时给文件名加内容 hash(app.ab12cd.js),改代码 hash 变 → URL 变 → 浏览器当新资源重新请求;同时 HTML 用 no-cache 保证每次拿到最新引用。

练习 2:ETag 和 Last-Modified 都能做协商,为什么更推荐 ETag?

答案:① Last-Modified 精度到「秒」,1 秒内多次改动会误判为未变;② 某些内容没变但修改时间变了(如定时重新生成),Last-Modified 会误判为变;③ ETag 是「内容指纹」(如 md5),内容一字未改就命中,最准确。所以规范推荐以 ETag 为准(两者可共存,ETag 优先)。

总结

缓存是前端性能里「投入产出比最高」的一环,但也是最容易被误解的一环。我的观点是:很多人把缓存当成「设个 max-age 就完事」,却没理解「强缓存和协商缓存是两套独立机制,且强缓存会让更新『失联』」。真正稳健的工程做法是「带 hash 的资源用一年强缓存,HTML 用 no-cache 每次协商」——让「不变的东西永不重下,变的东西永远最新」。CDN 则是把这个思路放大到地理尺度:把副本放到用户家门口。学完这节你应建立的直觉是:缓存不是「要不要」,而是「哪类资源用哪种策略」,这是每个上线项目都绕不开的硬功夫。