陌上人如玉
公子世无双

5.16 网络优化与资源提示

5.16 网络优化与资源提示

一句话:网络优化的三件事是「用对资源提示(preconnect/preload/prefetch,别滥用)+ 配好 HTTP 缓存(hash 资源一年 immutable、HTML 用 no-cache、stale-while-revalidate 兜住过期)+ 用 Service Worker 做分层缓存(外壳/资源/数据各用不同策略)」。

一、资源提示:四个 rel 的分工

提示 做什么 什么时候用 代价
dns-prefetch 只解析 DNS 早期就写(兼容性最好) 极小
preconnect DNS + TCP + TLS 确定会用的第三方源(CDN、字体、API) 占一个连接(别超过 4~6 个)
preload 提前下载「当前页面」要用的资源 关键路径上「发现晚」的资源(LCP 图、首屏字体) ⚠️ 抢占带宽;未使用会浪费
prefetch 空闲时下载「将来导航」要用的资源 下一页的 chunk、用户可能点的路由 低优先级;别太多
modulepreload preload 并解析 ES module 的依赖树 入口 chunk / 关键动态 import 比 preload 更适合 ESM
<!-- ✅ 一个「正确使用」的完整示例 -->
<head>
  <!-- ① 第三方源:提前建连(dns-prefetch 兜底 + preconnect 加速) -->
  <link rel="dns-prefetch" href="//cdn.example.com" />
  <link rel="preconnect" href="https://cdn.example.com" crossorigin />

  <!-- ② 首屏字体:preload(⚠️ 必须 crossorigin,字体是 CORS 请求) -->
  <link rel="preload" href="/fonts/main-subset.woff2" as="font" type="font/woff2" crossorigin />

  <!-- ③ LCP 图片:preload(⚠️ 必须带 imagesrcset/imagesizes,否则可能下载两份) -->
  <link rel="preload" as="image"
        imagesrcset="/img/hero-800.webp 800w, /img/hero-1600.webp 1600w"
        imagesizes="100vw" fetchpriority="high" />

  <!-- ④ 入口脚本:modulepreload(对 ESM 比 preload 更好) -->
  <link rel="modulepreload" href="/assets/index-abc123.js" />

  <!-- ⑤ 下一页的 chunk:prefetch(空闲时下,别在首屏就下) -->
  <link rel="prefetch" href="/assets/checkout-def456.js" as="script" />
</head>
⭐⭐⭐ `preload` 的四个「经典坑」(每一个都真实发生过):

① 【preload 了但没用到】(最浪费)
   浏览器会在控制台警告:
     "The resource ... was preloaded using link preload but not used within a few seconds"
   原因:预加载了「条件渲染才用的资源」(如某个 Modal 的脚本),
        而用户没打开它。
   ✅ 只 preload「**这次导航确定会用**」的资源。

② 【字体 preload 忘了 `crossorigin`】
   → 浏览器认为「preload 的那个」与「CSS 里请求的那个」不是同一资源 → **下载两次**
   ✅ <link rel="preload" href="/f.woff2" as="font" type="font/woff2" crossorigin>
   ⭐ 记忆点:**字体、fetch、以及任何跨域资源,preload 都要 `crossorigin`**。

③ 【图片 preload 只写 `href`,不写 `imagesrcset`】
   → preload 阶段用「单一 URL」,而实际渲染时 `srcset` 可能选中另一张 → **下载两次**
   ✅ 带上 `imagesrcset` + `imagesizes`(与 `<img>` 上的一致)。

④ 【preload 太多 → 抢走 LCP 的带宽】
   ⚠️ `preload` 是「高优先级」,你 preload 10 个资源,它们会一起挤占带宽,
      反而让真正的 LCP 资源更慢。
   ✅ 只 preload「1~2 个」真正关键的东西(通常是一个字体 + 一张 LCP 图)。
⭐⭐ `prefetch` 与 `preload` 的本质区别:

   · **`preload`**:**当前页面**要用 → **高优先级**(与「正常加载」同量级)→ 抢占带宽
   · **`prefetch`**:**将来的导航**要用 → **最低优先级**(只在空闲时下载)→ 不抢
     ⚠️ prefetch 的资源会被放进 **HTTP 缓存**(而不是 memory cache),
        所以「下次导航」时才真正受益。

   ⭐ 所以:「首屏关键资源用 preload,下一跳资源用 prefetch」。
