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 和资源的发布顺序也重要:

  1. 先上传新版本所有 hashed 资源;
  2. 验证资源可访问;
  3. 再切换或更新 HTML;
  4. 最后按策略清理过期资源。

如果反过来先更新 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 的内联脚本。

关键约束是:

  1. nonce 必须不可预测;
  2. 每个响应最好重新生成;
  3. 不能把 nonce 写死在静态 HTML 中;
  4. HTML 被 CDN 缓存时,nonce 与响应头必须保持一致;
  5. 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,则稳定哈希结果为 04 的用户进入灰度。这里的关键不是具体哈希算法,而是:

同一个用户 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 前端监控的四类信号

前端可观测性通常包含四类信号:

  1. Logs:日志
    记录单个事件,例如接口失败、资源加载失败。

  2. Metrics:指标
    统计错误率、延迟、Web Vitals、业务成功率。

  3. Traces:链路追踪
    关联浏览器请求、网关、后端服务和数据库调用。

  4. 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,
);

资源错误的诊断顺序通常是:

  1. 浏览器 Network 中确认 URL;
  2. 查看 HTTP 状态码;
  3. 检查是否返回 HTML 错误页;
  4. 检查 Content-TypeContent-Encoding
  5. 检查 CDN 是否持有旧 HTML;
  6. 检查对应 release 的资源目录是否被删除;
  7. 检查 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/

恢复:

  1. 立即吊销或轮换已泄露密钥;
  2. 从客户端代码中移除;
  3. 让浏览器请求同源服务端接口;
  4. 在 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 ...

诊断:

  1. 查看浏览器 Console 的完整 CSP 报错;
  2. 确定被阻止的资源类型;
  3. 检查真实请求域名,而不是只看文档中的主域名;
  4. 先使用 Report-Only 收集报告;
  5. 仅增加必要的来源,不直接添加 unsafe-inline 或过宽的 *

常见误区是只把 API 域名放进 script-src。API 请求受 connect-src 控制,脚本来源和连接来源是两类不同权限。


9.5 灰度流量不稳定

失败表现:

同一个用户刷新后版本变化
错误难以复现
接口和页面功能开关不一致

原因可能是:

  • 按请求随机分流;
  • CDN 没有正确转发 Cookie;
  • 灰度规则在多个服务中各自实现;
  • 服务端和客户端使用不同的用户标识;
  • 用户登录前后的分桶键变化。

恢复:

  • 使用稳定用户或租户标识;
  • 未登录用户使用签名 Cookie;
  • 在日志中记录 release、分桶结果和规则版本;
  • 版本灰度与功能开关使用明确的优先级;
  • 切换用户身份后决定是否重新分桶,并记录这一行为。

9.6 回滚后错误仍然存在

失败表现:

部署系统显示已回滚,但错误率没有下降

可能原因:

  1. CDN 仍返回问题 HTML;
  2. 浏览器缓存了问题入口;
  3. 灰度 Cookie 仍把用户送到新版本;
  4. 旧版本引用的 chunk 已被删除;
  5. 问题来自 API 或数据库,而非前端;
  6. 错误事件延迟上报;
  7. 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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。