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 项目常见两类运行模型:

  1. 客户端渲染应用

    • 例如 Vite + React;
    • 构建结果通常是静态 HTML、JavaScript、CSS 和资源;
    • 可部署到静态文件服务器或 CDN。
  2. 包含服务端能力的 React 框架应用

    • 例如使用 React Server Components、服务端渲染或路由服务的框架;
    • 构建结果可能同时包含浏览器资源、服务端代码和运行时元数据;
    • 不能简单地把整个输出目录当成静态网站上传。

因此,“构建成功后上传 dist/”只适用于产生静态输出的构建工具。使用其他框架时,必须以该框架的部署契约为准。


二、流水线的核心模型:每一关验证一个条件

可以把一次提交记为 cc,把流水线分成若干检查:

Q(c)=T(c)L(c)U(c)B(c)Q(c) = T(c) \land L(c) \land U(c) \land B(c)

其中:

  • T(c)T(c):Type Check,类型检查通过;
  • L(c)L(c):Lint,静态规则检查通过;
  • U(c)U(c):Unit/Component Test,测试通过;
  • B(c)B(c):Build,生产构建通过。

只有当四个条件都为真时,提交才满足基本合并质量门禁:

Q(c)=trueQ(c)=\text{true}

预览和制品通常不是简单的额外布尔条件:

  • 预览需要 Q(c)=trueQ(c)=\text{true} 后才能部署,否则部署的可能是无法验证的代码;
  • 制品只有在构建成功后才有意义;
  • 生产发布通常还需要环境批准、密钥、数据库迁移策略和回滚策略。

流水线的依赖关系可以表示为:

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,
  };
}

这里的流程是:

  1. fetch 返回的数据类型是运行时值;
  2. response.json() 得到的内容不能由 TypeScript 静态证明;
  3. parseUser 先检查未知值;
  4. 检查成功后,函数才返回满足 User 的对象;
  5. 检查失败则显式抛错,调用方可以显示错误状态或终止操作。

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();
  });
});

这个测试验证了完整的用户可观察路径:

  1. 挂载 Counter
  2. 通过可访问角色找到按钮;
  3. 模拟用户点击;
  4. 等待可能的异步更新;
  5. 断言界面显示结果。

相比直接查找 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 失败而本地通过,应优先检查:

  1. Node.js 版本是否一致;
  2. 是否使用 npm ci 安装了锁文件指定的依赖;
  3. 是否依赖本地时区、语言环境或当前时间;
  4. 是否依赖测试执行顺序;
  5. 是否存在未等待完成的异步任务;
  6. 是否有并发测试共享了可变全局状态;
  7. 是否依赖真实网络、数据库或外部服务。

例如,下面的测试具有时间不确定性:

expect(new Date().toISOString()).toBe("2025-01-01T00:00:00.000Z");

更稳妥的方式是注入时钟,或在测试中固定时间。测试必须明确自己的输入、环境和预期输出,否则失败时无法区分代码问题与环境问题。

5. 覆盖率不是正确率

覆盖率表示被测试执行到的代码比例,例如:

Line Coverage=执行过的代码行数可统计代码总行数\text{Line Coverage} = \frac{\text{执行过的代码行数}}{\text{可统计代码总行数}}

它不能推出:

Coverage=100%没有业务错误\text{Coverage}=100\% \Rightarrow \text{没有业务错误}

一个错误断言也可能执行所有代码行:

it("works", () => {
  expect(renderResult).toBeTruthy();
});

因此覆盖率适合发现“完全没有测试的区域”,不适合作为唯一质量指标。更重要的是覆盖边界条件、失败路径、权限判断和数据转换。


六、构建:验证源代码能否变成生产运行时

1. 构建做了什么

对于客户端应用,构建通常包含:

  1. 解析模块依赖;
  2. 转换 TypeScript 和 JSX;
  3. 处理 CSS、图片、字体等资源;
  4. 进行压缩和代码分割;
  5. 生成 HTML、JavaScript、CSS 与资源文件;
  6. 写入生产环境配置或生成运行时配置。

构建器可能执行转译,但转译不一定等于类型检查。以常见的快速转译方案为例,它可以把:

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 的客户端组件不能直接访问服务端专用资源。相反,服务端组件也不能随意使用浏览器专有对象,例如 windowdocument。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 会被后续构建覆盖,无法证明某次部署使用了哪份内容。更可靠的部署过程是:

  1. 从提交 c1a2... 构建制品;
  2. 将制品命名为 web-app:c1a2...
  3. 部署该不可变标识;
  4. 记录部署环境和时间;
  5. 出问题时重新部署同一制品或回退到上一制品。

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. 为什么发布阶段不应随意重新构建

假设第一次构建使用依赖集合 D1D_1,发布时重新安装依赖得到 D2D_2。即使源代码提交相同,也可能出现:

Build(c,D1)Build(c,D2)\text{Build}(c, D_1) \ne \text{Build}(c, D_2)

原因可能包括:

  • 锁文件未被严格使用;
  • 包管理器版本不同;
  • 原生依赖重新编译;
  • 构建时间或环境变量不同;
  • 上游依赖发布了不兼容内容。

因此,更稳定的模型是:

源代码 + 锁文件 + 固定工具链
        │
        ▼
      一次构建
        │
        ▼
    不可变制品
        │
        ├── 预览
        ├── 测试环境
        └── 生产环境

如果必须在不同环境中重新构建,应把环境差异、依赖版本和构建参数纳入审计,而不是把“重新构建”视为无成本操作。


九、一个可执行的 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 ...

诊断:

  1. 确认 CI 使用的 tsconfig
  2. 对比本地 TypeScript 版本;
  3. 检查是否因为 CI 使用了完整项目范围,而编辑器只检查当前文件;
  4. 检查生成代码或环境声明是否在 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 报警或报错。

诊断:

按层检查:

  1. DNS 和 HTTPS;
  2. HTML 响应状态;
  3. 静态资源请求;
  4. 浏览器 Console;
  5. API 请求和响应;
  6. 服务端日志;
  7. 环境变量和 Cookie;
  8. 路由 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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。