<!-- ⭐ 现代替代:Speculation Rules API(预取 + 预渲染,Chrome 109+) -->
<script type="speculationrules">
{
  "prerender": [
    { "source": "list", "urls": ["/checkout"], "eagerness": "moderate" }
  ],
  "prefetch": [
    { "source": "document", "where": { "href_matches": "/products/*" }, "eagerness": "conservative" }
  ]
}
</script>
⭐⭐ `prerender`(预渲染)与 `prefetch`/`preload` 的区别:

   · preload/prefetch 只处理「**资源**」
   · **prerender** 会在后台**完整渲染一个页面**(包括 JS 执行、子资源加载)
     → 用户点进去时**几乎是瞬间显示**
   · `eagerness`(急迫程度)控制「什么时候开始」:
     conservative(hover 才做)→ moderate(hover 一小会)→ eager(立刻)
   ⚠️ 代价:预渲染会消耗大量资源(CPU/内存/带宽)→ 只对「高概率的下一跳」用

   ⭐ `rel="prerender"`(老的写法)已废弃,改用 **Speculation Rules**。
   ⭐ 预渲染页面里的 `document.prerendering` 为 true,
      可以做「预渲染时先不请求分析/广告」这类优化。

二、Priority Hints:告诉浏览器「谁更重要」

<!-- ✅ fetchpriority 可用在 img / script / link / iframe 上 -->
<img src="hero.webp" fetchpriority="high" alt="主视觉" />       <!-- LCP 图 -->
<img src="related.webp" fetchpriority="low" alt="相关推荐" />   <!-- 屏幕外 -->

<!-- ⭐ 用于 preload 时也能「降低」优先级(避免抢 LCP 带宽) -->
<link rel="preload" as="image" href="/secondary.webp" fetchpriority="low" />

<!-- ⭐ 用于 script:让「非关键脚本」不要抢首屏带宽 -->
<script src="/analytics.js" fetchpriority="low" async></script>
⭐⭐ 三个「优先级」使用的判断点:

① 【只给 LCP 资源设 high】
   「全部设 high」=「都没设」——浏览器会在同一优先级里按顺序执行。

② 【用 low 把「非关键」压下去】
   · 埋点/监控脚本 → low
   · 屏幕外图片 → low
   · 预加载的「次要资源」→ low
   → ⭐ 这比「提升关键资源」更有效(因为总带宽是有限的,降低竞争收益更大)。

③ 【与 preload 的配合】
   如果你 preload 了「次要资源」,一定给它 `fetchpriority="low"`,
   否则它会以高优先级抢占 LCP 资源的带宽。

三、103 Early Hints:让「等待后端」的时间也在加载资源

# ✅ 服务端(在最终响应准备好之前先发 103)
HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </fonts/main.woff2>; rel=preload; as=font; crossorigin

HTTP/1.1 200 OK
Content-Type: text/html
...
⭐⭐ 103 解决的问题:

   场景:SSR 的 TTFB 是 600ms(服务端要查数据库/调后端)。
   问题:这 600ms 里,浏览器**什么都不能做**——因为它还不知道要加载哪些资源。
   解法:服务端「一收到请求就立刻」发一个 **103 Early Hints**,
        带上「即将需要的资源」的 `Link` 头 →
        ⭐ 浏览器在「服务端还在算」的这 600ms 里就已经开始下载 CSS/字体了。
   → 相当于把「TTFB」与「资源下载」**并行**起来。

⭐ 适用与注意:
   · 最适合「TTFB 长」的页面(SSR + 后端聚合、边缘计算)
   · 需要服务端/CDN 支持(Nginx、Next.js、Cloudflare 等多数已支持)
   · ⚠️ 只能「提示」不能「保证」(浏览器可能忽略)
   · ⚠️ 别一次发几十条(与 preload 滥用同理)

四、HTTP 缓存:把「不用重新下载」做到极致

