React 基础体系 · 第 64/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 静态交付:CDN、缓存、路由回退、压缩和回滚

React 应用的“静态交付”是指:构建阶段生成 HTML、JavaScript、CSS、图片和字体等文件,部署到对象存储、Web 服务器或 CDN;用户请求这些文件后,由浏览器执行 JavaScript,完成界面渲染和客户端路由。

这里的核心链路不是 React 组件本身,而是:

浏览器
  │
  │ DNS、TLS、HTTP 请求
  ▼
CDN 边缘节点
  │  命中缓存?────是────> 返回缓存对象
  │
  否
  ▼
源站:对象存储或 Web 服务器
  │
  ├── 返回静态文件
  ├── 对 SPA 路径返回 index.html
  └── 对 API、缺失资源返回真实错误

React 19 负责客户端运行时和组件模型,但不会规定 CDN 的缓存策略、Nginx 的路由回退方式,或者构建产物如何压缩。具体构建行为由 Vite、Webpack、Rspack、Next.js 等工具决定;本文以“生成一个可由 Web 服务器托管的 React 静态站点”为主,服务端渲染和 React Server Components 会在边界处单独说明。


一、静态交付到底交付了什么

假设执行:

npm ci
npm run build

常见构建工具会生成类似以下目录:

dist/
├── index.html
└── assets/
    ├── index-B7k2mPqL.js
    ├── index-R4v8sXcD.css
    └── logo-9a31f2.svg

这些文件之间的关系通常是:

  1. 浏览器先请求 /
  2. 源站返回 index.html
  3. index.html 中包含:
    <script type="module" src="/assets/index-B7k2mPqL.js"></script>
    <link rel="stylesheet" href="/assets/index-R4v8sXcD.css">
    
  4. 浏览器继续请求 JavaScript 和 CSS。
  5. JavaScript 启动 React,并把应用挂载到 HTML 中的根节点。
  6. 如果使用客户端路由,React Router 等库根据当前 URL 决定渲染哪个页面。

例如 React 入口可能是:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";

const root = document.getElementById("root");

if (!root) {
  throw new Error("Missing #root element");
}

createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

这段代码只能在浏览器已经成功取得 JavaScript 文件后运行。因此,下面几类问题发生在 React 启动之前:

  • CDN 返回了过期或错误的 index.html
  • HTML 引用了不存在的 JavaScript 文件;
  • JavaScript 被错误地返回成 HTML;
  • 压缩格式和 Content-Encoding 不匹配;
  • CDN 或 Web 服务器没有为深层路由配置回退。

因此,静态交付的正确性首先是 HTTP 和文件发布问题,其次才是 React 运行时问题。


二、带内容哈希的文件名是缓存和回滚的基础

构建工具通常会把源文件内容的一部分编码进文件名:

src/main.tsx
    ↓
assets/index-B7k2mPqL.js

这种文件称为内容寻址或内容哈希文件。理想情况下,文件名满足:

name=f(content)name = f(content)

也就是说,只要文件内容改变,文件名也改变。于是可以建立一个重要的不变性:

对于同一个哈希文件名,服务器不应在之后返回另一份内容。

例如:

/assets/index-B7k2mPqL.js

一旦发布,就可以设置很长的缓存时间:

Cache-Control: public, max-age=31536000, immutable

其中:

  • public:允许浏览器和共享缓存保存;
  • max-age=31536000:浏览器可以在一年内认为对象新鲜;
  • immutable:明确表示内容不会因为重新验证而改变。

immutable 不是“强制所有 CDN 永远缓存”的开关,它表达的是资源版本不可变。CDN 是否尊重该指令,还取决于 CDN 的缓存规则和清除策略。

2.1 HTML 不能使用同样的长期缓存策略

index.html 通常包含当前版本的资源引用:

<script type="module" src="/assets/index-B7k2mPqL.js"></script>

下一次发布可能变成:

<script type="module" src="/assets/index-C2d9nLmQ.js"></script>

如果 index.html 被浏览器或 CDN 缓存一年,用户就可能继续获得旧版本入口文件。因此 HTML 和哈希资源应当采用不同策略:

# index.html
Cache-Control: no-cache
ETag: "release-2025-03-08-1"

# 哈希 JavaScript、CSS、字体、图片
Cache-Control: public, max-age=31536000, immutable

