React 基础体系 · 第 23/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 生产交付:配置、静态资源、CSP、灰度、监控和回滚
React 应用在开发环境中通常由开发服务器即时编译、代理接口并注入环境变量;生产环境则是一个由构建产物、静态资源服务器、服务端渲染层、API、缓存、发布系统和监控系统共同组成的系统。
因此,“执行 npm run build 并把 dist 上传到服务器”只解决了编译问题,尚未解决以下生产问题:
- 不同环境的配置如何注入,如何避免把密钥打进浏览器;
- HTML、JavaScript、CSS、图片和字体如何缓存与更新;
- Content Security Policy(CSP)如何约束脚本执行;
- 新版本如何只交给一部分用户;
- 如何区分代码错误、接口错误、资源加载错误和服务端错误;
- 发布失败时如何恢复到旧版本,并避免旧前端与新后端互相不兼容。
本文以 React 19、现代 TypeScript 和常见的 Vite、Next.js、Node.js/Nginx 等能力为背景,但会明确区分浏览器客户端代码和服务端代码。
一、先建立生产交付模型
1.1 React 生产交付的基本对象
一个可发布的 React 应用至少包含以下对象:
源代码
│
├─ TypeScript 编译、Lint、测试
│
├─ 构建器:Vite、Next.js、Rspack 等
│
├─ 构建产物
│ ├─ HTML
│ ├─ JavaScript chunks
│ ├─ CSS
│ ├─ 图片、字体、SVG
│ └─ source map(是否公开需要单独决定)
│
├─ 配置
│ ├─ 构建时配置
│ ├─ 请求时配置
│ └─ 服务端私密配置
│
└─ 发布系统
├─ 上传
├─ 切换流量
├─ 监控
└─ 回滚
浏览器下载的所有内容都不再是秘密。即使变量名叫 SECRET_KEY,只要它被打进 JavaScript,用户就可以通过:
- DevTools;
- 浏览器缓存;
- source map;
- 网络请求;
- 构建产物搜索;
看到它或推导出它的值。
因此需要先区分三类配置:
| 配置类型 | 例子 | 是否可进入浏览器 |
|---|---|---|
| 公共构建配置 | API 公共域名、应用版本、Sentry 公钥 | 可以 |
| 请求时公共配置 | 当前租户、公共功能开关、部署版本 | 可以,但应由服务端控制 |
| 私密服务端配置 | 数据库密码、签名密钥、第三方私钥 | 绝不能 |
这条边界比“环境变量是否以 VITE_ 开头”更根本。前缀只是构建器的暴露规则,不是安全机制。
1.2 生产请求链路
典型的浏览器请求链路如下:
sequenceDiagram
participant U as 浏览器
participant CDN as CDN/静态资源服务器
participant SSR as SSR或HTML服务
participant API as API服务
participant O as 监控系统
U->>CDN: 请求 /index.html
CDN->>SSR: 未命中或回源
SSR-->>U: HTML、nonce、配置入口
U->>CDN: 请求 hashed JS/CSS
CDN-->>U: 静态资源
U->>API: 请求业务接口
API-->>U: JSON或错误响应
U->>O: 错误、Web Vitals、发布版本
纯 CSR 应用中,SSR 可以替换成返回静态 HTML 的 Web 服务器。SSR 应用中,HTML 通常由 Node.js、边缘运行时或框架服务端生成,浏览器收到 HTML 后再进行 hydration。
这里有一个重要的版本一致性问题:
HTML 引用了哪些 chunk
↓
浏览器必须能从静态资源服务器取到这些 chunk
如果新 HTML 已经上线,但旧 chunk 被删除,用户会看到:
Failed to fetch dynamically imported module
ChunkLoadError
Loading chunk xxx failed
这不是 React 组件错误,而是HTML 与静态资源版本不一致。
二、配置:构建时、请求时和服务端配置
2.1 构建时配置的性质
构建时配置在 build 执行期间被替换进产物。例如:
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
使用 Vite 时,常见配置文件如下:
# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
VITE_APP_ENV=development
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_ENV=production
代码:
export const publicConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
appEnv: import.meta.env.VITE_APP_ENV,
} as const;
构建命令:
npm run build
若 package.json 中定义:
{
"scripts": {
"build": "tsc -b && vite build"
}
}
则预期结果通常包括:
dist/index.html
dist/assets/index-<hash>.js
dist/assets/index-<hash>.css
这里的 <hash> 通常由文件内容决定。内容改变时文件名改变,后文的长期缓存策略依赖这个性质。
Vite 默认只会把以 VITE_ 开头的变量暴露给客户端代码:
VITE_PUBLIC_API=https://api.example.com
DB_PASSWORD=do-not-expose
DB_PASSWORD 不会因为没有前缀而自动变得安全。如果它被其他构建插件、脚本或日志输出,仍可能泄露。安全边界是:私密值不进入客户端构建过程。
2.2 构建时配置的代价:一个产物对应一个配置
假设应用构建时把 API 地址写成:
const API_BASE_URL = "https://api.example.com";
那么把同一个 dist 目录部署到测试环境,并不能自动让它访问测试 API。因为配置已经成为静态 JavaScript 内容的一部分。
这有两个常见策略。
策略 A:每个环境分别构建
vite build --mode staging
vite build --mode production
优点:
- 配置简单;
- 构建结果清晰;
- 不需要额外的运行时配置请求。
缺点:
- 每个环境都有独立产物;
- 构建产物不再完全相同;
- 如果发布系统只想“构建一次、部署多次”,不适合。
策略 B:构建一次,启动时或请求时注入配置
例如 HTML 中由服务端输出:
<script
nonce="generated-per-response"
type="application/json"
id="__PUBLIC_CONFIG__"
>
{"apiBaseUrl":"https://api.example.com","release":"web-2025.03.08"}
</script>
客户端读取:
type PublicConfig = {
apiBaseUrl: string;
release: string;
};
export function readPublicConfig(): PublicConfig {
const element = document.getElementById('__PUBLIC_CONFIG__');
if (!element?.textContent) {
throw new Error('Missing public runtime config');
}
const value: unknown = JSON.parse(element.textContent);
if (
typeof value !== 'object' ||
value === null ||
typeof (value as Record<string, unknown>).apiBaseUrl !== 'string' ||
typeof (value as Record<string, unknown>).release !== 'string'
) {
throw new Error('Invalid public runtime config');
}
return value as PublicConfig;
}
这个配置仍然是公开的。它适合放:
- API 公共地址;
- 当前发布版本;
- 公共租户标识;
- 非敏感的实验开关;
- 监控项目的公开 DSN。
它不适合放:
- 数据库凭证;
- API 签名私钥;
- JWT 签名密钥;
- 任何可以直接获得越权能力的秘密。
一个更稳妥的设计是让浏览器只请求自己的同源路径:
fetch('/api/orders')
由反向代理或服务端决定真正的上游 API。这样可以减少浏览器需要知道的环境差异,但不能替代服务端鉴权。
2.3 SSR 中的配置边界
在 Next.js、Remix 或自建 SSR 服务中,需要区分:
服务端模块
├─ 可以访问数据库、私密环境变量
└─ 不会自动发送给浏览器
客户端模块
├─ 代码会进入浏览器
└─ 只能使用公开配置
以 Node.js 服务端为例:
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
这段代码只能在服务端执行。若某个被客户端引用的模块直接导入它,构建器可能:
- 报错;
- 删除无法解析的分支;
- 将某些值内联;
- 或者因错误配置导致秘密进入产物。
不能仅凭文件名判断边界。应通过框架约定、模块边界和构建检查保证:
server/
db.ts
auth-secret.ts
client/
api-client.ts
public-config.ts
还可以在 CI 中检查构建产物:
grep -R "DATABASE_URL\|PRIVATE_KEY\|DB_PASSWORD" dist/
这个检查不是完整的密钥扫描,但可以捕获明显误配置。真正的密钥扫描应使用专门工具,并在密钥一旦泄露后立即轮换,而不是只删除 Git 历史中的文本。
三、静态资源:文件名、缓存和版本一致性
3.1 为什么资源需要内容哈希
静态资源通常包括:
- HTML;
- JavaScript;
- CSS;
- 图片;
- 字体;
- SVG;
- source map。
对 JavaScript 和 CSS 使用内容哈希:
assets/app-7c1e9a.js
assets/app-4a8d10.css
设资源内容为 C,文件名中的哈希为:
H = hash(C)
如果文件内容改变,则在哈希碰撞概率可忽略时:
C1 ≠ C2 ⇒ H(C1) ≠ H(C2)
于是可以安全地对带哈希的文件使用长时间缓存:
Cache-Control: public, max-age=31536000, immutable
immutable 的含义是:在缓存有效期内,客户端不需要因为重新验证而再次询问服务器。前提是相同 URL 永远对应相同内容。
因此,以下部署是错误的:
同一个 URL:/assets/app.js
今天返回版本 A
明天返回版本 B
同时设置 max-age=31536000
客户端可能一年内持续使用旧内容。
3.2 HTML 不能像 hashed chunk 一样长期缓存
HTML 通常包含入口资源引用:
<script type="module" src="/assets/app-7c1e9a.js"></script>
如果 HTML 长期缓存,用户可能长时间拿不到新版本入口。因此常见策略是:
/index.html
Cache-Control: no-cache
no-cache 不等于“不允许缓存”,而是允许存储,但使用前需要向服务端重新验证。生产中也可以使用较短的 max-age,但必须配合发布需求和 CDN 刷新策略。
静态 hashed 资源:
/assets/*
Cache-Control: public, max-age=31536000, immutable
HTML 和资源的发布顺序也重要:
- 先上传新版本所有 hashed 资源;
- 验证资源可访问;
- 再切换或更新 HTML;
- 最后按策略清理过期资源。
如果反过来先更新 HTML,浏览器立即会请求尚未上传的 chunk,造成资源加载失败。
3.3 SPA 路由回退和静态资源不能混用
React Router 的浏览器路由可能访问:
/dashboard
/settings/profile
服务器收到这些 URL 时,若没有对应的真实文件,需要把它们回退到 index.html:
location / {
try_files $uri $uri/ /index.html;
}
但静态资源请求不能无条件回退:
/assets/app-7c1e9a.js
如果文件不存在,服务器应返回 404,而不是返回 index.html。否则浏览器会得到 HTML,却按 JavaScript 解析,产生类似错误:
Uncaught SyntaxError: Unexpected token '<'
更明确的 Nginx 配置示意:
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
实际项目还需要确认:
index.html的 Content-Type 是text/html;- JavaScript 的 Content-Type 是
text/javascript或服务器认可的 JavaScript MIME 类型; - CSS 的 Content-Type 是
text/css; - 压缩响应携带正确的
Content-Encoding; - CDN 不会把错误页面缓存成正常资源。
3.4 动态 import 和旧页面问题
代码分割通常会产生动态 import:
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
用户可能在版本 A 的页面上停留很久。发布版本 B 后,他点击设置页,浏览器会请求版本 A 记录的 chunk。如果部署系统删除了旧 chunk,加载会失败。
一种通用的错误处理方式是捕获动态加载失败,并给用户一次刷新机会:
import { Component, type ErrorInfo, type ReactNode } from 'react';
type Props = {
children: ReactNode;
};
type State = {
hasError: boolean;
};
export class ChunkLoadBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: unknown): State {
const message = error instanceof Error ? error.message : String(error);
if (/chunk|import|module|loading/i.test(message)) {
return { hasError: true };
}
throw error;
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.error('Chunk loading failed', { error, componentStack: info.componentStack });
}
render() {
if (this.state.hasError) {
return (
<main>
<h1>页面版本已更新</h1>
<p>请刷新页面后继续操作。</p>
<button
onClick={() => {
window.location.reload();
}}
>
刷新
</button>
</main>
);
}
return this.props.children;
}
}
这不是对所有错误都调用 reload。业务 Bug、权限错误和接口错误不应通过无限刷新掩盖。生产实现还应记录刷新尝试,防止用户在同一错误上循环刷新。
更根本的措施是:保留至少一段发布窗口内的旧资源,使旧 HTML 和旧动态 chunk 仍可下载。例如使用带版本目录的产物:
/releases/2025-03-08-1200/index.html
/releases/2025-03-08-1200/assets/...
然后通过软链接、CDN 路由或版本清单切换当前入口。旧目录不应在切换瞬间删除。
四、CSP:限制浏览器能执行什么
4.1 CSP 的定义和执行方式
Content Security Policy 是通过 HTTP 响应头或 <meta> 声明的一组浏览器安全策略。它限制资源来源和执行方式,例如:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123';
style-src 'self' 'nonce-abc123';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com https://monitor.example.com;
font-src 'self' https:;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
report-to csp-endpoint;
各指令的因果关系是:
default-src提供未单独声明类型的默认来源;script-src控制脚本;style-src控制样式;img-src控制图片;connect-src控制fetch、XHR、WebSocket、sendBeacon;font-src控制字体;object-src 'none'禁止旧式插件对象;base-uri 'self'防止攻击者改变相对 URL 解析基准;frame-ancestors 'none'防止页面被嵌入 iframe。
CSP 是浏览器执行层的约束,不是服务端鉴权。即使 CSP 写得很严格,API 仍必须在服务端验证身份和权限。
4.2 unsafe-inline、nonce 和 hash
最容易配置但最弱的脚本策略是:
script-src 'self' 'unsafe-inline'
它允许任意内联脚本执行,降低了 CSP 对 XSS 的防护价值。
更常见的严格策略是 nonce:
script-src 'self' 'nonce-随机值'
服务端为每个 HTML 响应生成不可预测的随机 nonce:
import crypto from 'node:crypto';
export function createNonce(): string {
return crypto.randomBytes(16).toString('base64');
}
输出 HTML:
<script nonce="abc...">
window.__PUBLIC_CONFIG__ = {...};
</script>
响应头必须使用同一个 nonce:
Content-Security-Policy: script-src 'self' 'nonce-abc...'
浏览器只执行带有匹配 nonce 的内联脚本。
关键约束是:
- nonce 必须不可预测;
- 每个响应最好重新生成;
- 不能把 nonce 写死在静态 HTML 中;
- HTML 被 CDN 缓存时,nonce 与响应头必须保持一致;
- SSR 输出的内联脚本、框架 bootstrap 脚本都要正确传递 nonce。
如果无法对 HTML 动态生成 nonce,也可以对固定内联内容使用 hash:
script-src 'self' 'sha256-<base64-hash>'
hash 针对的是脚本内容本身。脚本内容任何一个字符变化,hash 都需要重新计算。动态配置通常更适合 nonce。
4.3 React 与 CSP 的关系
React 本身不要求开发者使用 eval 或任意内联脚本来渲染组件。生产 CSP 是否能收紧,主要取决于:
- 构建器是否生成运行时
eval; - SSR 框架是否注入内联 bootstrap;
- CSS-in-JS 是否插入
<style>; - 第三方监控、支付、地图脚本需要哪些来源;
- 是否使用 WebSocket、Blob URL、Web Worker。
开发环境常见:
script-src 'self' 'unsafe-eval'
因为某些开发工具需要 source map 或快速编译。这个策略不应直接复制到生产。
建议先使用报告模式:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self';
connect-src 'self' https://api.example.com;
report-uri /csp-report;
报告模式只上报违规,不阻止资源。确认误报和真实依赖后,再切换为强制模式。
CSP 报告接口必须限制请求体大小并避免记录敏感信息:
import type { Request, Response } from 'express';
export function cspReport(req: Request, res: Response) {
// 生产中应校验 Content-Type、限制 body 大小,并做采样
console.warn('CSP violation', req.body);
res.status(204).end();
}
CSP 报告不是普通业务日志。攻击者可以主动发送伪造报告,因此它适合发现浏览器侧违规趋势,不应直接作为安全事实来源。
五、灰度发布:把版本风险限制在一部分流量内
5.1 灰度的定义
灰度发布(canary release)是让新版本先服务于选定的一部分用户或请求,再根据监控结果逐步扩大范围。
灰度对象可能是:
- 用户;
- 租户;
- 请求;
- 地域;
- 设备类型;
- 内部员工;
- 百分比流量。
它与“功能开关”不是同一个概念:
| 机制 | 控制对象 | 典型用途 |
|---|---|---|
| 版本灰度 | 哪个构建版本处理请求 | 发现发布级故障 |
| 功能开关 | 某功能是否启用 | 控制业务功能 |
| A/B 实验 | 不同方案的行为 | 比较产品效果 |
一个完整发布可能同时使用三者:
版本 B 只给 5% 用户
└─ 其中新支付流程功能只给 1% 用户
5.2 灰度路由必须保持用户一致性
若每次请求都随机决定版本:
请求 1 → 版本 A
请求 2 → 版本 B
请求 3 → 版本 A
同一个用户可能获得不一致的 HTML、API 行为和功能开关,导致难以诊断的问题。
常见做法是基于稳定键计算分桶:
function isInCanary(userId: string, percentage: number): boolean {
const hash = stableHash(userId); // 返回稳定整数
return hash % 100 < percentage;
}
若 percentage = 5,则稳定哈希结果为 0 到 4 的用户进入灰度。这里的关键不是具体哈希算法,而是:
同一个用户 ID + 同一个灰度规则 ⇒ 同一个结果
匿名用户可以使用服务端设置的签名 Cookie:
canary=1.<signature>
不能只信任客户端传来的:
?canary=true
否则任何用户都能伪造灰度身份,监控数据和发布判断都会失真。
灰度状态应在边缘层、网关层或服务端统一决定,并通过响应头或运行时配置让客户端知道版本:
X-Release: web-2025-03-08-1200
客户端上报监控时带上:
type TelemetryContext = {
release: string;
environment: string;
route: string;
};
否则发现灰度错误率升高时,无法确认错误究竟来自哪个构建。
5.3 灰度期间的 API 兼容性
前端灰度通常意味着旧前端和新前端会同时在线。因此后端不能只兼容一个前端版本。
例如旧前端发送:
{
"name": "Alice"
}
新前端发送:
{
"displayName": "Alice",
"timezone": "Asia/Shanghai"
}
后端若立即删除 name 字段,旧前端会失败。更稳妥的迁移顺序是:
1. 后端先接受旧格式和新格式
2. 发布能使用新格式的前端
3. 观察旧格式请求是否归零
4. 再删除旧格式支持
数据库结构也应遵循 expand-contract:
Expand:增加新列/新表,旧代码仍可运行
Migrate:逐步写入和回填
Switch:新代码读取新结构
Contract:确认旧代码消失后删除旧结构
直接执行破坏性迁移后再灰度前端,会使“回滚前端”变成无效操作,因为旧代码已经无法使用新的后端或数据库。
5.4 灰度判断的指标
灰度扩大不能只看“页面打开成功”。至少应分组比较:
灰度组 vs 稳定组
指标包括:
- JavaScript 异常率;
- 未处理 Promise rejection;
- API 5xx 比例;
- API 延迟;
- 静态资源 404;
- 动态 chunk 加载失败;
- hydration 错误;
- LCP、INP、CLS;
- 登录、下单、支付等关键业务成功率。
如果灰度组的关键错误率明显高于基线,应停止扩大并回滚或关闭功能。阈值需要基于业务基线定义,不能凭空使用固定百分比。
六、监控:从“报错”定位到“哪个版本、哪个用户、哪条链路”
6.1 前端监控的四类信号
前端可观测性通常包含四类信号:
-
Logs:日志
记录单个事件,例如接口失败、资源加载失败。 -
Metrics:指标
统计错误率、延迟、Web Vitals、业务成功率。 -
Traces:链路追踪
关联浏览器请求、网关、后端服务和数据库调用。 -
Profiles:性能剖析
分析 CPU、组件渲染和长任务。
React 的 Profiler 适合分析组件渲染耗时,但它不是完整的生产监控系统:
import { Profiler, type ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
) => {
if (actualDuration > 100) {
navigator.sendBeacon(
'/telemetry/react-render',
JSON.stringify({
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
}),
);
}
};
export function AppWithProfiler() {
return (
<Profiler id="App" onRender={onRender}>
<App />
</Profiler>
);
}
这里:
actualDuration是本次更新实际渲染耗时;baseDuration是不使用记忆化优化时的估算渲染成本;phase通常区分 mount 和 update。
生产是否长期启用、是否采样,应根据性能开销和数据价值决定。不能把 actualDuration 直接解释成“用户感知到的页面耗时”,因为它不包含网络、浏览器排队、布局、绘制和主线程其他任务。
6.2 Error Boundary 能捕获什么
React 错误边界可以捕获子树渲染期间、生命周期和部分事件处理相关的错误,并显示降级 UI:
import { Component, type ErrorInfo, type ReactNode } from 'react';
type Props = {
children: ReactNode;
release: string;
};
type State = {
error: Error | null;
};
export class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
reportError({
error,
componentStack: info.componentStack,
release: this.props.release,
url: window.location.href,
});
}
render() {
if (this.state.error) {
return (
<main>
<h1>页面出现问题</h1>
<p>请重试;如果问题持续,请联系支持人员。</p>
<button onClick={() => window.location.reload()}>刷新页面</button>
</main>
);
}
return this.props.children;
}
}
但错误边界不是全局异常捕获器,不能覆盖:
- 事件处理函数中未处理的错误;
- 异步回调中的错误;
- 服务端渲染期间的所有错误;
- 错误边界自身抛出的错误;
- 网络请求失败本身。
因此还应监听:
window.addEventListener('error', (event) => {
reportError({
type: 'window.error',
message: event.message,
filename: event.filename,
line: event.lineno,
column: event.colno,
});
});
window.addEventListener('unhandledrejection', (event) => {
reportError({
type: 'unhandledrejection',
reason: serializeUnknown(event.reason),
});
});
全局监听器要做采样、去重和脱敏。不要把 access token、完整请求头、身份证号或用户输入直接发送到监控系统。
6.3 发布诊断必须携带 release
错误事件至少需要包含:
type ErrorEventContext = {
release: string;
environment: 'production' | 'staging';
route: string;
buildId?: string;
userIdHash?: string;
browser?: string;
};
其中 release 应来自构建产物或服务端发布清单,而不是由用户输入。
例如构建时生成:
// generated/release.ts
export const RELEASE = 'web-2025-03-08-1200';
客户端上报:
reportError({
error,
release: RELEASE,
route: window.location.pathname,
});
服务端日志同时记录:
request_id=abc123
release=web-2025-03-08-1200
api_version=v42
status=500
这样才能完成以下关联:
用户看到错误
→ 浏览器错误事件
→ 请求 request_id
→ API 日志
→ 对应部署版本
没有版本号的错误监控只能告诉你“有问题”,不能可靠地告诉你“哪个发布引入了问题”。
6.4 Web Vitals 的正确解释
常见 Web Vitals 包括:
- LCP:Largest Contentful Paint,最大内容绘制,反映主要内容出现的速度;
- INP:Interaction to Next Paint,交互到下一次绘制的延迟,反映交互响应;
- CLS:Cumulative Layout Shift,累计布局偏移,反映页面是否突然移动。
它们受以下因素影响:
网络 + CDN + HTML + JS下载 + 主线程任务 + 渲染 + 设备性能
所以 React Profiler 只能解释其中的组件渲染部分。若 LCP 变差,可能原因包括:
- HTML 首字节变慢;
- 首屏图片未优化;
- CSS 阻塞;
- JavaScript 下载变大;
- hydration 或主线程任务阻塞;
- CDN 命中率下降。
发布诊断应把 Web Vitals 按以下维度切分:
release × route × device × region × connection type
否则新版本只在低端 Android 设备上变慢时,整体平均值可能掩盖问题。
6.5 资源加载错误必须单独监控
资源错误通常不是 React 错误边界能处理的。可以监听资源错误:
window.addEventListener(
'error',
(event) => {
const target = event.target;
if (
target instanceof HTMLScriptElement ||
target instanceof HTMLLinkElement ||
target instanceof HTMLImageElement
) {
reportError({
type: 'resource-load-error',
url: target instanceof HTMLScriptElement
? target.src
: target instanceof HTMLLinkElement
? target.href
: target.src,
release: RELEASE,
});
}
},
true,
);
资源错误的诊断顺序通常是:
- 浏览器 Network 中确认 URL;
- 查看 HTTP 状态码;
- 检查是否返回 HTML 错误页;
- 检查
Content-Type和Content-Encoding; - 检查 CDN 是否持有旧 HTML;
- 检查对应 release 的资源目录是否被删除;
- 检查 CSP、SRI 或跨域策略是否阻止执行。
七、回滚:恢复版本不等于恢复系统
7.1 可回滚发布的前提
一个发布系统只有在以下条件同时满足时才容易回滚:
旧构建产物仍然存在
+ 旧资源 URL 仍可访问
+ HTML 可以切回旧版本
+ API 兼容旧前端
+ 数据库迁移不破坏旧代码
+ 监控能确认恢复结果
因此,推荐将构建产物视为不可变制品:
artifact/
web-2025-03-08-1200/
index.html
assets/
web-2025-03-08-1215/
index.html
assets/
发布时切换的是“当前版本指针”,而不是覆盖同名文件:
current → web-2025-03-08-1215
回滚时:
current → web-2025-03-08-1200
这比重新执行一次旧代码构建更可靠,因为重新构建可能受到:
- 依赖版本漂移;
- Node.js 版本变化;
- 构建环境变化;
- 外部资源变化;
- 未锁定工具版本;
的影响。
7.2 一个可执行的静态发布流程
假设构建产物位于 dist/,发布版本为:
export RELEASE=web-2025-03-08-1200
上传到独立目录:
aws s3 sync dist/ "s3://my-site/releases/$RELEASE/" \
--delete
先验证入口资源:
curl -I "https://example.com/releases/$RELEASE/index.html"
再验证 HTML 中引用的资源:
curl -fsSL "https://example.com/releases/$RELEASE/index.html" \
| grep -oE '(/|[^"]+)(assets/[^"]+\.(js|css))' \
| while read -r path; do
curl -fIs "https://example.com$path" >/dev/null || exit 1
done
实际项目需要根据 HTML 的路径格式调整提取命令。验证的核心不是这条命令本身,而是确认:
入口 HTML 中声明的每一个生产资源都能以 2xx 返回
切换当前版本可以通过 CDN 配置、Nginx 符号链接或服务端版本映射完成。例如在文件系统中:
ln -sfn "/var/www/releases/$RELEASE" /var/www/current
之后进行冒烟测试:
curl -fsS https://example.com/healthz
curl -fsS https://example.com/ | grep -q '<script'
再用浏览器自动化测试:
打开首页
登录测试账号
进入一个动态路由
执行关键业务动作
检查控制台无资源加载错误
回滚:
export ROLLBACK_RELEASE=web-2025-03-08-1145
ln -sfn "/var/www/releases/$ROLLBACK_RELEASE" /var/www/current
风险在于 CDN、浏览器和服务端可能仍缓存旧内容。回滚验证应检查:
- 新请求拿到的 HTML 对应哪个 release;
- HTML 引用的 chunk 是否全部存在;
- API 是否仍接受旧前端请求;
- 灰度 Cookie 是否让部分用户继续进入问题版本;
- 监控错误率是否恢复,而不是只看服务器部署命令成功。
7.3 什么时候不应该只回滚前端
以下情况只回滚前端可能无效:
后端已删除旧字段
旧前端继续发送旧字段,但后端返回 400。
数据库迁移具有破坏性
旧前端读取的列已被删除,回滚代码无法恢复数据结构。
服务端渲染版本不匹配
旧客户端无法 hydration 新 HTML,或新 HTML 依赖旧服务端不存在的组件协议。
已发生不可逆业务副作用
例如新版本重复创建订单。回滚代码不能撤销已经产生的数据,需要业务补偿或数据修复。
因此回滚决策应区分:
代码回滚
配置回滚
流量回滚
功能开关关闭
数据库迁移补救
数据补偿
它们不是同一个操作。
八、一个完整的发布状态机
把发布过程看成状态机,比把它看成“执行几个命令”更准确。
stateDiagram-v2
[*] --> Built
Built --> Uploaded: 上传不可变产物
Uploaded --> Verified: 资源完整性检查通过
Verified --> Canary: 少量流量
Canary --> Expanded: 指标正常
Canary --> RolledBack: 错误率或关键业务失败
Expanded --> Full: 全量流量
Expanded --> RolledBack: 指标恶化
Full --> RolledBack: 线上故障
RolledBack --> Stable: 旧版本验证通过
RolledBack --> Incident: 旧版本也失败或数据已变更
Incident --> [*]
Stable --> [*]
每个状态都有不同的验证条件:
| 状态 | 主要验证 |
|---|---|
| Built | TypeScript、Lint、测试、构建成功 |
| Uploaded | 所有资源进入制品仓库 |
| Verified | HTML 引用的资源可访问 |
| Canary | 灰度组指标与基线比较 |
| Expanded | 逐步提高流量,监控无明显回归 |
| Full | 全量版本运行稳定 |
| RolledBack | 旧入口、旧 chunk、旧 API 兼容性均正常 |
“上传成功”不等于“发布成功”,“切换成功”也不等于“恢复成功”。
九、常见失败模式及诊断方法
9.1 把服务端密钥写进 VITE_ 变量
失败表现:
构建产物中出现数据库连接串或第三方私钥
原因:
VITE_ 变量会被替换到客户端 JavaScript
诊断:
grep -R "postgres://\|PRIVATE_KEY\|PASSWORD" dist/
恢复:
- 立即吊销或轮换已泄露密钥;
- 从客户端代码中移除;
- 让浏览器请求同源服务端接口;
- 在 CI 中加入密钥扫描。
9.2 资源启用长期缓存,但文件名不带哈希
失败表现:
部分用户一直使用旧 JavaScript
新版本只有强制刷新后才出现
原因:
同一个 URL 被缓存了多个内容版本
恢复:
- 为 JS、CSS 等资源使用内容哈希;
- HTML 设置
no-cache或短缓存; - 不要在同名资源上覆盖内容;
- 必要时刷新 CDN,但不要把 CDN 刷新当作版本设计。
9.3 SPA 回退把 JavaScript 请求返回成 HTML
失败表现:
Unexpected token '<'
诊断:
curl -i https://example.com/assets/missing.js
如果响应体开头是:
<!doctype html>
说明静态资源 404 被错误地回退到了 index.html。
恢复:
location /assets/ {
try_files $uri =404;
}
9.4 CSP 阻止 SSR 或第三方 SDK
失败表现:
Refused to execute inline script
Refused to connect to ...
Refused to load the image ...
诊断:
- 查看浏览器 Console 的完整 CSP 报错;
- 确定被阻止的资源类型;
- 检查真实请求域名,而不是只看文档中的主域名;
- 先使用
Report-Only收集报告; - 仅增加必要的来源,不直接添加
unsafe-inline或过宽的*。
常见误区是只把 API 域名放进 script-src。API 请求受 connect-src 控制,脚本来源和连接来源是两类不同权限。
9.5 灰度流量不稳定
失败表现:
同一个用户刷新后版本变化
错误难以复现
接口和页面功能开关不一致
原因可能是:
- 按请求随机分流;
- CDN 没有正确转发 Cookie;
- 灰度规则在多个服务中各自实现;
- 服务端和客户端使用不同的用户标识;
- 用户登录前后的分桶键变化。
恢复:
- 使用稳定用户或租户标识;
- 未登录用户使用签名 Cookie;
- 在日志中记录
release、分桶结果和规则版本; - 版本灰度与功能开关使用明确的优先级;
- 切换用户身份后决定是否重新分桶,并记录这一行为。
9.6 回滚后错误仍然存在
失败表现:
部署系统显示已回滚,但错误率没有下降
可能原因:
- CDN 仍返回问题 HTML;
- 浏览器缓存了问题入口;
- 灰度 Cookie 仍把用户送到新版本;
- 旧版本引用的 chunk 已被删除;
- 问题来自 API 或数据库,而非前端;
- 错误事件延迟上报;
- source map 与 release 不匹配,导致看似不同的错误。
正确做法是逐层验证:
DNS/CDN → HTML → chunk → 客户端 release → API → 数据库 → 监控
不要只检查发布平台上的一个“当前版本”字段。
十、建议的最小生产基线
一个 React 生产系统至少应满足:
构建
├─ 锁定依赖和 Node.js 版本
├─ TypeScript 检查
├─ Lint 和测试
├─ 生成唯一 release
└─ 检查产物中没有私密配置
静态资源
├─ JS/CSS 使用内容哈希
├─ hashed 资源长期 immutable 缓存
├─ HTML 短缓存或 no-cache
├─ 保留旧 release 资源
└─ SPA 路由回退不影响 /assets
安全
├─ 生产使用 CSP
├─ 优先 nonce 或 hash
├─ 明确 connect-src、script-src 等来源
├─ 服务端鉴权不依赖 CSP
└─ 监控和日志脱敏
灰度
├─ 使用稳定分桶
├─ 记录 release 和规则版本
├─ 旧前端与新后端保持兼容
└─ 按错误、性能和业务指标逐步扩大
监控
├─ React Error Boundary
├─ window error 和 unhandledrejection
├─ 资源加载错误
├─ Web Vitals
├─ API 和业务成功率
└─ 错误关联 release、request_id 和 route
回滚
├─ 构建产物不可变
├─ 通过版本指针切换
├─ 数据库使用 expand-contract
├─ 回滚后验证 HTML、chunk、API 和指标
└─ 区分代码、配置、流量和数据补救
React 组件是否正确只是交付链路的一部分。真正稳定的生产交付要求浏览器获得正确的配置、正确的 HTML、正确版本的资源和正确的 API;当新版本失败时,还必须保留一条能被验证的旧路径。把这些对象和状态明确建模后,配置、CSP、灰度、监控与回滚就不再是彼此孤立的部署技巧,而是一套具有版本一致性和故障恢复能力的系统。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:Next.js 应用架构:路由、渲染、数据、缓存、Server Actions 和部署
- 下一篇:React 事件系统:合成事件、传播、优先级、闭包和原生事件
- 延伸:React 项目工具链:Vite、TypeScript、Lint、环境变量和构建
- 延伸:React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle
- 延伸:React 错误边界与可观测性:异常、日志、Web Vitals 和发布诊断
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论