指令 含义 典型用法
max-age=N N 秒内直接用缓存(不发请求) 静态资源
s-maxage=N 只对共享缓存(CDN)生效,覆盖 max-age 让 CDN 缓存更久
immutable 在 max-age 内不重新验证(连刷新都不验证) ⭐ 带 hash 的资源
no-cache 可以缓存,但每次必须向服务端验证 HTML
no-store 完全不缓存(也不写入) 敏感接口
private 只允许浏览器缓存(不允许 CDN) 用户数据
public 允许任何缓存(含 CDN) 公共资源
stale-while-revalidate=N 过期后先返回旧内容,同时后台更新(N 秒内允许) ⭐ 数据接口
stale-if-error=N 源站错误时,用旧内容兜底(N 秒内) 提升可用性
must-revalidate 过期后必须验证(不允许用陈旧内容) 一致性要求高
⭐⭐⭐ 「黄金组合」(这是缓存策略的标准答案):

① 【带内容 hash 的静态资源】
   Cache-Control: public, max-age=31536000, immutable
   → ⭐ 「一年 + immutable」+「文件名带 hash」= 
     「内容变了文件名就变 → 永远不会读到旧内容」+「永远不用重新验证」
   → 这是**零成本的最优缓存**

② 【HTML】
   Cache-Control: no-cache
   → 必须「每次向服务端验证」(用 ETag → 304 时几乎零成本)
   ⚠️ 不要给 HTML 设长 max-age(会导致「用户看到旧版本」的经典事故)

③ 【数据接口(可容忍短暂陈旧)】
   Cache-Control: private, max-age=0, stale-while-revalidate=60
   → 或者交给前端缓存层(React Query 的 staleTime,见 7.4)

④ 【CDN 层的「双层」缓存】
   Cache-Control: public, s-maxage=600, max-age=60
   → CDN 缓存 10 分钟(s-maxage),浏览器只缓存 1 分钟(max-age)
   → ⭐ 这样「回源压力」与「用户拿到的陈旧程度」都能控制
⭐⭐ `stale-while-revalidate` 为什么重要(它是「体验」与「一致性」的平衡点):

   问题:`max-age=60` 的数据,60 秒后就「必须重新请求」→ 第 61 秒的用户
        要等一个 RTT(几百毫秒)才能看到内容。
   解法:`max-age=60, stale-while-revalidate=600`
        → 第 61 秒的用户 **立刻看到旧内容**,同时浏览器**后台请求新版本**,
          下一个用户就看到新的了。
   ⭐ 结果:**几乎所有人都不会等**,且「陈旧窗口」被限制在 10 分钟内。

   ⚠️ 注意:`stale-while-revalidate` 是「**浏览器/CDN 的缓存层**」行为,
      如果你在前端用 React Query 之类的库,「同样的语义」是由库实现的
      (`staleTime` + 后台 refetch)——两者可以叠加(见 7.4)。
⭐⭐ 两个「验证型」缓存(304)的机制(面试常问):

   【ETag / If-None-Match】
     服务端:`ETag: "abc123"`
     下一次请求:`If-None-Match: "abc123"`
     → 没变 → 返回 **304 Not Modified**(**无响应体**)
   【Last-Modified / If-Modified-Since】
     精度是「秒」,且如果「内容变了但修改时间没变」(或反之)会出错
   ⭐ 优先级:**ETag 优先于 Last-Modified**(更精确)

   ⭐ 304 的成本:仍然要「一个完整的往返」(RTT)。
      所以「304 优于重新下载,但劣于『不请求』」——
      这就是为什么「hash 文件名 + 长 max-age + immutable」是最优解
      (它连请求都不用发)。

五、HTTP/2 与 HTTP/3:从「投机优化」到「正常写」

⭐⭐ 协议演进改变了「优化手段的适用性」:

【HTTP/1.1】
  问题:一个连接同时只能处理一个请求(队头阻塞)
  当时的手段(⭐ 现在很多变成反模式):
    · 域名分片(domain sharding)→ 多个域名并行
    · 雪碧图(CSS sprite)→ 合并小图为一张
    · 内联小图(base64)→ 减少请求数
    · 合并 JS/CSS 文件 → 减少请求数