这里的 no-cache 容易被误解。它不是“绝对不缓存”,而是:

可以存储,但再次使用前必须向源站或验证方确认是否仍然有效。

no-store 才是“不允许存储”。静态站点的 HTML 通常更适合 no-cache,因为浏览器可以保留副本,但每次导航可以通过 ETagLast-Modified 快速验证。

典型验证过程如下:

GET /index.html HTTP/1.1
If-None-Match: "release-2025-03-08-1"

如果没有变化,服务器返回:

HTTP/1.1 304 Not Modified

304 没有响应体,浏览器继续使用本地保存的 HTML。若已经发布新版本,服务器应返回:

HTTP/1.1 200 OK
ETag: "release-2025-03-09-2"

并附上新的 HTML 内容。

2.2 s-maxage 可以区分浏览器和 CDN

共享缓存可以使用:

Cache-Control: public, max-age=0, s-maxage=60, must-revalidate

其含义通常是:

  • 浏览器端 max-age=0:不要长时间直接复用;
  • CDN 端 s-maxage=60:边缘节点最多缓存 60 秒;
  • must-revalidate:过期后需要重新验证,具体执行仍受 CDN 实现影响。

这适合希望 CDN 缓存 HTML、但又不想让浏览器长期保留 HTML 的场景。不过发布后的可见时间会受 HTML 的 CDN TTL 影响。若要求发布后立即切换,必须配合 CDN 清除或版本化入口。


三、缓存命中、缓存键和重新验证

CDN 缓存不是简单的“按 URL 保存文件”。一个缓存对象通常由缓存键决定:

K=(scheme,host,path,query,selected headers)K = (scheme, host, path, query, selected\ headers)

例如:

https://example.com/assets/app.js?v=1
https://example.com/assets/app.js?v=2

即使路径相同,查询参数也可能使它们成为两个缓存对象。不同 CDN 对查询参数的处理可能不同:

  • 保留全部查询参数;
  • 忽略查询参数;
  • 只保留白名单参数;
  • 对某些文件统一剥离查询参数。

因此,不能只凭 URL 形式猜测 CDN 的缓存键,必须检查 CDN 配置。

缓存的基本决策可以抽象为:

  1. 根据请求计算缓存键 KK
  2. 查找是否存在对象 OKO_K
  3. 如果对象仍然新鲜,直接返回;
  4. 如果对象过期,向源站重新验证;
  5. 源站返回 304,继续使用旧对象;
  6. 源站返回 200,用新对象替换旧对象;
  7. 源站返回错误,CDN 是否返回旧对象取决于 stale-if-error、CDN 配置和故障策略。

常见响应头:

Cache-Control: public, max-age=60, stale-while-revalidate=300

stale-while-revalidate=300 表示对象过期后的某个时间窗口内,共享缓存可以先返回旧对象,同时在后台重新获取新对象。这样可以减少请求等待,但也意味着不同用户可能短时间看到不同版本。

这不是 React 的一致性机制,而是 HTTP 缓存允许的可见性差异。发布系统必须接受这种差异,或者通过版本化路径和主动清除降低它。


四、SPA 路由为什么需要回退

单页应用(SPA)通常在客户端处理路由。例如当前页面是:

https://example.com/orders/42

用户从 / 点击链接跳转到 /orders/42 时,客户端路由器可以调用 History API 修改地址,而不重新请求服务器。此时流程是:

浏览器首次请求 /
  ↓
服务器返回 index.html
  ↓
React 启动
  ↓
客户端路由器读取 /
  ↓
渲染首页

用户点击订单链接
  ↓
客户端路由器读取 /orders/42
  ↓
React 渲染订单详情
  ↓
通常不发生整页 HTTP 请求

但用户刷新 /orders/42,或者直接把这个地址粘贴到新标签页时,浏览器会向服务器发送:

GET /orders/42 HTTP/1.1

服务器默认只会查找名为 orders/42 的文件或目录。如果找不到,就返回 404。React 甚至没有机会启动。

**路由回退(history fallback)**就是:

当请求看起来是客户端页面路由、且没有对应静态文件时,服务器返回应用入口 index.html,让客户端路由器继续处理 URL。

这不是把所有错误都改成 index.html。至少要区分三类请求:

