React 基础体系 · 第 63/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React CI 质量流水线:类型、Lint、测试、构建、预览和制品
React 项目的 CI 质量流水线,不是把若干命令依次放进云端执行器,而是把“源代码能否被安全地合并、构建、部署和回溯”拆成可验证的条件。
一条完整流水线通常包含:
提交代码
│
├── 安装依赖
├── 类型检查
├── Lint 静态检查
├── 单元测试与组件测试
├── 生产构建
├── 生成并保存制品
└── 为 Pull Request 部署预览环境
这些步骤验证的是不同问题:
| 阶段 | 主要回答的问题 |
|---|---|
| 类型检查 | 代码中的值是否满足声明的静态类型约束? |
| Lint | 代码是否违反约定、规则或已知危险模式? |
| 测试 | 在给定输入和环境下,行为是否符合预期? |
| 构建 | 源代码能否被转换成可运行的生产产物? |
| 预览 | 这个分支实际部署后,页面和路由是否可访问? |
| 制品 | 是否保留了可部署、可审计、可复现的输出? |
它们之间不是互相替代的关系。类型检查通过,不代表运行时数据可信;测试通过,不代表生产构建成功;构建成功,也不代表用户访问的预览环境配置正确。
一、先定义流水线的边界
CI、CD、预览和制品不是同一个概念
**持续集成(Continuous Integration,CI)**指的是:代码变更被频繁合并到共享分支,并由自动化流程验证。CI 的核心产出通常是“验证结果”和“构建制品”。
**持续交付(Continuous Delivery)**在 CI 通过后,把制品发布到可部署状态,但是否正式上线可能仍需人工批准。
**持续部署(Continuous Deployment)**则是在验证通过后自动发布到生产环境。
**预览环境(Preview Environment)**是针对某个 Pull Request、分支或提交创建的临时部署环境。它主要回答:
当前这次代码变更,经过真实构建和部署后,用户能否访问到预期页面?
**制品(Artifact)**是流水线产生并保存的文件集合,例如:
- Vite 应用的
dist/; - Next.js 应用的
.next/及其启动所需文件; - 测试报告;
- 覆盖率报告;
- source map;
- 依赖扫描结果。
制品必须绑定到明确的提交 SHA 或构建编号,否则后续无法确定“部署的到底是哪份代码”。
客户端与服务端边界
React 本身是 UI 库,不规定完整的 CI 或部署方式。现代 React 项目常见两类运行模型:
-
客户端渲染应用
- 例如 Vite + React;
- 构建结果通常是静态 HTML、JavaScript、CSS 和资源;
- 可部署到静态文件服务器或 CDN。
-
包含服务端能力的 React 框架应用
- 例如使用 React Server Components、服务端渲染或路由服务的框架;
- 构建结果可能同时包含浏览器资源、服务端代码和运行时元数据;
- 不能简单地把整个输出目录当成静态网站上传。
因此,“构建成功后上传 dist/”只适用于产生静态输出的构建工具。使用其他框架时,必须以该框架的部署契约为准。
二、流水线的核心模型:每一关验证一个条件
可以把一次提交记为 ,把流水线分成若干检查:
其中:
- :Type Check,类型检查通过;
- :Lint,静态规则检查通过;
- :Unit/Component Test,测试通过;
- :Build,生产构建通过。
只有当四个条件都为真时,提交才满足基本合并质量门禁:
预览和制品通常不是简单的额外布尔条件:
- 预览需要 后才能部署,否则部署的可能是无法验证的代码;
- 制品只有在构建成功后才有意义;
- 生产发布通常还需要环境批准、密钥、数据库迁移策略和回滚策略。
流水线的依赖关系可以表示为:
flowchart LR
A[提交或 Pull Request] --> B[npm ci]
B --> C[类型检查]
B --> D[Lint]
B --> E[测试]
C --> F[生产构建]
D --> F
E --> F
F --> G[保存构建制品]
F --> H[预览部署]
G --> I[后续发布或审计]
H --> J[预览地址]
这里的关键不是“必须严格串行”,而是依赖关系:
- 类型检查、Lint、测试通常可以并行;
- 构建依赖于依赖安装,但不一定依赖其他三个检查;
- 如果产品要求“所有检查通过才能部署”,则预览部署要依赖全部质量检查;
- 保存制品必须依赖构建成功。
三、类型检查:验证静态数据流,而不是验证所有运行时数据
1. TypeScript 检查的对象
类型检查器分析变量、函数参数、返回值、对象属性和模块导入之间的关系。例如:
type User = {
id: string;
name: string;
};
function UserName({ user }: { user: User }) {
return <span>{user.name}</span>;
}
const user: User = {
id: "u-1",
name: "Ada",
};
export function App() {
return <UserName user={user} />;
}
如果传入错误的属性:
<UserName user={{ id: 1, name: "Ada" }} />
TypeScript 会报告 id 应为 string 而不是 number。
类型检查验证的是编译时可见的代码关系。它不自动验证服务端在运行时返回的数据:
const response = await fetch("/api/user");
const user = (await response.json()) as User;
as User 只是告诉 TypeScript“把这个值当作 User”,并没有检查 JSON 是否真的符合结构。如果服务端返回:
{
"id": 1,
"name": null
}
类型检查仍可能通过,运行时却可能出现错误。
因此,客户端与服务端之间应把网络数据视为不可信输入,必要时使用运行时校验库或手写解析函数:
type User = {
id: string;
name: string;
};
function parseUser(value: unknown): User {
if (
typeof value !== "object" ||
value === null ||
!("id" in value) ||
!("name" in value) ||
typeof value.id !== "string" ||
typeof value.name !== "string"
) {
throw new Error("Invalid user payload");
}
return {
id: value.id,
name: value.name,
};
}
这里的流程是:
fetch返回的数据类型是运行时值;response.json()得到的内容不能由 TypeScript 静态证明;parseUser先检查未知值;- 检查成功后,函数才返回满足
User的对象; - 检查失败则显式抛错,调用方可以显示错误状态或终止操作。
2. CI 中的类型检查命令
推荐把类型检查放入 package.json:
{
"scripts": {
"typecheck": "tsc --noEmit"
}
}
--noEmit 表示只做类型分析,不输出 JavaScript 文件。它适用于“构建工具负责转换代码、TypeScript 负责检查类型”的项目。
一个基础的 tsconfig.json 可以是:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}
需要区分几个选项:
strict: true开启一组更严格的类型检查;jsx: "react-jsx"使用现代 JSX 转换,不需要每个 JSX 文件都显式导入React;moduleResolution: "Bundler"适合由现代打包器处理模块的项目;noEmit: true防止类型检查阶段产生与构建工具不一致的输出;skipLibCheck: true跳过部分依赖声明文件检查,可以降低第三方类型噪音,但不能解决业务代码的类型错误。
skipLibCheck 是工程取舍,不是“类型更正确”。如果依赖类型冲突导致构建失败,仍应检查依赖版本和类型包,而不是长期依赖忽略检查。
3. 类型检查的常见失败路径
失败路径一:编辑器通过,CI 失败
编辑器可能使用了不同的 TypeScript 版本、不同的 tsconfig 或缓存结果。CI 使用锁定依赖重新安装后,才暴露真实错误。
验证方式:
npm ci
npm run typecheck
应确保本地和 CI 使用同一个锁文件以及兼容的 Node.js 版本。
失败路径二:测试通过,类型检查失败
测试运行器可能通过转译直接执行 TypeScript,并不进行完整类型检查。例如某些测试工具会把类型语法去掉后执行代码。
因此:
npm test
不能替代:
npm run typecheck
失败路径三:使用过多 any 或不安全断言
如果大量使用:
const value: any = externalValue;
类型系统就无法继续传播约束。CI 可能“通过”,但这不是类型安全,而是绕开了检查。
四、Lint:检查代码模式,不负责证明业务正确
1. Lint 检查什么
Lint 是基于语法树、作用域和规则集合进行的静态检查。它可以发现:
- 未使用变量;
- 不一致的导入方式;
- 潜在错误的 React Hook 调用;
- 不允许的依赖;
- 明显危险或难以维护的代码模式;
- 项目约定不一致。
Lint 通常不能证明:
- API 一定返回正确数据;
- 用户点击后一定得到正确业务结果;
- 组件在所有屏幕尺寸都美观;
- 数据库事务一定不会失败。
React 项目中,Hook 规则尤其重要。Hook 的调用顺序必须在每次渲染中保持一致。例如:
function Panel({ enabled }: { enabled: boolean }) {
if (enabled) {
useEffect(() => {
console.log("enabled");
}, []);
}
return null;
}
这个写法违反 Hook 调用规则,因为当 enabled 在不同渲染中变化时,useEffect 的调用位置会变化。应改为:
function Panel({ enabled }: { enabled: boolean }) {
useEffect(() => {
if (enabled) {
console.log("enabled");
}
}, [enabled]);
return null;
}
第一种写法可能在简单测试中暂时不报错,但它破坏了 React 运行时依赖 Hook 顺序管理状态的前提。Lint 在这里发现的是结构性风险,而不是某个特定输入下的业务错误。
2. 一个现代 ESLint 配置示例
现代 ESLint 支持 flat config。示例:
// eslint.config.js
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
{
ignores: ["dist", "coverage", "node_modules"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
globals: {
...globals.browser,
...globals.es2022,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-explicit-any": "warn"
},
}
);
脚本:
{
"scripts": {
"lint": "eslint ."
}
}
这里有几个边界:
react-hooks/rules-of-hooks关注 Hook 的调用位置;react-hooks/exhaustive-deps通常用于提醒 Effect 依赖可能不完整,但它不是对所有闭包逻辑的数学证明;no-explicit-any设为warn时,警告是否让命令失败取决于命令参数和 CI 策略;- 如果项目要求警告也阻断流水线,可以使用:
eslint . --max-warnings=0
Lint 规则不应无限增加。每条规则都应有明确目的:防止错误、统一约定或降低维护成本。否则开发者会通过禁用规则、添加无意义注释来“修复”流水线,静态检查就失去信号价值。
五、测试:验证运行时行为及其边界
1. 测试和类型检查的区别
类型检查验证代码结构,测试验证运行行为。
例如:
function add(a: number, b: number): number {
return a + b;
}
这个函数类型正确,但实现可能错误:
function add(a: number, b: number): number {
return a - b;
}
类型检查仍通过,测试才能发现行为错误:
import { describe, expect, it } from "vitest";
import { add } from "./add";
describe("add", () => {
it("returns the sum of two numbers", () => {
expect(add(2, 3)).toBe(5);
});
});
2. 测试层次
React CI 中常见三类测试:
单元测试
验证纯函数、格式化逻辑、状态转换等局部行为。它们通常运行快,失败定位清楚。
组件测试
验证组件在特定输入下的渲染、交互和可访问行为:
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import Counter from "./Counter";
describe("Counter", () => {
it("increments after clicking the button", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(
screen.getByRole("button", { name: /increment/i })
);
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
});
这个测试验证了完整的用户可观察路径:
- 挂载
Counter; - 通过可访问角色找到按钮;
- 模拟用户点击;
- 等待可能的异步更新;
- 断言界面显示结果。
相比直接查找 CSS 类名,使用角色和可访问名称更接近用户与页面的交互方式,也减少了实现细节变化导致的脆弱测试。
端到端测试
启动真实应用,通过浏览器访问页面并执行跨组件、路由和网络交互。它可以发现:
- 路由配置错误;
- 静态资源路径错误;
- 服务端渲染与客户端 hydration 不一致;
- 生产环境变量缺失;
- 反向代理或 Cookie 配置问题。
但端到端测试启动成本更高,通常不会替代全部单元测试和组件测试。
3. Vitest 配置示例
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
coverage: {
reporter: ["text", "html", "lcov"],
},
},
});
测试初始化文件:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
脚本:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
jsdom 提供浏览器 DOM 的模拟环境,但它不是完整浏览器。以下能力可能与真实浏览器不同:
- 布局和实际像素;
- 浏览器导航;
- 某些 Web API;
- CSS 渲染;
- Service Worker;
- 跨域行为。
所以 DOM 测试通过后,仍可能需要浏览器端到端测试验证真实运行环境。
4. 测试失败的诊断顺序
当测试在 CI 失败而本地通过,应优先检查:
- Node.js 版本是否一致;
- 是否使用
npm ci安装了锁文件指定的依赖; - 是否依赖本地时区、语言环境或当前时间;
- 是否依赖测试执行顺序;
- 是否存在未等待完成的异步任务;
- 是否有并发测试共享了可变全局状态;
- 是否依赖真实网络、数据库或外部服务。
例如,下面的测试具有时间不确定性:
expect(new Date().toISOString()).toBe("2025-01-01T00:00:00.000Z");
更稳妥的方式是注入时钟,或在测试中固定时间。测试必须明确自己的输入、环境和预期输出,否则失败时无法区分代码问题与环境问题。
5. 覆盖率不是正确率
覆盖率表示被测试执行到的代码比例,例如:
它不能推出:
一个错误断言也可能执行所有代码行:
it("works", () => {
expect(renderResult).toBeTruthy();
});
因此覆盖率适合发现“完全没有测试的区域”,不适合作为唯一质量指标。更重要的是覆盖边界条件、失败路径、权限判断和数据转换。
六、构建:验证源代码能否变成生产运行时
1. 构建做了什么
对于客户端应用,构建通常包含:
- 解析模块依赖;
- 转换 TypeScript 和 JSX;
- 处理 CSS、图片、字体等资源;
- 进行压缩和代码分割;
- 生成 HTML、JavaScript、CSS 与资源文件;
- 写入生产环境配置或生成运行时配置。
构建器可能执行转译,但转译不一定等于类型检查。以常见的快速转译方案为例,它可以把:
const value: string = "hello";
转换成浏览器可执行代码,却不验证 value 在后续使用中是否满足所有类型约束。因此仍需要单独的 tsc --noEmit。
2. Vite 客户端应用示例
package.json:
{
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"build": "vite build",
"preview": "vite preview"
}
}
执行:
npm run build
典型结果会包含类似信息:
vite vX.Y.Z building for production...
✓ modules transformed.
dist/index.html
dist/assets/index-xxxxx.js
dist/assets/index-xxxxx.css
✓ built in ...
这里的文件名和耗时随版本、代码和环境变化,不能在流水线中依赖具体数字。真正需要验证的是:
- 命令退出码为
0; - 输出目录存在;
- HTML 引用了实际生成的资源;
- 生产服务器能正确返回这些文件。
本地预览:
npm run preview
vite preview 的作用是提供一个本地静态服务器来检查构建输出,它不是生产服务器。它不能自动代替:
- CDN;
- HTTPS 终止;
- 缓存策略;
- 压缩;
- 访问控制;
- 日志和监控。
3. 服务端渲染或 React Server Components 的边界
如果项目使用服务端渲染或 React Server Components,构建结果可能包括:
- 浏览器端 bundle;
- 服务端 bundle;
- 路由清单;
- 服务端渲染入口;
- 框架运行时文件。
这类项目的“构建成功”只说明框架能够产生预期运行时文件,不说明可以把任意目录直接放到静态服务器。
例如,服务端代码可能读取:
const data = await database.query(...);
此时部署必须提供:
- Node.js 或框架要求的运行时;
- 数据库连接;
- 生产环境变量;
- 正确的服务端启动命令;
- 必要的网络权限。
React 的客户端组件不能直接访问服务端专用资源。相反,服务端组件也不能随意使用浏览器专有对象,例如 window 或 document。CI 应通过构建规则、Lint、测试和实际预览共同暴露这类边界错误。
七、预览:验证“部署后的系统”,而不是只验证源代码
1. 预览部署的运行过程
一次 Pull Request 预览通常经历:
sequenceDiagram
participant Dev as 开发者
participant Git as Git 平台
participant CI as CI Runner
participant Build as 构建系统
participant Host as 预览平台
participant Browser as 浏览器
Dev->>Git: 推送提交
Git->>CI: 触发工作流
CI->>CI: 安装依赖并运行质量检查
CI->>Build: 执行生产构建
Build-->>CI: 返回构建输出
CI->>Host: 上传或部署制品
Host-->>CI: 返回预览 URL
CI-->>Git: 写入检查结果和预览地址
Browser->>Host: 访问预览页面
Host-->>Browser: 返回页面与资源
预览比本地开发服务器更接近生产,因为它通常使用:
- 生产构建;
- 真实路由配置;
- 真实静态资源路径;
- 部署平台的环境变量;
- 类似生产的 HTTPS 和域名。
2. 预览失败的几种不同含义
构建失败
例如模块导入错误、类型错误、环境变量处理错误。此时没有可部署的制品,预览不应启动。
部署失败
构建成功,但上传权限、平台 API、项目配置或资源大小限制导致部署失败。
页面加载失败
部署成功,但浏览器访问时报错。常见原因包括:
base或资源前缀错误;- SPA 路由没有 fallback;
- 环境变量在构建时未注入;
- 服务端与客户端输出不一致;
- 预览域名的 Cookie、CORS 或 CSP 配置不正确。
功能失败
页面可以加载,但 API 地址、数据库、认证或第三方服务不可用。这说明“静态部署成功”并不等于“系统功能正确”。
3. 客户端和服务端的预览差异
纯客户端静态应用的预览,通常只需要上传静态文件并配置 SPA fallback。
带服务端的 React 应用则需要同时验证:
- 服务端进程是否正常启动;
- 服务端路由是否返回预期状态码;
- API 和页面是否使用正确环境;
- 数据库迁移是否已执行;
- 服务端错误是否被记录;
- 客户端是否成功完成 hydration。
预览环境不应默认连接生产数据库。常见做法是使用隔离数据库、脱敏数据或只读数据源,避免 Pull Request 代码修改生产数据。
八、制品:把“可验证结果”变成可部署和可审计对象
1. 制品的身份
一个制品至少应能关联到:
- Git 提交 SHA;
- 构建时间;
- 构建工具版本;
- Node.js 版本;
- 依赖锁文件;
- 构建配置;
- 是否来自受信任分支。
可以把制品标识写成:
web-app:<commit-sha>
而不是:
web-app:latest
latest 会被后续构建覆盖,无法证明某次部署使用了哪份内容。更可靠的部署过程是:
- 从提交
c1a2...构建制品; - 将制品命名为
web-app:c1a2...; - 部署该不可变标识;
- 记录部署环境和时间;
- 出问题时重新部署同一制品或回退到上一制品。
2. 静态客户端制品示例
构建后检查输出:
npm run build
test -f dist/index.html
find dist -type f -maxdepth 3
预期至少应有入口 HTML 和若干资源文件。test -f 在文件不存在时返回非零退出码,因此可作为 CI 质量门禁。
GitHub Actions 中保存制品:
- name: Build
run: npm run build
- name: Verify build output
run: test -f dist/index.html
- name: Upload static artifact
uses: actions/upload-artifact@v4
with:
name: web-dist-${{ github.sha }}
path: dist/
if-no-files-found: error
这里保存的是构建输出,不是源代码。后续发布任务可以下载同一个制品,而不必重新构建。
3. 为什么发布阶段不应随意重新构建
假设第一次构建使用依赖集合 ,发布时重新安装依赖得到 。即使源代码提交相同,也可能出现:
原因可能包括:
- 锁文件未被严格使用;
- 包管理器版本不同;
- 原生依赖重新编译;
- 构建时间或环境变量不同;
- 上游依赖发布了不兼容内容。
因此,更稳定的模型是:
源代码 + 锁文件 + 固定工具链
│
▼
一次构建
│
▼
不可变制品
│
├── 预览
├── 测试环境
└── 生产环境
如果必须在不同环境中重新构建,应把环境差异、依赖版本和构建参数纳入审计,而不是把“重新构建”视为无成本操作。
九、一个可执行的 GitHub Actions 流水线
下面示例以 React + TypeScript + Vite + Vitest 为基础。它不代表所有框架的唯一方案,但展示了质量检查、构建和制品之间的依赖关系。
# .github/workflows/ci.yml
name: React CI
on:
push:
branches: ["main"]
pull_request:
permissions:
contents: read
concurrency:
group: react-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Typecheck, lint and test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint -- --max-warnings=0
- name: Test
run: npm run test -- --reporter=dot
build:
name: Production build
needs: quality
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Verify build output
run: test -f dist/index.html
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: web-dist-${{ github.sha }}
path: dist/
if-no-files-found: error
retention-days: 14
每一步为什么成立
actions/checkout
将触发工作流的提交检出到 Runner。CI 必须检查实际触发提交,而不是默认分支最新代码。
actions/setup-node
固定 Node.js 主版本,减少本地和 CI 的运行时差异。实际项目应根据框架和依赖支持范围选择版本,不应盲目使用最新版本。
npm ci
npm ci 面向自动化环境:
- 依据锁文件安装;
- 通常要求锁文件与
package.json一致; - 不应在 CI 中修改锁文件;
- 安装前会清理已有依赖目录。
如果仓库使用 pnpm 或 Yarn,应使用对应的冻结锁文件命令,而不是混用包管理器。
cache: npm
缓存的是包管理器下载缓存,不等于缓存整个 node_modules。它可以减少重复下载,但不能替代锁文件,也不能保证构建结果一致。
needs: quality
让构建只在质量检查成功后执行。这样可以把“能构建”建立在类型、Lint 和测试均通过的前提上。
这种顺序不是唯一选择。若构建和质量检查互不依赖,也可以让它们并行,以缩短总耗时,再用一个汇总任务作为合并门禁。选择串行还是并行,本质上是在“反馈速度”和“失败后消耗的 Runner 时间”之间取舍。
预览部署任务的边界
如果预览平台接受静态目录,可以在 build 之后添加部署任务:
preview:
name: Deploy preview
if: github.event_name == 'pull_request'
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: web-dist-${{ github.sha }}
path: dist
- name: Deploy preview
run: ./scripts/deploy-preview.sh
env:
PREVIEW_TOKEN: ${{ secrets.PREVIEW_TOKEN }}
示例中的 deploy-preview.sh 必须由具体托管平台提供实现。不能把某个平台的 CLI 参数当成通用 React 能力。
尤其要注意 Pull Request 来自 Fork 时的密钥风险:许多 CI 平台不会把仓库 Secrets 提供给不受信任的 Fork 工作流。即使平台允许,也不应直接执行来源不可信的脚本并赋予生产级部署凭据。安全做法是:
- 对外部贡献只执行不带部署密钥的质量检查;
- 由受信任分支或人工批准后执行预览部署;
- 使用最小权限 Token;
- 限制预览环境能访问的数据库和内部网络。
十、把测试报告和构建制品分开保存
构建输出和测试报告的生命周期不同:
- 构建输出用于部署;
- 覆盖率 HTML 用于查看测试范围;
- JUnit XML 用于 CI 平台展示测试结果;
- source map 用于错误定位,但可能暴露源代码信息。
例如:
- name: Test with coverage
run: npm run test:coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ github.sha }}
path: coverage/
if-no-files-found: warn
retention-days: 7
if: always() 的意义是:即使测试失败,也尝试保存已经生成的报告。否则测试进程返回非零码后,后续上传步骤可能不会运行,诊断信息就会丢失。
但 always() 不能用于无条件继续部署。报告上传可以在失败后执行,生产发布不能绕过质量门禁。
十一、失败路径、诊断与恢复
类型检查失败
表现:
Type error: Property 'name' does not exist on type ...
诊断:
- 确认 CI 使用的
tsconfig; - 对比本地 TypeScript 版本;
- 检查是否因为 CI 使用了完整项目范围,而编辑器只检查当前文件;
- 检查生成代码或环境声明是否在 CI 中缺失。
恢复:
修复类型关系、补充正确的环境声明,或调整项目边界。不要通过增加 any 或关闭 strict 让错误消失。
Lint 失败
表现:
error React Hook ... cannot be called conditionally
诊断:
查看规则名称和文件位置,判断它是错误、警告还是格式约定冲突。
恢复:
优先修正代码结构。只有在规则确实与项目约束冲突时,才使用局部禁用,并在代码附近说明原因。大范围关闭规则会降低后续 CI 的发现能力。
测试失败
表现:
- 断言不一致;
- 测试超时;
document is not defined;- 端口占用;
- 网络请求超时。
诊断:
先区分业务断言失败和测试环境失败。组件测试需要 DOM 环境;纯 Node 测试不应依赖 document。涉及网络时,应使用模拟服务或隔离测试服务,而不是依赖不稳定的真实第三方 API。
构建失败
表现:
- 模块找不到;
- 环境变量为空;
- 资源路径错误;
- 内存不足;
- 服务端和客户端模块边界不兼容。
诊断:
使用与 CI 相同的 Node.js 版本和锁文件在本地执行:
rm -rf node_modules
npm ci
npm run typecheck
npm run lint -- --max-warnings=0
npm run test
npm run build
这组命令应在与 CI 相近的工作目录、环境变量和操作系统条件下执行。若本地仍无法复现,应保存 CI 日志、构建配置和依赖信息。
预览失败
表现:
- 部署任务成功但页面白屏;
- HTML 能打开但 JS 资源 404;
- 刷新子路由返回 404;
- 页面加载后 API 请求失败;
- SSR hydration 报警或报错。
诊断:
按层检查:
- DNS 和 HTTPS;
- HTML 响应状态;
- 静态资源请求;
- 浏览器 Console;
- API 请求和响应;
- 服务端日志;
- 环境变量和 Cookie;
- 路由 fallback 或服务端路由。
恢复时不要直接重新运行一遍构建来“碰运气”。先确认问题属于制品、部署配置还是运行时依赖。如果制品正确,应重新部署同一制品;如果制品错误,则修复提交后重新构建。
十二、缓存、并发和可复现性
1. 缓存不能改变正确性
缓存的目标是减少下载和计算时间,不应成为正确性的前提。安全的缓存键至少应包含:
- 操作系统;
- Node.js 主版本;
- 包管理器;
- 锁文件哈希。
如果依赖锁文件发生变化,缓存应失效。否则可能出现“本地全新安装失败,但 CI 使用旧缓存通过”的误导结果。
2. 并发提交的取消策略
开发者连续推送多个提交时,旧提交的流水线可能仍在运行。使用:
concurrency:
group: react-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
可以取消同一分支上尚未完成的旧任务。
这里的风险是:若某个任务承担不可中断的部署、数据库迁移或发布职责,不应简单套用取消策略。质量检查适合取消旧任务,生产变更需要单独设计并发控制和恢复流程。
3. 环境变量的时机
客户端构建通常会把公开配置嵌入静态 JavaScript。于是:
构建时环境变量
│
▼
写入客户端资源
│
▼
浏览器可读取
这意味着客户端可见变量不是秘密。API 公钥、功能开关等可以公开,但数据库密码、服务端 Token 和签名密钥绝不能放入客户端构建变量。
服务端运行时配置则可能在进程启动时读取:
部署制品
│
▼
服务端启动
│
▼
读取运行时环境变量
客户端静态应用若需要在不重新构建的情况下切换环境,通常需要单独的运行时配置注入机制,例如由服务器生成配置文件;不能假定静态 JavaScript 会自动读取部署时的新环境变量。
十三、合并门禁应怎样设计
一个可操作的合并门禁可以定义为:
允许合并 =
依赖安装成功
AND 类型检查成功
AND Lint 无错误
AND 测试成功
AND 生产构建成功
覆盖率阈值可以作为附加条件,但要理解其含义。例如:
{
"test": {
"coverage": {
"thresholds": {
"lines": 80,
"functions": 80,
"branches": 70,
"statements": 80
}
}
}
}
具体配置格式取决于测试工具,不能把不同工具的配置直接混用。阈值适合防止覆盖率持续下降,但不能代替对关键业务路径的人工审查。
预览部署通常可以作为 Pull Request 的反馈,而不一定阻断所有合并。是否阻断取决于团队希望验证的内容:
- 只有静态页面检查:预览失败可以阻断;
- 预览依赖临时数据库:应考虑环境不可用导致的误阻断;
- 高风险页面改动:可以要求端到端检查和人工验收;
- 外部 Fork:应限制部署权限。
流水线的目标不是让所有失败都自动修复,而是让失败具有清晰归因:
类型错误 → 修改类型或数据边界
Lint 错误 → 修改代码结构或规则配置
测试失败 → 修复行为或测试环境
构建失败 → 修复模块、配置或框架边界
部署失败 → 修复平台权限或发布配置
运行时失败 → 修复环境、路由、API 或服务端逻辑
当每一层验证的对象、输入和输出都清楚时,React 项目的 CI 才真正从“命令集合”变成了质量流水线:类型检查约束静态数据流,Lint 约束危险代码模式,测试验证运行行为,构建生成生产输出,预览验证真实部署路径,制品则把这次验证结果固定为可部署、可追踪和可恢复的对象。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 水合诊断:不一致、事件绑定、客户端边界和调试
- 下一篇:React 静态交付:CDN、缓存、路由回退、压缩和回滚
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论