【HTTP/2】
  ✅ 多路复用(一个连接上并行多个请求流)→ 队头阻塞(应用层)基本解决
  ✅ 头部压缩(HPACK)→ 重复的 header 不再重复传输
  ⭐ 所以:
    · 域名分片 → ❌ 反模式(反而是「放弃多路复用」还多建连接)
    · 雪碧图 → ⚠️ 收益下降(但仍可减少请求)→ 现在多用 SVG sprite(按需)
    · 合并所有 JS 成一个文件 → ❌ 反模式(破坏缓存粒度,见 5.14)

【HTTP/3】
  ✅ 基于 QUIC(UDP):解决 **TCP 层的队头阻塞**(丢包不再阻塞其它流)
  ✅ 0-RTT / 1-RTT 建连(重连更快)
  ✅ 连接迁移(切换 WiFi/4G 不断连)
  ⚠️ 部分企业网络会「屏蔽 UDP」→ 需要「回退到 H2」的能力
曾经的优化 H1 H2/H3 现在该怎么做
域名分片 ✅ ❌ 反而有害 同一个源
合并所有 JS ✅ ⚠️ 破坏缓存 按「变更频率」拆分
CSS 雪碧图 ✅ ⚠️ 收益低 SVG sprite / 图标组件
内联小图 base64 ✅ ⚠️ 阻塞 HTML 解析 只内联「极小且必需」的
CDN ✅ ✅ 依然重要

六、完整实现:一个 210 行的 Service Worker 缓存策略库

// sw.js —— 分层缓存策略(外壳 / 资源 / 数据 各用不同策略)
// ⚠️ 这是一个「可直接用」的实现(不依赖 Workbox),生产环境也可考虑 Workbox。

const VERSION = 'v3';
const CACHE_NAMES = {
  shell: `shell-${VERSION}`,      // HTML 外壳(navigation)
  assets: `assets-${VERSION}`,    // 带 hash 的静态资源
  images: `images-${VERSION}`,    // 图片
  data: `data-${VERSION}`,        // API
};

/** 需要「离线可用」的最少外壳资源 */
const PRECACHE_URLS = ['/', '/offline.html'];

/** ============ 安装:预缓存外壳 ============ */
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAMES.shell);
      await cache.addAll(PRECACHE_URLS);
      // ⚠️ 这里【不】调用 skipWaiting —— 让「新版本」等用户刷新后再接管,
      //    避免「页面资源与 SW 版本不一致」导致的白屏(见下方「特有的坑」)
    })()
  );
});

/** ============ 激活:清掉旧版本缓存 ============ */
self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      await Promise.all(
        keys
          .filter((k) => !Object.values(CACHE_NAMES).includes(k))
          .map((k) => caches.delete(k))
      );
      // ⭐ 让「下一次导航」立即用新 SW(而不是再等一次刷新)
      await self.clients.claim();
    })()
  );
});
/** ============ 三种缓存策略 ============ */

/** ① cache-first:静态资源(带 hash → 内容不会变,命中就是命中) */
async function cacheFirst(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (response.ok && response.type === 'basic') {
    cache.put(request, response.clone());
  }
  return response;
}