请求 找不到时应返回
/assets/index-B7k2mPqL.js 真实 404
/api/orders/42 API 的真实状态码,例如 404500
/orders/42 SPA 的 index.html,再由 React 渲染页面或应用级 404

如果把所有路径都回退到 index.html,缺失的 JavaScript 也会得到 HTML。浏览器会出现类似错误:

Failed to load module script:
Expected a JavaScript module script but the server responded with a MIME type of "text/html".

这通常不是 React 代码错误,而是静态文件路由配置错误或发布不完整。


五、Nginx 中正确配置静态文件、API 和 SPA 回退

下面是一个适合纯静态 React 应用的 Nginx 片段。它假设构建目录为 /srv/www/app/dist,API 由同一台服务器代理。

server {
    listen 80;
    server_name example.com;

    root /srv/www/app/dist;
    index index.html;

    # API 不进入 SPA 回退
    location ^~ /api/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 构建产物缺失时必须返回真实 404,不能返回 index.html
    location ~* \.(?:js|mjs|css|map|json|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
        try_files $uri =404;

        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }

    # HTML 需要重新验证
    location = /index.html {
        add_header Cache-Control "no-cache" always;
        try_files $uri =404;
    }

    # 其他页面路径使用 SPA history fallback
    location / {
        try_files $uri $uri/ /index.html;
    }
}

每个部分承担不同责任:

  • location ^~ /api/ 防止 API 请求被后面的 SPA 回退捕获;
  • 静态扩展名匹配使用 try_files $uri =404,保证资源缺失时不伪装成成功;
  • /index.html 单独设置较短的缓存策略;
  • location / 只负责页面导航的回退。

配置完成后,先检查语法:

sudo nginx -t

预期输出类似:

syntax is ok
test is successful

然后重新加载:

sudo systemctl reload nginx

reload 通常不会终止已有连接;如果 nginx -t 失败,不应继续加载配置,因为错误配置可能导致服务不可用。

5.1 验证回退和资源错误

验证页面回退:

curl -i https://example.com/orders/42

正确结果应类似:

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache

响应体应该是 index.html

验证缺失 JavaScript:

curl -i https://example.com/assets/not-found.js

正确结果应是:

HTTP/1.1 404 Not Found

而不是:

HTTP/1.1 200 OK
Content-Type: text/html

后者说明服务器把资源错误回退成了 HTML,浏览器加载模块时才会暴露问题。

验证 API 不被接管:

curl -i https://example.com/api/orders/not-found

应得到 API 服务定义的状态码,例如:

HTTP/1.1 404 Not Found
Content-Type: application/json

六、不同路由模型的边界

6.1 客户端路由加 history fallback

这是典型 SPA 部署方式:

任意页面 URL
  ↓
服务器返回同一个 index.html
  ↓
React 根据 URL 渲染页面

优点是部署简单、页面之间可以无刷新切换。缺点是首次 HTML 不包含完整页面内容,搜索引擎、禁用 JavaScript 的客户端和首屏性能需要另外处理。

6.2 预渲染或静态导出

另一种方式是在构建阶段为每条路由生成 HTML:

dist/
├── index.html
├── about/
│   └── index.html
└── orders/
    └── 42/
        └── index.html

此时 /orders/42 可以直接映射到真实文件,不一定需要统一回退。不同框架对静态导出的支持不同,路由参数、数据获取和动态页面也有额外限制。

6.3 服务端渲染

服务端渲染(SSR)不是纯静态交付。服务器需要在请求时执行部分 React 代码,返回针对当前 URL 生成的 HTML,并可能继续提供客户端 hydration 所需的 JavaScript。

因此,SSR 中的 /orders/42 可能由服务端真正判断订单是否存在并返回 404;而纯 SPA 回退通常先返回 index.html,再由客户端判断并渲染应用级 404。

不能把 SPA 回退当成通用的服务端路由实现。它解决的是“让客户端有机会启动”,不是“在服务器端完成页面业务判断”。


七、压缩:编码协商而不是简单压缩文件

静态文件压缩有两个层面:

  1. 构建压缩:删除无效空白、缩短变量名、移除不可达代码;
  2. 传输压缩:服务器或 CDN 根据请求协商,以 Brotli 或 gzip 发送响应。

构建压缩减少源文件内容体积;传输压缩进一步减少 HTTP 响应体积。两者作用不同,可以同时使用。

浏览器发送:

Accept-Encoding: br, gzip

