网络请求优化

中等 🟡性能优化
10 个标签
预计阅读时间:34 分钟
性能优化网络请求HTTP/2HTTP/3QUIC缓存策略CDNService WorkerBrotli资源提示

网络请求优化

网络请求是前端性能的关键瓶颈。一次典型的页面加载,用户看到内容的快慢,往往不取决于 JavaScript 执行了多少毫秒,而取决于"数据从服务器到浏览器"这条链路走得快不快。优化网络请求可以显著提升页面加载速度和用户体验。

概念:什么是"网络请求优化"

网络请求优化,指的是围绕"浏览器向服务器要资源"这个动作,从建立连接、传输数据、缓存复用、调度优先级四个维度做的一整套工程实践。

可以把浏览器加载一个页面类比成"点外卖":

DNS 解析 = 查商家电话(不知道 IP 就打不了电话);
TCP/TLS 握手 = 接通电话并确认身份(加密握手就像对暗号);
请求/响应传输 = 报菜名、等出餐、骑手送达;
缓存 = 冰箱里已经有的菜,不用再点;
优先级 = 先上主食还是先上饮料。

任何一环慢了,整顿饭都晚。网络优化就是把这五件事分别做到极致。

为什么重要:一个真实的数字账

先看一组被反复引用的行业数据,理解"为什么值得投入":

| 场景 | 数据来源 | 结论 |

| --- | --- | --- |

| 页面加载时间从 1s 增加到 3s | Google/SOASTA 研究 | 跳出率概率提升约 32% |

| 加载时间从 1s 到 5s | Google/SOASTA 研究 | 跳出率概率提升约 90% |

| 首页加载每慢 100ms | Amazon 经典结论 | 销售额下降约 1% |

| 搜索延迟增加 400ms | Google 实验 | 搜索量下降约 0.6% |

| 页面加载加快 850ms | Pinterest 重构 | 注册转化提升 15%,SEO 流量提升 15% |

这些数字说明:网络性能不是"技术洁癖",而是直接和留存、转化、营收挂钩的商业指标。

原理层:一次请求到底慢在哪

在优化之前,必须先看清一次 HTTPS 请求的时间构成。用 Chrome DevTools 的 Timing 面板,或用 Navigation Timing API 可以量化每一段:

jsCode
// 用 PerformanceNavigationTiming 拆解一次导航的各阶段耗时
const [nav] = performance.getEntriesByType('navigation');

const timings = {
  DNS:  nav.domainLookupEnd - nav.domainLookupStart,   // DNS 解析
  TCP:  nav.connectEnd - nav.connectStart,             // TCP + TLS 握手
  TLS:  nav.connectEnd - nav.secureConnectionStart,    // 仅 TLS 部分
  TTFB: nav.responseStart - nav.requestStart,          // 首字节时间
  下载: nav.responseEnd - nav.responseStart,           // 内容传输
  DOM:  nav.domContentLoadedEventEnd - nav.responseEnd,
};

console.table(timings);

一次全新的 HTTPS 请求,在 4G 网络(单程 RTT 约 50ms)下,光是"建立连接"就要付出的往返代价大致如下:

| 阶段 | 往返次数(RTT) | 4G 下耗时(约) | 说明 |

| --- | --- | --- | --- |

| DNS 查询 | 1 RTT | 50ms | 递归解析可能更久 |

| TCP 三次握手 | 1 RTT | 50ms | SYN / SYN-ACK / ACK |

| TLS 1.2 握手 | 2 RTT | 100ms | 完整握手 |

| TLS 1.3 握手 | 1 RTT | 50ms | 简化后省一次往返 |

| 首个请求响应 | 1 RTT | 50ms | 发请求到收首字节 |

也就是说,在 TLS 1.2 下,还没开始传第一个字节的正文,就已经花掉了约 250ms。这就是为什么"减少连接建立次数""复用连接""升级协议"如此关键。

HTTP 协议演进:1.1 vs 2 vs 3

HTTP/1.1 的核心问题

HTTP/1.1 是文本协议,每个 TCP 连接同一时刻只能处理一个请求-响应。虽然浏览器为同一域名开了 6 个并发连接来"曲线救国",但依然存在两大痛点:

队头阻塞(Head-of-Line Blocking):一个连接上,前一个响应没回来,后面的请求排队干等。
头部冗余:每个请求都重复携带完整的 Cookie、User-Agent、Accept 等,几十个请求就是几十份重复头部。

HTTP/2 的三大武器

1)多路复用(Multiplexing):HTTP/2 引入二进制分帧层,把请求和响应拆成带有流 ID 的帧,在单个 TCP 连接上交错传输。突破了 HTTP/1.1 应用层的队头阻塞,浏览器不再需要开 6 个连接,任意数量的请求可以在一条连接上并行,这是 HTTP/2 性能提升的核心。

2)头部压缩(HPACK):使用静态表(常见头部字段)、动态表(本连接出现过的字段)加哈夫曼编码,把重复头部压缩成索引。HTTP/1.1 里每个请求重复传的 Cookie,在 HTTP/2 里只需传一个索引号,大幅减少开销。

3)服务器推送(Server Push):服务器在返回 HTML 时可主动推送 CSS/JS。不过实践中命中率低、容易推送浪费,Chrome 已在 2022 年移除对它的支持,现代方案改用后文的 \`103 Early Hints\` + \`preload\`。

HTTP/3 与 QUIC

HTTP/2 解决了应用层队头阻塞,但它跑在 TCP 上——一旦某个 TCP 分片丢包,整条连接的所有流都要等重传,这叫TCP 层队头阻塞

HTTP/3 把传输层从 TCP 换成了基于 UDP 的 QUIC

消除 TCP 队头阻塞:QUIC 里每个流独立,一个流丢包不影响其他流。
0-RTT / 1-RTT 连接建立:QUIC 把传输握手和 TLS 1.3 握手合并。首次连接 1-RTT;对访问过的服务器可用 0-RTT 恢复,携带请求数据一起发出,几乎零等待。
连接迁移:用连接 ID 而非四元组标识连接,手机从 Wi-Fi 切到 4G,连接不中断。

三代协议对比

| 维度 | HTTP/1.1 | HTTP/2 | HTTP/3 |

| --- | --- | --- | --- |

| 传输层 | TCP | TCP | QUIC(UDP) |

| 多路复用 | 无(需多连接) | 有 | 有 |

| 队头阻塞 | 应用层有 | 应用层无、TCP层有 | 基本消除 |

| 头部压缩 | 无 | HPACK | QPACK |

| 连接建立(首次) | TCP+TLS 3RTT | TCP+TLS 3RTT | QUIC 1RTT |

| 连接恢复 | 需重新握手 | TLS会话复用 | 0-RTT |

| 弱网表现 | 差 | 一般 | 优秀 |

| 连接迁移 | 不支持 | 不支持 | 支持 |

真实案例:Cloudflare 在启用 HTTP/3 后,在高丢包的移动网络下,页面加载时间中位数改善明显,弱网环境下 P95 加载时间下降幅度尤为突出。Google、YouTube、Facebook 主站均已大规模部署 HTTP/3。

开启 HTTP/2 与 HTTP/3 的服务端配置

nginxCode
# Nginx 开启 HTTP/2(需 nginx 1.9.5+)
server {
    listen 443 ssl;
    http2 on;                      # nginx 1.25+ 新写法
    server_name example.com;

    ssl_certificate     /etc/nginx/ssl/example.crt;
    ssl_certificate_key /etc/nginx/ssl/example.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # 开启 HTTP/3 (QUIC),需 nginx 1.25.0+ 且编译了 quic 模块
    listen 443 quic reuseport;
    add_header Alt-Svc 'h3=":443"; ma=86400';   # 告知浏览器可升级到 h3
}

\`Alt-Svc\` 响应头是关键:浏览器首次用 HTTP/2 访问,看到这个头后,后续请求才会尝试升级到 HTTP/3。

缓存策略:最快的请求是不发请求

缓存是网络优化里性价比最高的一环。命中缓存意味着零网络往返。缓存分两大类:强缓存与协商缓存。

强缓存:完全不问服务器

强缓存命中时,浏览器直接用本地副本,Network 面板显示 \`(disk cache)\` 或 \`(memory cache)\`,状态码不发起真实请求。

httpCode
# 服务器响应头(强缓存,一年)
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
Content-Length: 84210
\`max-age=31536000\`:一年内直接用缓存。
\`immutable\`:告诉浏览器"这个文件永不改变",连用户刷新都不必发条件请求验证。
\`public\`:允许 CDN 等中间代理也缓存。

强缓存必须配合文件名哈希才安全:\`app.3f9a2c.js\`,内容一变哈希就变,文件名变了浏览器自然会请求新文件,从而实现"内容不变永久缓存、内容变了立即更新"。

协商缓存:问一句"变了没"

对 HTML 这类会变的资源,用协商缓存。浏览器带上标识去问服务器,没变就回 304(不带响应体),变了才回 200 带新内容。

httpCode
# 首次响应:服务器下发校验标识
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "5d8c72a5edda8-2c1e"
Last-Modified: Mon, 03 Aug 2026 08:00:00 GMT

# 再次请求:浏览器带上校验条件
GET /index.html HTTP/1.1
If-None-Match: "5d8c72a5edda8-2c1e"
If-Modified-Since: Mon, 03 Aug 2026 08:00:00 GMT

# 未变化:服务器回 304,无响应体,省下传输
HTTP/1.1 304 Not Modified
ETag: "5d8c72a5edda8-2c1e"

ETag(内容指纹,精确到字节)优先级高于 Last-Modified(精确到秒,秒内多次修改会漏判)。注意 \`Cache-Control: no-cache\` 不是"不缓存",而是"每次都要协商验证";真正的不缓存是 \`no-store\`。

强缓存 vs 协商缓存对比

| 维度 | 强缓存 | 协商缓存 |

| --- | --- | --- |

| 是否发请求 | 否 | 是(条件请求) |

| 相关头部 | Cache-Control / Expires | ETag / Last-Modified |

| 命中状态码 | 200 (from cache) | 304 Not Modified |

| 网络往返 | 0 | 1(但无响应体) |

| 适用资源 | 带哈希的静态资源 | HTML、频繁变动接口 |

Nginx 与 Node 缓存配置示例

nginxCode
# 带哈希的静态资源用强缓存 + immutable
location ~* \.(js|css|woff2|png|jpg|webp|avif)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

# HTML 入口用协商缓存,保证发版后立即生效
location = /index.html {
    add_header Cache-Control "no-cache";
    etag on;
}
jsCode
// Node/Express 手动实现协商缓存
const crypto = require('crypto');

app.get('/api/config', (req, res) => {
  const body = JSON.stringify(getConfig());
  const etag = '"' + crypto.createHash('md5').update(body).digest('hex') + '"';

  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('ETag', etag);

  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();   // 命中,无需重传
  }
  res.type('json').send(body);
});

压缩:让每个字节都值钱

减少传输体积最直接的手段是文本压缩。目前主流是 gzip 与 Brotli。Brotli(\`br\`)是 Google 推出的算法,在文本资源上通常比 gzip 再小 15%~25%。

nginxCode
# gzip
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;

# Brotli(需 ngx_brotli 模块)
brotli on;
brotli_comp_level 6;          # 动态压缩建议 4-6,兼顾 CPU 与压缩率
brotli_static on;             # 优先使用预压缩好的 .br 文件
brotli_types text/plain text/css application/javascript application/json image/svg+xml;

一个 300KB 的未压缩 JS bundle,实测各方式对比(数字随内容浮动,仅示意量级):

| 压缩方式 | 体积 | 相对原始 | 4G(5Mbps)下载耗时 |

| --- | --- | --- | --- |

| 不压缩 | 300 KB | 100% | 约 480ms |

| gzip -6 | 92 KB | 31% | 约 147ms |

| gzip -9 | 88 KB | 29% | 约 141ms |

| brotli -6 | 78 KB | 26% | 约 125ms |

| brotli -11 | 71 KB | 24% | 约 114ms |

最佳实践:静态资源在构建期预压缩(生成 \`.br\` 与 \`.gz\`),运行时由 \`brotli_static\` 直接下发,既拿到 brotli -11 的极致压缩率,又不消耗线上 CPU。动态内容再用等级 4~6 实时压缩。注意图片、视频等已压缩的二进制不要再套 gzip/brotli,白费 CPU。

资源提示:提前告诉浏览器该干什么

浏览器默认按发现顺序加载资源,但很多关键资源"发现得太晚"。资源提示(Resource Hints)让开发者显式指导浏览器。

htmlCode
<!-- dns-prefetch:提前解析第三方域名的 DNS,最轻量 -->
<link rel="dns-prefetch" href="//cdn.example.com" />

<!-- preconnect:提前完成 DNS + TCP + TLS 握手,为关键第三方省下整段连接时间 -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />

<!-- preload:高优先级预加载当前页必需的关键资源 -->
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/css/critical.css" as="style" />

<!-- modulepreload:预加载 ES 模块(含依赖解析),比 preload as=script 更贴合 ESM -->
<link rel="modulepreload" href="/js/app.js" />

<!-- prefetch:低优先级预取"下一个页面"可能用到的资源,空闲时下载 -->
<link rel="prefetch" href="/js/dashboard.chunk.js" />

选型速查:

| 提示 | 作用范围 | 优先级 | 典型场景 |

| --- | --- | --- | --- |

| dns-prefetch | 仅 DNS | 最低 | 众多第三方域名 |

| preconnect | DNS+TCP+TLS | 中 | 少数关键第三方(字体/CDN) |

| preload | 当前页资源 | 高 | 首屏字体、关键 CSS、LCP 图片 |

| modulepreload | 当前页 ES 模块 | 高 | 入口 JS 及其依赖 |

| prefetch | 未来页资源 | 最低(空闲) | 预判用户下一步 |

注意:\`preconnect\` 别滥用,每个连接都占资源,通常只对 2~4 个最关键的第三方域名使用;\`preload\` 的 \`as\` 必须写对,否则浏览器可能重复下载。

请求优先级 fetchpriority

即便是同类资源,重要性也不同。首屏的 LCP 图片应比页脚 logo 更早下载。\`fetchpriority\` 让开发者微调:

htmlCode
<!-- 首屏主图:拉高优先级,加速 LCP -->
<img src="/hero.avif" fetchpriority="high" alt="首屏主图" />

<!-- 折叠下方的次要图:降低优先级,把带宽让给关键资源 -->
<img src="/footer-logo.png" fetchpriority="low" loading="lazy" alt="logo" />
jsCode
// fetch 同样支持 priority
fetch('/api/critical-data', { priority: 'high' });
fetch('/api/analytics', { priority: 'low' });

103 Early Hints

现代替代 Server Push 的方案:服务器在生成完整 HTML 前,先回一个 \`103 Early Hints\` 响应,携带 \`preload\`/\`preconnect\`,让浏览器在后端还在渲染时就并行下载资源。

httpCode
HTTP/1.1 103 Early Hints
Link: </css/critical.css>; rel=preload; as=style
Link: </js/app.js>; rel=modulepreload

HTTP/1.1 200 OK
Content-Type: text/html
...完整 HTML...

减少请求与减少往返

减少请求数量

资源合并:小型 CSS/JS 合并(但在 HTTP/2 下过度合并反而不利缓存粒度,需权衡)。
雪碧图/SVG 雪碧:合并小图标,或直接用 SVG symbol、图标字体。
内联关键 CSS:把首屏必需的 CSS 内联进 HTML \`\`,消除一次阻塞渲染的往返。
减少第三方脚本:每个第三方 SDK 都是一条新连接 + 潜在的主线程阻塞。

请求去重与并发控制

前端常见问题:同一份数据被多个组件同时请求,产生重复请求;或短时间内发起上百个请求打爆浏览器连接数。

jsCode
// 请求去重:相同 key 的进行中请求共享同一个 Promise
const inflight = new Map();

function dedupeFetch(url, options) {
  const key = url + JSON.stringify(options || {});
  if (inflight.has(key)) return inflight.get(key);

  const p = fetch(url, options)
    .then((r) => r.json())
    .finally(() => inflight.delete(key));  // 完成后清理,避免拿到过期数据

  inflight.set(key, p);
  return p;
}
jsCode
// 并发控制:限制同时进行的请求数,避免瞬时打满连接
async function runWithConcurrency(tasks, limit = 6) {
  const results = [];
  const executing = new Set();

  for (const task of tasks) {
    const p = Promise.resolve().then(() => task());
    results.push(p);
    executing.add(p);
    p.finally(() => executing.delete(p));

    if (executing.size >= limit) {
      await Promise.race(executing);   // 等最快的一个完成再继续投放
    }
  }
  return Promise.all(results);
}

// 用法:1000 个请求,最多 6 个并发
const urls = Array.from({ length: 1000 }, (_, i) => '/api/item/' + i);
await runWithConcurrency(urls.map((u) => () => fetch(u)), 6);

批量合并请求(Batching)

把短时间内的多个细粒度请求合并成一个批量请求,减少往返次数:

jsCode
// 简易 DataLoader:同一 tick 内的 id 合并成一次批量查询
function createBatcher(batchFn, wait = 10) {
  let queue = [];
  let timer = null;

  return function load(id) {
    return new Promise((resolve, reject) => {
      queue.push({ id, resolve, reject });
      if (!timer) {
        timer = setTimeout(async () => {
          const current = queue; queue = []; timer = null;
          try {
            const ids = current.map((q) => q.id);
            const data = await batchFn(ids);           // 一次请求拿回全部
            current.forEach((q, i) => q.resolve(data[i]));
          } catch (e) {
            current.forEach((q) => q.reject(e));
          }
        }, wait);
      }
    });
  };
}

const loadUser = createBatcher((ids) => fetch('/api/users?ids=' + ids.join(',')).then((r) => r.json()));
// 组件各自 loadUser(1)/loadUser(2)/loadUser(3) → 实际只发一次 /api/users?ids=1,2,3

Service Worker 缓存策略

Service Worker 是运行在浏览器后台的代理,可拦截所有网络请求,实现离线可用与精细缓存。三种经典策略要按资源特性选择。

jsCode
// sw.js
const CACHE = 'v1';

self.addEventListener('fetch', (event) => {
  const { request } = event;

  // 策略1:Cache First —— 静态资源(带哈希),命中即返回,最快
  if (/\.(js|css|woff2|png|webp|avif)$/.test(request.url)) {
    event.respondWith(
      caches.match(request).then((cached) =>
        cached || fetch(request).then((res) => {
          const copy = res.clone();
          caches.open(CACHE).then((c) => c.put(request, copy));
          return res;
        })
      )
    );
    return;
  }

  // 策略2:Network First —— API 数据,优先新鲜,断网降级到缓存
  if (request.url.includes('/api/')) {
    event.respondWith(
      fetch(request)
        .then((res) => {
          const copy = res.clone();
          caches.open(CACHE).then((c) => c.put(request, copy));
          return res;
        })
        .catch(() => caches.match(request))   // 离线兜底
    );
    return;
  }

  // 策略3:Stale-While-Revalidate —— 先回缓存(秒开),后台静默更新
  event.respondWith(
    caches.match(request).then((cached) => {
      const network = fetch(request).then((res) => {
        const copy = res.clone();
        caches.open(CACHE).then((c) => c.put(request, copy));
        return res;
      });
      return cached || network;   // 有缓存立即用,同时后台刷新下次用
    })
  );
});

策略选型:

| 策略 | 首字节速度 | 数据新鲜度 | 离线可用 | 适用资源 |

| --- | --- | --- | --- | --- |

| Cache First | 最快 | 低 | 是 | 带哈希静态资源 |

| Network First | 慢 | 最高 | 是(降级) | 实时性强的 API |

| Stale-While-Revalidate | 快 | 中 | 是 | 头像、列表等可容忍轻微陈旧 |

连接层优化

DNS 优化

关键第三方域名用 \`dns-prefetch\`/\`preconnect\` 提前解析。
收敛域名数量,避免过多域名带来重复 DNS 解析。
合理设置 DNS TTL,选可靠的权威 DNS 服务商。

TCP 优化

开启 TCP Fast Open,握手阶段即可携带数据。
使用 Keep-Alive 持久连接,复用 TCP 连接避免反复握手。
调优初始拥塞窗口(initcwnd)与窗口大小。
nginxCode
# 开启长连接并调优
keepalive_timeout 65;
keepalive_requests 1000;

# 对上游(反向代理场景)也复用连接
upstream backend {
    server 127.0.0.1:3000;
    keepalive 32;              # 连接池,避免每次请求新建到上游的连接
}

TLS 优化

使用 TLS 1.3,握手从 2-RTT 降到 1-RTT,且支持 0-RTT 恢复。
开启会话复用(Session Resumption / Session Ticket)。
开启 OCSP Stapling,避免浏览器额外去查证书吊销状态。
精简证书链,用 ECDSA 证书替代 RSA 可减小握手体积。
nginxCode
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_stapling on;
ssl_stapling_verify on;
ssl_early_data on;            # 开启 TLS 1.3 0-RTT

TTFB 优化

首字节时间(TTFB)反映"服务器出餐"速度,受后端处理、数据库查询、缓存命中共同影响。前端侧能做的:用 CDN 就近命中边缘缓存、开启 Early Hints、静态化可缓存的 HTML;后端侧:加数据库索引、加内存缓存、异步化重逻辑。经验阈值:TTFB 建议控制在 200ms 以内,超过 600ms 需要重点排查。

CDN 与边缘计算

CDN(内容分发网络):把静态资源缓存到全球边缘节点,用户就近访问。核心收益是缩短物理距离带来的 RTT,同时分担源站压力、抵御流量洪峰。

边缘计算:不只是缓存静态文件,而是把计算逻辑(如 A/B 分流、鉴权、个性化 HTML 拼装、图片实时裁剪)下沉到离用户最近的边缘节点执行,兼顾动态内容的低延迟。代表产品有 Cloudflare Workers、Vercel Edge Functions、AWS Lambda@Edge。

jsCode
// Cloudflare Workers:在边缘节点做缓存与改写
export default {
  async fetch(request, env, ctx) {
    const cache = caches.default;
    let response = await cache.match(request);
    if (response) return response;               // 边缘命中,零回源

    response = await fetch(request);
    response = new Response(response.body, response);
    response.headers.set('Cache-Control', 'public, max-age=3600');
    ctx.waitUntil(cache.put(request, response.clone()));  // 异步写边缘缓存
    return response;
  },
};

真实案例:某电商把图片与静态资源迁移到 CDN 并开启边缘图片格式协商(按 \`Accept\` 头动态返回 AVIF/WebP/JPEG),首屏图片体积下降约 40%,海外用户 LCP 中位数从 3.8s 降到 1.9s。

监控与分析

只有可量化才能持续优化。工具链与关键指标:

| 类别 | 工具/API | 用途 |

| --- | --- | --- |

| 实验室数据 | Lighthouse、WebPageTest | 可复现的评分与瀑布图 |

| 现场数据(RUM) | web-vitals、Navigation Timing、Resource Timing | 真实用户网络表现 |

| 手动排查 | Chrome DevTools Network 面板 | 逐请求看 Timing 分段 |

| 服务端 | 访问日志、APM(New Relic 等) | TTFB、错误率、慢请求 |

jsCode
// 采集真实用户的核心网络指标并上报
import { onLCP, onTTFB, onINP } from 'web-vitals';

function report(metric) {
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    id: metric.id,
  }));
}
onTTFB(report);   // 首字节
onLCP(report);    // 最大内容绘制
onINP(report);    // 交互到下次绘制

关键指标清单:DNS 解析时间、TCP 连接时间、TLS 握手时间、首字节时间(TTFB)、内容传输时间、总加载时间。

常见坑

强缓存不加哈希:\`app.js\` 设了一年强缓存又没哈希,发版后用户永远拿旧版。必须内容哈希文件名。
误用 no-cache 当不缓存:\`no-cache\` 是每次协商,真正不缓存要用 \`no-store\`。
preload 的 as 写错或未加 crossorigin:字体不加 \`crossorigin\` 会重复下载两次。
preconnect 滥用:对几十个域名 preconnect,反而抢占连接资源拖慢关键请求。
对已压缩资源再压缩:给图片、视频套 gzip,只烧 CPU 不减体积。
HTTP/2 下过度合并 bundle:多路复用已让并发不再是瓶颈,一个巨型 bundle 反而牺牲缓存粒度和按需加载。
Service Worker 缓存不做版本管理:旧缓存不清理,用户困在旧版本。要在 \`activate\` 时清理过期 CACHE。
第三方脚本同步阻塞:埋点/客服 SDK 用同步 \`