/** ② network-first:HTML / 需要「尽量新」的内容 */
async function networkFirst(request, cacheName, timeoutMs = 3000) {
  const cache = await caches.open(cacheName);

  try {
    // ⭐ 给网络加超时(避免弱网下长时间白屏)
    const response = await fetchWithTimeout(request, timeoutMs);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;
    // ⭐ 兜底:离线页面
    const offline = await caches.match('/offline.html');
    if (offline) return offline;
    return new Response('离线,且没有缓存', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
  }
}

/** ③ stale-while-revalidate:先给旧的,同时后台更新(图片/数据最常用) */
async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  // ⭐ 后台更新(不 await,让「当前请求」立刻返回)
  const networkPromise = fetch(request)
    .then((response) => {
      if (response.ok && response.type === 'basic') {
        cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => null);

  if (cached) {
    // ⭐ 注意:用 event.waitUntil 保证「后台更新」不被中断
    return cached;
  }

  const response = await networkPromise;
  if (response) return response;
  return new Response('', { status: 504 });
}

function fetchWithTimeout(request, ms) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('timeout')), ms);
    fetch(request).then(
      (res) => { clearTimeout(timer); resolve(res); },
      (err) => { clearTimeout(timer); reject(err); }
    );
  });
}
/** ============ 路由分发 ============ */
self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;                     // ⚠️ 只处理 GET

  const url = new URL(request.url);

  // ⭐① 非本域:直接放行(不缓存第三方,避免「跨域 opaque 响应」的坑)
  if (url.origin !== self.location.origin) return;

  // ⭐② 带 hash 的构建产物(/assets/xxx-abc123.js|css)→ cache-first
  if (/\/assets\/.*\.[0-9a-f]{6,}\.(js|css|woff2?)$/.test(url.pathname)) {
    event.respondWith(cacheFirst(request, CACHE_NAMES.assets));
    return;
  }

  // ⭐③ 图片 → stale-while-revalidate(用户对「旧图」不敏感)
  if (/\.(png|jpe?g|webp|avif|gif|svg)$/i.test(url.pathname)) {
    event.respondWith(
      staleWhileRevalidate(request, CACHE_NAMES.images).then((response) => {
        // ⭐ 图片缓存命中时,用 event.waitUntil 保证「后台更新」有机会完成
        return response;
      })
    );
    return;
  }

  // ⭐④ API → network-first(数据要新)+ 短超时
  if (/^\/api\//.test(url.pathname)) {
    event.respondWith(networkFirst(request, CACHE_NAMES.data, 2500));
    return;
  }

  // ⭐⑤ 导航请求(HTML)→ network-first + 离线兜底
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request, CACHE_NAMES.shell, 3000));
    return;
  }

  // 其余:走默认网络
});
/** ============ 更新流程(避免「资源与 SW 版本不一致」) ============ */
// 页面侧:监听 SW 更新,提示用户刷新
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');

  navigator.serviceWorker.addEventListener('controllerchange', () => {
    // ⭐ SW 换了 → 页面上的资源可能已经不匹配 → 只在「用户主动刷新」时重载
    // 这里用「一次性标志」避免无限刷新
    if (window.__refreshOnce) return;
    window.__refreshOnce = true;
    location.reload();
  });

  navigator.serviceWorker.ready.then((registration) => {
    // ⭐ 检查更新(可在「路由切换时」或「定时」执行)
    registration.update();

    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      newWorker?.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // 有新版本「等待中」→ 提示用户(或自动切换)
          showUpdateToast(() => {
            newWorker.postMessage({ type: 'SKIP_WAITING' });
          });
        }
      });
    });
  });
}

// SW 侧:响应 SKIP_WAITING 消息
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
⭐⭐⭐ 五条「SW 策略」的关键判断:

① 【带 hash 的资源永远用 cache-first】
   因为「文件名带 hash」= 「内容永不变」→ 命中就是最优,不需要网络。

② 【HTML 用 network-first(而不是 cache-first)】
   否则用户会「一直看到旧版本」(因为 HTML 被缓存了,它引用的还是旧的 chunk)。
   ⚠️ 这是 SPA 更新的经典事故:「index.html 被缓存 → 指向旧 chunk」。

③ 【图片用 stale-while-revalidate】
   图片「旧一点」无感知,而「立刻显示」的价值很大。

④ 【不要一开始 `skipWaiting()`】
   立即接管会导致「页面上正在使用的旧 chunk」与「新 SW 缓存的资源」不匹配
   → 用户点了按钮报「ChunkLoadError」(见下)
   ✅ 做法:新 SW 进入 waiting → 提示用户「有新版本,点击刷新」
      → 用户点击后 `skipWaiting()` + reload

⑤ 【只缓存同源 GET】
   ⚠️ 跨域响应通常是 `opaque`(`status` 读不到、`ok` 为 false),
      缓存它们容易出问题;非 GET(POST/PUT)本来就不该缓存。

七、本篇特有的坑

<!-- ① preload 了没用的资源(浪费带宽 + 控制台警告) -->
<link rel="preload" as="script" href="/heavy-modal.js" />   <!-- ⚠️ 用户没打开 Modal -->
<!-- ✅ 只 preload「本次导航确定会用」的 -->

<!-- ② 字体 preload 忘了 crossorigin → 下载两次 -->
<link rel="preload" href="/f.woff2" as="font">                          <!-- ❌ -->
<link rel="preload" href="/f.woff2" as="font" type="font/woff2" crossorigin>  <!-- ✅ -->