服务器选择一种编码后返回:

Content-Encoding: br
Content-Type: application/javascript
Vary: Accept-Encoding

这里有三个必须匹配的事实:

  • Content-Type 描述解压后的资源类型;
  • Content-Encoding 描述响应体经过了什么编码;
  • Vary: Accept-Encoding 告诉共享缓存:不同编码能力的请求不能无条件共用同一响应。

如果服务器把 Brotli 压缩内容标记成 Content-Encoding: gzip,浏览器会按错误算法解码,通常表现为资源加载失败。反过来,如果已经是压缩文件却再次压缩,也可能浪费 CPU,甚至无法有效压缩。

7.1 Nginx 动态 gzip 示例

http {
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_comp_level 6;

    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml;
}

说明:

  • gzip_vary on 会设置 Vary: Accept-Encoding
  • gzip_min_length 1024 避免对很小的响应压缩;
  • gzip_types 必须包含实际响应的 MIME 类型;
  • JPEG、PNG、WebP、WOFF2 等通常已经压缩,不应期待 gzip 带来明显收益。

Nginx 原生配置中,Brotli 支持常依赖额外模块或发行版构建选项,不能假设所有 Nginx 都支持 brotli on。如果 CDN 已经负责 Brotli 协商,源站可以只提供未压缩版本,避免重复配置。

7.2 预压缩文件

也可以在发布阶段生成:

index-B7k2mPqL.js
index-B7k2mPqL.js.gz
index-B7k2mPqL.js.br

服务器根据 Accept-Encoding 选择对应文件。这种方式把 CPU 成本放到构建阶段,但必须保证:

  • 只有客户端声明支持某编码时才返回该编码;
  • Content-Encoding 与实际文件一致;
  • Content-Length 是压缩后文件大小,或者使用分块传输;
  • CDN 缓存键能够区分不同编码,或者 CDN 会正确规范化 Accept-Encoding
  • 不要让源站和 CDN 对同一响应重复压缩。

验证响应编码:

curl -I -H 'Accept-Encoding: br' https://example.com/assets/index-B7k2mPqL.js

可能得到:

HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Encoding: br
Vary: Accept-Encoding
Cache-Control: public, max-age=31536000, immutable

使用 --compressed 可以让 curl 自动解码响应:

curl --compressed \
  -H 'Accept-Encoding: br, gzip' \
  https://example.com/assets/index-B7k2mPqL.js \
  -o /tmp/index.js

如果响应头写了 Content-Encoding: br,但 curl 无法解码,应该优先检查文件内容、CDN 转换和源站头部,而不是修改 React 代码。


八、CDN 的角色、边界和缓存失效

CDN 通常在多个边缘节点保存源站响应。第一次请求某个边缘节点时可能发生:

用户请求
  ↓
边缘节点查缓存:MISS
  ↓
边缘节点请求源站
  ↓
源站返回文件和 Cache-Control
  ↓
边缘节点保存对象
  ↓
返回用户

后续请求可能是:

用户请求
  ↓
边缘节点查缓存:HIT
  ↓
直接返回对象

CDN 提供的是分发和缓存,不会自动理解 React 的组件、路由树或构建版本。它只看到 HTTP 请求和响应。

8.1 哪些内容应该缓存

典型策略如下:

内容 典型策略 原因
哈希 JS/CSS 长缓存、immutable 文件名变化代表内容变化
哈希图片、字体 长缓存、immutable 同上
index.html no-cache 或较短 CDN TTL 它决定当前资源版本
API 响应 依业务决定 通常有用户、权限和实时性
登录态页面 通常禁止共享缓存 防止用户数据泄漏

对于带 Cookie、Authorization 或用户身份的响应,不能直接套用静态资源的公共缓存策略。公共 CDN 缓存错误的 API 响应,可能造成跨用户数据暴露。

8.2 缓存清除不是发布一致性

发布后清除 CDN 缓存可以让新 HTML 更快可见,但它有几个限制:

  • 清除可能是异步的;
  • 不同边缘节点可能在不同时间完成;
  • 浏览器本地缓存不受 CDN 清除直接控制;
  • Service Worker 可能继续返回旧内容;
  • 已经开始加载旧 HTML 的用户仍然可能引用旧资源。

因此,清除缓存只能缩短旧版本的存活时间,不能把正在进行的请求瞬间变成新版本。

