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
这些文件之间的关系通常是:
- 浏览器先请求
/。 - 源站返回
index.html。 index.html中包含:<script type="module" src="/assets/index-B7k2mPqL.js"></script> <link rel="stylesheet" href="/assets/index-R4v8sXcD.css">- 浏览器继续请求 JavaScript 和 CSS。
- JavaScript 启动 React,并把应用挂载到 HTML 中的根节点。
- 如果使用客户端路由,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
这种文件称为内容寻址或内容哈希文件。理想情况下,文件名满足:
也就是说,只要文件内容改变,文件名也改变。于是可以建立一个重要的不变性:
对于同一个哈希文件名,服务器不应在之后返回另一份内容。
例如:
/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,因为浏览器可以保留副本,但每次导航可以通过 ETag 或 Last-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 保存文件”。一个缓存对象通常由缓存键决定:
例如:
https://example.com/assets/app.js?v=1
https://example.com/assets/app.js?v=2
即使路径相同,查询参数也可能使它们成为两个缓存对象。不同 CDN 对查询参数的处理可能不同:
- 保留全部查询参数;
- 忽略查询参数;
- 只保留白名单参数;
- 对某些文件统一剥离查询参数。
因此,不能只凭 URL 形式猜测 CDN 的缓存键,必须检查 CDN 配置。
缓存的基本决策可以抽象为:
- 根据请求计算缓存键 ;
- 查找是否存在对象 ;
- 如果对象仍然新鲜,直接返回;
- 如果对象过期,向源站重新验证;
- 源站返回
304,继续使用旧对象; - 源站返回
200,用新对象替换旧对象; - 源站返回错误,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 的真实状态码,例如 404 或 500 |
/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 回退当成通用的服务端路由实现。它解决的是“让客户端有机会启动”,不是“在服务器端完成页面业务判断”。
七、压缩:编码协商而不是简单压缩文件
静态文件压缩有两个层面:
- 构建压缩:删除无效空白、缩短变量名、移除不可达代码;
- 传输压缩:服务器或 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 都能找到它引用的资源。
九、发布顺序必须满足资源先于入口
设版本 的 HTML 引用资源集合为:
发布过程必须满足:
也就是:先发布所有资源,最后发布引用这些资源的 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 版本引用的资源集合是 ,则发布和回滚期间应保持:
如果清理策略在 7 天后删除旧版本资源,则至少需要保证所有缓存 HTML、Service Worker 和用户会话不可能再引用这些资源,或者接受资源加载失败的风险。实际系统通常会保留更长时间,并通过版本目录和定期垃圾回收删除确定不再使用的版本。
10.2 采用“当前版本指针”的对象存储方案
对象存储可以有如下结构:
releases/R1/index.html
releases/R1/assets/...
releases/R2/index.html
releases/R2/assets/...
public/index.html
部署时:
- 上传
releases/R2/assets; - 上传并验证
releases/R2/index.html; - 将
public/index.html更新为 R2 的内容; - 清除
/index.html的 CDN 缓存; - 观察错误率和资源加载错误;
- 回滚时把
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-Type 是 text/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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React CI 质量流水线:类型、Lint、测试、构建、预览和制品
- 下一篇:Next.js 路由与布局:Segment、并行路由、拦截和错误边界
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论