<!-- ③ preload 缺 `as` → 浏览器不知道类型 → 可能重复下载 -->
<link rel="preload" href="/app.js">                        <!-- ❌ -->
<link rel="preload" href="/app.js" as="script">            <!-- ✅ -->

<!-- ④ preload 太多 → 抢 LCP 的带宽 -->
<!-- ✅ 1~2 个;次要的加 fetchpriority="low" -->

<!-- ⑤ preconnect 开太多(每个都占连接) -->
<link rel="preconnect" href="https://a.com">
<link rel="preconnect" href="https://b.com">  <!-- ⚠️ × 6 个 = 资源浪费 -->
<!-- ✅ 只 preconnect「真正会用且握手成本高」的 2~4 个源 -->
⑥ 给 HTML 设长 max-age(用户长期看到旧版本)
   ✅ HTML 用 `Cache-Control: no-cache`(配合 ETag → 304)
   ⚠️ 另一个常见变体:「Nginx 默认给所有文件加 expires 1y」
      → HTML 也被缓存一年 → 必须按类型区分

⑦ 忘记「带 hash 的资源」才能用 `immutable`
   ⚠️ 如果文件名【不带 hash】而用了 `max-age=1y, immutable`,
      你就**永远无法更新**那个文件了(用户不会重新请求)
   ✅ 「hash 文件名」与「长缓存」是绑定的一对

⑧ CDN 缓存了「用户相关」的响应(数据串用户)
   ✅ `Cache-Control: private` 或 `Vary`(但 `Vary` 的维度不要太多)
   ⚠️ 尤其注意「带 locale / 登录态 / A/B 分组」的响应:
      一定要在「缓存键」里体现(或用 `s-maxage` + `Vary`)

⑨ CDN 的缓存键包含了「无关参数」(`?utm_source=...`)
   → 同一个页面被缓存成 N 份(命中率暴跌)
   ✅ 在 CDN 配置里「忽略无关 query 参数」

⑩ 用 `no-store` 来处理「不想缓存」的一切
   ⚠️ 它会「完全不缓存」,导致每次都要完整下载
   ✅ 「可以缓存但要验证」用 `no-cache`;
      「敏感数据」才用 `no-store`

⑪ ST 只说「用 CDN」但不知道「分层」
   ⭐ 正确说法是「浏览器缓存 + CDN 边缘缓存 + 源站」三层,
      并用 `max-age`/`s-maxage` 分别控制(见黄金组合 ④)

⑫ SW 一开始就 `skipWaiting()` → ChunkLoadError
   ⚠️ 新 SW 立刻接管并清了旧缓存,但**当前页面**还在用旧 chunk 的引用
      → 用户点按钮时「找不到旧 chunk」→ 报错
   ✅ 提示用户刷新(或「等所有 tab 关闭后再接管」)

⑬ SW 缓存了「非 GET 请求」或「跨域 opaque 响应」
   ⚠️ opaque 响应的 `status` 是 0、`ok` 为 false → 判断逻辑会错
   ✅ 只处理同源 GET

⑭ 没有「离线兜底」却注册了 SW(离线时白屏)
   ✅ `precache` 一个 `/offline.html`,在 network-first 失败时返回它

⑮ 以为「HTTP/2 之后『域名分片』仍然有用」
   ⚠️ H2 下它反而有害(放弃了多路复用 + 多建连接)
   ✅ 同一个源;把「优化请求数」的心思转到「优化缓存粒度」上

⑯ 更新 SW 后忘记「清理旧版本缓存」(占满存储)
   ✅ `activate` 里删掉不在当前版本白名单里的 cache
   ⚠️ 还要注意「浏览器存储配额」(超了会被整体清除)

⑰ 只看 TTFB 不看「资源发现时间」
   ⭐ LCP 的四段里,「资源加载延迟」往往比 TTFB 更容易优化(preload/early hints)
   (见 5.11 的 LCP 拆解)

⑱ `stale-while-revalidate` 用在了「一致性要求高」的接口上
   (如「账户余额」「订单状态」)
   ⚠️ 用户可能看到 10 分钟前的数据 → 这类接口用 `no-cache` 或短 max-age