更稳妥的设计是让旧版本资源长期存在,并确保任意已发布的 HTML 都能找到它引用的资源。


九、发布顺序必须满足资源先于入口

设版本 R1R_1 的 HTML 引用资源集合为:

A(R1)={a1,a2,,an}A(R_1) = \{a_1, a_2, \ldots, a_n\}

发布过程必须满足:

aA(R1),publish(a)<publish(index(R1))\forall a \in A(R_1),\quad publish(a) < publish(index(R_1))

也就是:先发布所有资源,最后发布引用这些资源的 HTML

完整流程:

构建版本 R2
  ↓
上传 R2 的 JS、CSS、图片、字体
  ↓
验证所有资源可通过源站访问
  ↓
上传或切换 R2 的 index.html
  ↓
清除 HTML 的 CDN 缓存,或等待 HTML TTL
  ↓
验证浏览器实际加载版本

如果反过来先发布 HTML,可能出现:

CDN 返回新 index.html
  ↓
HTML 引用 app-C2d9nLmQ.js
  ↓
边缘节点请求该 JS
  ↓
源站仍未上传
  ↓
JS 404
  ↓
React 无法启动

这类故障经常被误判为“React 构建失败”,实际是发布顺序违反了依赖关系。

9.1 使用版本目录降低覆盖风险

一种可靠的目录布局是:

releases/
├── 2025-03-08-001/
│   ├── index.html
│   └── assets/
│       └── index-B7k2mPqL.js
└── 2025-03-09-002/
    ├── index.html
    └── assets/
        └── index-C2d9nLmQ.js

对外暴露的 /index.html 可以引用某个版本目录:

<script type="module"
        src="/releases/2025-03-09-002/assets/index-C2d9nLmQ.js">
</script>

旧版本目录不删除,便能保证:

  • 旧 HTML 仍可加载旧资源;
  • 正在使用旧版本的用户不会因为新发布而丢资源;
  • 回滚时可以重新指向旧 HTML;
  • 可以按版本做离线验证和审计。

也可以让构建工具生成以版本目录为基础的资源 URL,但必须正确设置 base 或等价配置。若 HTML 在 /releases/2025-03-09-002/ 下,而资源 URL 写成相对路径和绝对路径,最终请求位置会不同,发布前必须检查生成的 HTML。


十、回滚:恢复入口,不删除资源

静态站点的回滚本质上是:

让新的用户再次获得上一个已知正常版本的入口文件,同时保留所有可能被旧入口引用的资源。

假设:

R1/index.html → R1/assets/app-A.js
R2/index.html → R2/assets/app-B.js

发布 R2 后发现问题,正确回滚路径是:

恢复 R1/index.html
  ↓
清除或降低 index.html 的 CDN 缓存
  ↓
继续保留 R1/assets/app-A.js 和 R2/assets/app-B.js
  ↓
验证新请求得到 R1

不应先删除 R1 的资源。因为以下用户可能仍然持有 R1 的 HTML:

  • 浏览器尚未重新验证 HTML;
  • 某个 CDN 边缘节点尚未失效;
  • Service Worker 返回旧 HTML;
  • 用户在回滚前已经打开页面;
  • 网络请求已经处于进行中。

10.1 回滚的安全不变量

设某个 HTML 版本引用的资源集合是 A(R)A(R),则发布和回滚期间应保持:

HTML(R) 可获得aA(R), a 仍可获得\text{HTML}(R)\ \text{可获得} \Rightarrow \forall a \in A(R),\ a\ \text{仍可获得}

如果清理策略在 7 天后删除旧版本资源,则至少需要保证所有缓存 HTML、Service Worker 和用户会话不可能再引用这些资源,或者接受资源加载失败的风险。实际系统通常会保留更长时间,并通过版本目录和定期垃圾回收删除确定不再使用的版本。

10.2 采用“当前版本指针”的对象存储方案

对象存储可以有如下结构:

releases/R1/index.html
releases/R1/assets/...

releases/R2/index.html
releases/R2/assets/...

public/index.html

部署时:

  1. 上传 releases/R2/assets
  2. 上传并验证 releases/R2/index.html
  3. public/index.html 更新为 R2 的内容;
  4. 清除 /index.html 的 CDN 缓存;
  5. 观察错误率和资源加载错误;
  6. 回滚时把 public/index.html 恢复为 R1 内容。

“更新单个对象”并不一定在所有对象存储和 CDN 组合中表现为全球瞬时原子切换,但它至少避免了逐个覆盖 JS、CSS 文件导致的混合版本。真正的全局一致性仍受 CDN 缓存、边缘传播和浏览器缓存影响。


十一、部署状态和故障路径

可以把一次发布抽象成以下状态:

stateDiagram-v2
    [*] --> Built: 构建完成
    Built --> AssetsUploaded: 上传哈希资源
    AssetsUploaded --> AssetsVerified: 资源可访问且头部正确
    AssetsVerified --> HtmlPublished: 发布入口 HTML
    HtmlPublished --> CachePropagating: CDN 缓存更新中
    CachePropagating --> Healthy: 关键路径验证通过
    CachePropagating --> Rollback: 发现错误
    Healthy --> Rollback: 运行时指标恶化
    Rollback --> PreviousHtmlPublished: 恢复旧入口
    PreviousHtmlPublished --> CachePropagating

关键故障路径如下。

路径一:新 HTML,旧资源系统

如果资源文件名没有哈希,或者发布时覆盖了固定名称:

旧 HTML 已被 CDN 缓存
新 JS 覆盖旧 JS
旧 HTML 与新 JS 的接口不兼容

这会造成难以复现的错误:不同浏览器、不同边缘节点加载到不同组合。内容哈希和不覆盖旧资源可以避免这种组合污染。

路径二:新 HTML,资源尚未上传

HTML 先发布
  ↓
用户拿到新 HTML
  ↓
新资源 404

修复是恢复旧 HTML 或完成资源上传;单纯清除缓存不能修复一个源站中不存在的文件。

路径三:资源 404 被回退成 HTML

GET /assets/app.js
  ↓
文件不存在
  ↓
服务器返回 index.html,状态 200
  ↓
浏览器按 JavaScript 解析 HTML
  ↓
模块 MIME 或语法错误

修复是让资源路径优先返回真实 404,不要进入 SPA fallback。

路径四:CDN 返回旧 HTML,旧资源已删除

边缘节点仍保存旧 HTML
  ↓
旧 HTML 引用旧资源
  ↓
源站已删除旧资源
  ↓
页面启动失败

修复是恢复旧资源,或者在删除策略上保留足够长的安全窗口。先删除旧资源再等待 CDN 过期是不安全的。


十二、实际诊断顺序

遇到“页面白屏”时,应先区分故障发生在哪一层。

12.1 检查 HTML

curl -i https://example.com/

确认:

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache

然后查看 HTML 引用的资源:

curl -s https://example.com/ | grep -Eo \
  '(?:src|href)="[^"]+\.(?:js|css)[^"]*"'

预期能看到当前发布版本的资源路径。

12.2 检查每个资源的状态和类型

curl -I https://example.com/assets/index-C2d9nLmQ.js

至少检查:

HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable

如果是压缩响应,还要检查:

Content-Encoding: br
Vary: Accept-Encoding

Content-Typetext/html,即使状态码是 200,也说明资源路由或 CDN 回源结果错误。

12.3 检查深层路由

curl -i https://example.com/orders/42

纯 SPA 通常应返回入口 HTML:

HTTP/1.1 200 OK
Content-Type: text/html

但未知的静态资源必须仍然是:

curl -i https://example.com/assets/missing.js
HTTP/1.1 404 Not Found

12.4 检查缓存命中和源站差异

CDN 常见会添加供应商特有头部,例如:

Age: 120
X-Cache: HIT

这些头部不是 HTTP 统一标准,名称和含义取决于供应商。可以从不同网络、不同地区或绕过 CDN 的源站地址分别请求,比较:

  • ETag
  • Cache-Control
  • Content-Encoding
  • Content-Length
  • HTML 中的资源版本;
  • CDN 命中状态。

如果源站已经是 R2,而 CDN 仍返回 R1,问题在缓存传播、缓存键或清除策略;如果源站本身返回 R1,问题在发布切换。

12.5 检查 Service Worker

如果站点注册了 Service Worker,浏览器请求可能先经过:

页面
  ↓
Service Worker fetch 事件
  ├── 返回 Cache Storage 旧对象
  └── 再决定是否访问网络