⭐ ⑫ 的详细说明(这是 SW 最容易出的线上事故):

   时序:
   ① 用户打开页面 → 用 v3 的 SW + v3 的 chunk
   ② 你发布了 v4 → SW 更新,且 `skipWaiting()` 立刻接管
   ③ v4 的 SW 在 `activate` 里**删掉了 v3 的缓存**
   ④ 但用户**当前页面**还是 v3 的 HTML/JS,
      里面有个懒加载 chunk 的 URL 是 `chunk-v3-abc.js`
   ⑤ 用户点了按钮 → 触发动态 import → 请求 `chunk-v3-abc.js`
   ⑥ 缓存里已经被删了 → 从网络请求 → 服务器上只有 v4 → **404**
   ⑦ 用户看到 "Failed to fetch dynamically imported module" / "ChunkLoadError"

   ✅ 三种缓解方式:
     a. **不立即接管**:新 SW 等待,提示用户「刷新以更新」
     b. **保留旧版本缓存一段时间**(delayed cleanup)
     c. **监听 `vite:preloadError` / `unhandledrejection`**,
        遇到 ChunkLoadError 时**自动 reload 一次**
        → ⭐ 这是「兜底」的必备手段(即使 a/b 都做了也建议有)
// ✅ c 的实现(Vite 项目)
window.addEventListener('vite:preloadError', () => {
  // ⭐ 只在「第一次」失败时刷新,避免无限循环
  const KEY = 'chunk-reload-at';
  const last = Number(sessionStorage.getItem(KEY) ?? 0);
  if (Date.now() - last > 10_000) {
    sessionStorage.setItem(KEY, String(Date.now()));
    location.reload();
  }
});

八、面试延伸

  1. 「preload / prefetch / preconnect / dns-prefetch 有什么区别?」
    按「做多少事」递增:① dns-prefetch——只做 DNS 解析(最便宜,兼容性最好,可作为兜底);② preconnect——DNS + TCP + TLS(省掉完整的建连 RTT,适合「确定会用的第三方源」,但别开太多,每个都占连接);③ preload——提前下载当前页面要用的资源(高优先级,会抢带宽 → 只用于「1~2 个关键资源」,如 LCP 图与首屏字体);④ prefetch——空闲时下载「将来导航」要用的资源(最低优先级,放进 HTTP 缓存,下次导航受益)。另有 modulepreload——对 ES module 更优(会提前解析依赖树)。

  2. 「preload 有哪些坑?」
    四个经典坑:① preload 了但没用到(控制台会警告 “preloaded but not used”,白费带宽);② 字体 preload 忘记 crossorigin——字体是 CORS 请求,不带 crossorigin 会被视为「不同资源」→ 下载两次;③ 图片 preload 只写 href 不写 imagesrcset/imagesizes——preload 用「单一 URL」而渲染时 srcset 可能选另一张 → 下载两次;④ preload 太多——它是高优先级,会抢占 LCP 资源的带宽(次要资源应加 fetchpriority="low")。

  3. 「HTTP 缓存怎么配?」
    按「资源类型」分三类:① 带内容 hash 的静态资源 → Cache-Control: public, max-age=31536000, immutable(⭐ hash 文件名 + 一年 + immutable = 永远不会读到旧内容,也永远不用验证);② HTML → Cache-Control: no-cache(必须每次验证,配 ETag → 304 几乎零成本;⚠️ 绝不能给 HTML 长 max-age,否则用户长期看到旧版本);③ 数据接口 → private, max-age=0, stale-while-revalidate=60(先返回旧的、后台更新)。CDN 层用 s-maxage 控制共享缓存时长,形成「浏览器 / CDN / 源站」三层。

  4. 「stale-while-revalidate 是什么?为什么有用?」
    含义是「缓存过期后,允许在 N 秒内先返回陈旧内容,同时后台请求新版本」。为什么有用:没有它时,max-age=60 的数据在第 61 秒就「必须重新请求」——用户要等一个 RTT 才能看到内容;有了它,第 61 秒的用户立刻看到旧内容、后台更新,下一个用户就看到新的了。结果是「几乎没有人需要等待,而陈旧窗口被限制在可接受范围内」。⚠️ 但它不适合「一致性要求高」的数据(账户余额、订单状态)。

  5. 「HTTP/2 之后,哪些前端优化变成了反模式?」
    三个:① 域名分片(domain sharding)——H2 有多路复用,分片反而「放弃复用 + 多建连接」,纯负收益;② 把所有 JS 合并成一个文件——H2 不需要「减少请求数」了,而合并破坏了缓存粒度(改一行 → 用户重新下载全部);③ 雪碧图/内联 base64 的收益显著下降(前者仍能减少请求但可用 SVG sprite 替代,后者会阻塞 HTML 解析,只应内联「极小且必需」的东西)。优化的重心从「减少请求数」转移到了「优化缓存粒度与优先级」。

  6. 「Service Worker 的缓存策略怎么设计?」
    分三层:① 静态资源(带 hash)→ cache-first(内容不变,命中就是最优);② HTML/导航 → network-first + 离线兜底(⚠️ HTML 用 cache-first 会导致用户一直看到旧版本);③ 图片/数据 → stale-while-revalidate(旧一点无感知,立刻显示价值大)。⚠️ 两个要点:只处理同源 GET(跨域响应是 opaque,ok 为 false 容易判断错);不要一开始 skipWaiting(),否则会出现「当前页面用旧 chunk,但缓存的旧 chunk 已被删」→ ChunkLoadError。

  7. 「SW 更新后用户报 ChunkLoadError,是什么原因?」
    时序是:① 用户用 v3 打开页面;② 你发布 v4,SW 立即 skipWaiting() 接管;③ v4 的 SW 在 activate 里删掉了 v3 的缓存;④ 但当前页面仍是 v3 的代码,某个懒加载 chunk 指向 chunk-v3.js;⑤ 用户点按钮触发动态 import → 缓存已删 → 从网络请求 → 服务器只有 v4 → 404。解法三层:① 不立即接管(新 SW 进 waiting,提示用户刷新);② 延迟清理旧缓存;③ ⭐ 监听 vite:preloadError / unhandledrejection,遇到 ChunkLoadError 自动 reload 一次(带「防无限刷新」的时间戳判断)——这是必备兜底。

  8. 「103 Early Hints 解决什么问题?」
    解决「服务端还在算的时候,浏览器什么都不能做」的问题。典型场景:SSR 的 TTFB 是 600ms(后端聚合查询慢),而浏览器在这 600ms 里还不知道要加载哪些资源。103 的做法是「服务端一收到请求就立刻返回一个 103 Early Hints 响应,带上 Link: </style.css>; rel=preload; as=style 这类头」→ ⭐ 浏览器在「服务端还在算」的这段时间里就已经开始下载 CSS/字体了,相当于把 TTFB 与资源下载并行。适用于「TTFB 长」的页面;注意只发「少数几条」关键资源提示。

一句话速记

资源提示的分工:dns-prefetch(只解析 DNS)→ preconnect(DNS+TCP+TLS,别超过 4~6 个)→ preload(当前页面关键资源,高优先级,只 preload 1~2 个)→ prefetch(下次导航用,最低优先级),ESM 用 modulepreload,现代替代是 Speculation Rules(prefetch/prerender);preload 四大坑:没用上、字体缺 crossorigin、图片缺 imagesrcset、preload 太多抢 LCP 带宽;Priority Hints 的关键是「给 LCP 设 high,把次要资源压成 low」(全部设 high = 都没设);103 Early Hints 让「服务端计算」与「资源下载」并行;HTTP 缓存的黄金组合是「带 hash 的资源 public, max-age=31536000, immutable + HTML no-cache(配 ETag→304)+ 数据 stale-while-revalidate」,CDN 层用 s-maxage 形成三层;H2/H3 之后「域名分片」「合并所有 JS」变成反模式,优化重心从「减少请求数」转到「缓存粒度 + 优先级」;SW 分层策略是「资源 cache-first、HTML network-first + 离线兜底、图片/数据 stale-while-revalidate」,只处理同源 GET、别立即 skipWaiting(否则 ChunkLoadError),并监听 vite:preloadError 自动 reload 一次兜底。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.16 网络优化与资源提示

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