此时 CDN 已经返回新 HTML,并不代表页面一定使用新 HTML。回滚验证时应检查:

  • Application 面板中的 Service Worker;
  • Cache Storage 中保存的 HTML 和资源;
  • sw.js 的更新策略;
  • 是否有“缓存优先”逻辑;
  • 是否需要注销旧 Service Worker 或增加版本迁移逻辑。

Service Worker 是浏览器端缓存层,不应与 CDN 缓存混为一谈。它还可能缓存离线页,因此回滚设计必须把它列为独立状态机。


十三、React 应用中的运行时配置限制

静态构建通常会把环境变量编译进 JavaScript:

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;

执行构建后,apiBaseUrl 往往已经成为构建产物中的字符串。把同一份 dist 上传到不同环境,并不会自动在浏览器运行时读取服务器环境变量。

如果希望不重新构建就改变配置,可以让 HTML 加载一个运行时配置文件:

<script src="/runtime-config.js"></script>
// runtime-config.js
window.__APP_CONFIG__ = {
  apiBaseUrl: "https://api.example.com",
};

TypeScript 中声明类型:

declare global {
  interface Window {
    __APP_CONFIG__: {
      apiBaseUrl: string;
    };
  }
}

export {};

然后:

const apiBaseUrl = window.__APP_CONFIG__.apiBaseUrl;

runtime-config.js 现在属于发布配置,而不是内容哈希资源,缓存策略必须谨慎。若它被长期缓存,修改 API 地址可能不会立即生效;若它包含敏感信息,任何用户都能看到,不能放置密钥。


十四、容易混淆的几个结论

“CDN 缓存越久越好”

只对真正不可变的资源成立。哈希 JS、CSS 适合长期缓存;HTML、用户相关响应和未版本化配置不适合无条件长期缓存。

no-cache 等于不缓存”

不等于。no-cache 允许存储,但要求使用前重新验证;no-store 才是不存储。

“路由回退能处理所有 404”

不能。它只应该处理客户端页面导航。缺失 JavaScript、CSS、图片和 API 资源必须返回真实错误,否则错误会以 HTML 200 的形式隐藏到浏览器运行阶段。

“清 CDN 后一定完成回滚”

不一定。浏览器缓存、Service Worker、进行中的请求和不同边缘节点都可能保留旧状态。回滚必须恢复入口,并保证旧资源仍然存在。

“启用 gzip 就一定更快”

不一定。小文件、已压缩媒体和字体可能收益很小;压缩还会消耗 CPU。现代 CDN 通常可以协商 Brotli 和 gzip,但需要验证实际响应头和客户端兼容性。

“React 19 提供了静态部署方案”

React 19 提供 React 的运行时能力和 API,但静态文件如何构建、发布、缓存和回退属于构建工具、Web 服务器、对象存储和 CDN 的职责。React 应用只有在这些 HTTP 前提满足后才会启动。


十五、一个可验证的发布契约

一个可运行的 React 静态站点至少应满足以下契约:

1. / 返回可执行的 index.html
2. index.html 引用的每个资源都存在
3. 哈希资源发布后不覆盖
4. 哈希资源使用长期缓存
5. index.html 可以重新验证
6. 深层页面路由能回退到 index.html
7. 缺失资源不会回退到 index.html
8. /api/ 不会进入 SPA 回退
9. Content-Type 与文件类型匹配
10. Content-Encoding 与实际压缩算法匹配
11. 任意仍可能被缓存的旧 HTML,其资源仍可访问
12. 回滚只需恢复旧入口,不依赖重新构建

其中最重要的因果关系是:

内容哈希
  → 资源可视为不可变
  → 资源可以长期缓存
  → 发布时可以保留多个版本
  → 回滚只需切换 HTML

而路由回退解决的是另一条链路:

浏览器直接访问深层 URL
  → 服务器找不到同名文件
  → 返回 index.html
  → React 启动
  → 客户端路由渲染页面

压缩则是在这条 HTTP 链路上减少传输成本:

客户端声明 Accept-Encoding
  → CDN 或源站选择 br/gzip
  → 设置 Content-Encoding 和 Vary
  → 浏览器解码后交给 React 资源加载器

当这三条机制分别配置正确,并且发布顺序保证“资源先于入口、旧资源晚于旧入口删除”,React 静态交付才具备可缓存、可诊断和可回滚的基础。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。