React 基础体系 · 第 2/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 项目工具链:Vite、TypeScript、Lint、环境变量和构建

1. 工具链解决的不是同一个问题

一个 React 项目通常同时包含以下几类工具:

工具 主要问题 是否执行应用逻辑
React 如何声明组件、更新状态并渲染 UI
Vite 如何启动开发服务器、转换模块并生成生产资源 是,负责工程运行时
TypeScript 如何在编译阶段检查类型 否,类型通常会被擦除
ESLint 如何根据规则发现代码问题
环境变量机制 如何向不同运行模式注入配置 间接影响构建和运行
构建工具 如何把源码转换成浏览器可加载的资源 是,生成最终交付物

这些工具之间存在依赖关系,但不能互相替代:

源代码
  │
  ├── TypeScript:检查类型,不产生运行时类型
  ├── ESLint:检查代码规则和潜在错误
  └── Vite:
       ├── 开发模式:转换模块并提供 Dev Server
       └── 构建模式:打包、拆分、压缩并生成静态资源
              │
              ▼
          浏览器加载 dist/

例如,TypeScript 可以发现:

const count: number = "1";

但它不会保证:

  • React Hook 的依赖数组正确;
  • 未清理的事件监听器不会造成泄漏;
  • 浏览器支持某个 Web API;
  • 生产服务器正确返回 index.html
  • 环境变量中没有泄漏密钥。

因此,工具链的正确理解是:每个工具负责一个阶段,阶段之间有明确边界


2. 从 React 项目开始:Vite 创建了什么

现代 React 项目可以使用 Vite 创建:

npm create vite@latest react-toolchain-demo -- --template react-ts
cd react-toolchain-demo
npm install
npm run dev

这里有几个重要事实:

  1. npm create vite@latest 执行的是一个项目脚手架;
  2. react-ts 模板表示 React + TypeScript;
  3. npm install 根据 package.json 安装依赖;
  4. npm run dev 执行 package.json 中定义的脚本;
  5. Vite 开发服务器默认只服务开发过程,不等于生产服务器。

一个典型的 package.json 可能包含:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  }
}

build 中的两个步骤含义不同:

tsc -b
  └── 对 TypeScript 项目进行类型检查或项目构建检查

vite build
  └── 读取入口、转换模块、处理静态资源并生成生产资源

如果项目使用的是模板自动生成的多个 tsconfig 文件,tsc -b 会根据项目引用关系检查它们。具体文件名可能随 Vite 模板版本变化,因此应该以项目实际生成的 package.jsontsconfig*.json 为准,而不是机械复制某个版本的配置。


3. Vite 的核心模型:开发时模块图,构建时资源图

3.1 开发模式不是“先打包再刷新”

传统开发服务器通常先把整个应用打包,然后浏览器加载这个包。Vite 的常见开发模型则是:

  1. 浏览器请求 HTML;
  2. HTML 中的模块入口通过 <script type="module"> 加载;
  3. 浏览器继续请求被 import 的模块;
  4. Vite 按请求转换这些模块;
  5. 文件变化后,Vite 通过 HMR 通知浏览器更新相关模块。

例如入口关系如下:

index.html
   │
   ▼
src/main.tsx
   │
   ├── react
   ├── react-dom/client
   └── ./App.tsx
          │
          ├── ./components/Header.tsx
          └── ./styles.css

浏览器首先请求 main.tsx,随后根据 import 关系继续请求依赖模块。Vite 在这个过程中负责:

  • 将 TypeScript 和 JSX 转换为浏览器可理解的 JavaScript;
  • 解析模块导入;
  • 处理 CSS、图片、字体等资源;
  • 提供错误覆盖层;
  • 监听文件并触发 HMR。

因此,开发模式下“修改一个组件后快速更新”,主要得益于按模块转换和增量更新,而不是因为应用被完整重新构建了一遍。

Vite 内部使用的转换器、打包器会随版本演进,项目不应依赖某个未公开的内部实现。稳定的使用边界是:开发时通过 Vite Dev Server 提供模块,生产时通过 vite build 生成可部署资源。

3.2 vite.config.ts 的作用

一个 React 项目常见的配置如下:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
});

react() 插件负责 React 相关的编译和开发体验,例如 JSX 转换以及 Fast Refresh 集成。没有正确配置 React 插件时,项目可能出现:

  • JSX 无法转换;
  • React Fast Refresh 不生效;
  • 开发服务器无法正确处理 React 文件。

defineConfig 主要提供配置对象的类型提示。它不会在运行时“开启 TypeScript 类型检查”。

3.3 index.html 是 Vite 的入口之一

Vite 项目的 index.html 通常位于项目根目录:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React Toolchain Demo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

src/main.tsx 负责把 React 树挂载到 #root

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

const rootElement = document.getElementById("root");

if (!rootElement) {
  throw new Error("Missing #root element");
}

createRoot(rootElement).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

这里有两个不同层次的问题:

  • document.getElementById("root") 是浏览器运行时行为;
  • createRoot(...).render(...) 是 React 的渲染入口;
  • Vite 只负责让这段代码能够被开发服务器和生产资源加载。

StrictMode 在开发阶段可能故意额外执行某些生命周期相关逻辑,以帮助发现不安全副作用。它不是生产环境的性能开关,也不代表生产环境会永久执行两遍副作用。


4. TypeScript:类型检查不是 JavaScript 运行时

4.1 TypeScript 的类型会被擦除

TypeScript 代码:

function add(a: number, b: number): number {
  return a + b;
}

const result = add(1, 2);

转换后可以近似理解为:

function add(a, b) {
  return a + b;
}

const result = add(1, 2);

number 类型不会出现在浏览器运行时。因此这段代码虽然通过 TypeScript 检查:

const value: number = JSON.parse('{"value": "not-a-number"}').value;
console.log(value.toFixed(2));

仍可能在运行时失败,因为 JSON.parse 的结果来自外部数据,TypeScript 并没有自动验证 JSON 的真实结构。

这说明:

静态类型检查:证明源码满足某些类型规则
运行时验证:检查实际输入是否满足数据契约

对于 API 响应、URL 参数、localStorage 和用户输入,必要时仍要使用运行时校验库或手写校验函数。

4.2 Vite 通常不会替代 tsc

Vite 需要快速转换 .ts.tsx 文件,但它的主要职责不是完整的 TypeScript 类型检查。于是以下代码可能被 Vite 转换并交给浏览器,但应该由 tsc 在 CI 中拒绝:

const count: number = "wrong";

推荐将两个阶段分开理解:

# 类型检查,不生成部署文件
npx tsc --noEmit

# 生产构建
npm run build

如果项目使用项目引用:

npx tsc -b

具体选择取决于项目的 tsconfig 结构。关键不是命令名称,而是确保 CI 中确实执行了类型检查。

4.3 推荐的严格配置

一个简化的 tsconfig.json 可以是:

{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": false,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  },
  "include": ["src", "vite.config.ts"]
}

关键选项的含义:

  • strict:开启一组更严格的类型检查;
  • noEmit:只检查,不让 tsc 输出 JavaScript;
  • jsx: "react-jsx":使用现代 JSX 转换,不要求每个 JSX 文件显式导入 React
  • moduleResolution: "Bundler":按照现代打包器的模块解析模型处理导入;
  • isolatedModules:保证每个文件可以被独立转换,适合 Vite 这类按模块处理的工具;
  • noUnusedLocalsnoUnusedParameters:发现无用声明。

这些选项不是 React 规范,而是 TypeScript 与现代前端打包器之间的工程配置。项目模板可能把它们拆到 tsconfig.app.jsontsconfig.node.json 中。

4.4 JSX、类型和运行时是三件事

下面的组件:

type UserCardProps = {
  name: string;
  age?: number;
};

export function UserCard({ name, age }: UserCardProps) {
  return (
    <section>
      <strong>{name}</strong>
      {age !== undefined && <span>{age} 岁</span>}
    </section>
  );
}

可以分成三部分:

  1. UserCardProps 只在类型检查阶段存在;
  2. 参数解构和条件表达式会转换为 JavaScript;
  3. JSX 会被转换为 React 元素创建逻辑。

类型系统并不会自动把 age 转换成数字,也不会检查服务端返回的数据。若数据来自网络,应先建立运行时边界:

type User = {
  name: string;
  age: number;
};

function isUser(value: unknown): value is User {
  if (typeof value !== "object" || value === null) {
    return false;
  }

  const record = value as Record<string, unknown>;

  return (
    typeof record.name === "string" &&
    typeof record.age === "number"
  );
}

value is User 告诉 TypeScript:当函数返回 true 时,后续代码可以按 User 使用该值。但这个结论成立的前提是检查逻辑本身没有写错。


5. Lint:代码规则检查,不是类型检查

5.1 ESLint 检查什么

Lint 是静态规则检查。它可以发现:

  • 未使用变量;
  • 无法到达的代码;
  • 某些明显的逻辑错误;
  • 不符合团队规则的写法;
  • React Hook 的调用或依赖问题;
  • 导入顺序和风格问题。

它不能完全证明代码正确。例如:

const total = price * quantity;

Lint 可以检查语法和规则,但通常不能知道 price 是否应该是含税价格,也不能证明服务端返回的 quantity 一定是非负整数。

5.2 一个可运行的 ESLint 基础配置

安装基础依赖:

npm install -D eslint @eslint/js typescript-eslint

新版本 ESLint 推荐使用扁平配置 eslint.config.js

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  {
    ignores: ["dist", "node_modules"],
  },
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  {
    files: ["**/*.{ts,tsx}"],
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        {
          argsIgnorePattern: "^_",
          varsIgnorePattern: "^_",
        },
      ],
    },
  },
);

运行:

npm run lint

package.json 中没有脚本,可以添加:

{
  "scripts": {
    "lint": "eslint ."
  }
}

预期行为是:

  • 发现违规时退出码非零;
  • 没有违规时退出码为 0
  • distnode_modules 不参与检查。

退出码很重要,因为 CI 通常依据退出码决定流水线是否继续,而不是依据终端中是否出现了文字提示。

5.3 React Hook 规则需要额外配置

React Hook 的规则属于 React 生态规则,不是 TypeScript 自带规则。典型错误:

useEffect(() => {
  fetchUser(userId);
}, []);

如果 userId 发生变化,副作用仍可能使用旧值。应根据副作用实际依赖补全:

useEffect(() => {
  fetchUser(userId);
}, [userId]);

项目通常会安装并配置 eslint-plugin-react-hooks。不同版本对 Flat Config 的导出方式可能不同,应按照所安装版本的文档配置,不能把某个版本的配置对象直接复制到另一个版本。

规则检查的是依赖表达式的静态关系,而不是运行时证明。以下写法虽然可能绕过某些检查,却没有解决因果问题:

useEffect(() => {
  fetchUser(userId);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

禁用规则只能表示“接受这个风险”,不能让 userId 变成稳定值。

5.4 Lint、格式化和类型检查的边界

三个命令可能分别是:

npm run lint
npx tsc --noEmit
npm run build

它们发现的问题不同:

问题 Lint TypeScript Vite 构建
未使用变量 通常可以 可以,取决于配置 通常不是主要职责
字符串赋给数字 不一定 可以 可能仍然转换
Hook 依赖错误 配置规则后可以 不可以 不可以
无法解析模块 某些配置可以 可以 构建时通常失败
浏览器运行时 API 不存在 通常不可以 通常不可以 通常不可以

格式化工具则主要负责排版,不应被误认为是语义检查器。实际项目可以使用 Prettier 或其他格式化工具,但格式化和 ESLint 的职责仍然不同。


6. 环境变量:构建时注入的配置,不是安全存储

6.1 Vite 环境变量的基本形式

Vite 客户端代码通常通过 import.meta.env 读取变量:

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;

例如创建 .env.development

VITE_API_BASE_URL=http://localhost:3000/api
VITE_ENABLE_BETA=false

再创建 .env.production

VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_BETA=false

根据运行模式,Vite 会加载相应文件。常见文件包括:

.env
.env.local
.env.development
.env.development.local
.env.production
.env.production.local

具体优先级还会受到外部环境变量和当前 mode 的影响;通常应把本地私有覆盖写入 .local 文件,并将不含秘密的示例配置写入版本库。

默认情况下,只有带有 VITE_ 前缀的变量会暴露给客户端:

VITE_PUBLIC_API_URL=https://example.com
DATABASE_PASSWORD=secret

客户端代码可以读取:

import.meta.env.VITE_PUBLIC_API_URL;

但不应读取:

import.meta.env.DATABASE_PASSWORD;

更重要的是,VITE_ 前缀不是安全机制。只要变量进入客户端构建产物,用户就可以通过浏览器开发者工具、资源搜索或网络请求看到它。

因此以下内容不能放进客户端环境变量:

VITE_DATABASE_PASSWORD=secret
VITE_PRIVATE_API_TOKEN=secret
VITE_JWT_SIGNING_KEY=secret

6.2 为什么环境变量常常是字符串

环境变量来自文本文件或进程环境,下面的值都是字符串:

VITE_ENABLE_BETA=false
VITE_RETRY_COUNT=3
const enabled = import.meta.env.VITE_ENABLE_BETA === "true";
const retryCount = Number(import.meta.env.VITE_RETRY_COUNT);

if (!Number.isInteger(retryCount) || retryCount < 0) {
  throw new Error("VITE_RETRY_COUNT must be a non-negative integer");
}

直接写:

if (import.meta.env.VITE_ENABLE_BETA) {
  // ...
}

会产生误解,因为字符串 "false" 是 truthy,条件实际上会成立。

可以用类型声明改善编辑器提示:

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string;
  readonly VITE_ENABLE_BETA?: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

这只改善 TypeScript 检查,不会在运行时自动保证变量存在。若变量是必须配置,应显式验证:

function requireEnv(value: string | undefined, name: string): string {
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

export const config = {
  apiBaseUrl: requireEnv(
    import.meta.env.VITE_API_BASE_URL,
    "VITE_API_BASE_URL",
  ),
};

6.3 环境变量通常是构建时替换

客户端构建中的环境变量通常会被写入或替换到构建产物。于是下面的区别非常重要:

构建一次
  ├── VITE_API_BASE_URL=https://staging.example.com
  └── 生成 dist/

把同一个 dist/ 部署到生产
  └── 不会自动变成 https://api.example.com

如果构建产物中的 URL 已经被替换,部署阶段修改服务器环境变量通常不会影响它。

需要“同一份静态资源在不同环境动态配置”时,可以在 index.html 或服务器注入一个运行时配置:

<script>
  window.__APP_CONFIG__ = {
    apiBaseUrl: "https://api.example.com"
  };
</script>

类型声明:

declare global {
  interface Window {
    __APP_CONFIG__?: {
      apiBaseUrl?: string;
    };
  }
}

export {};

读取时仍然要验证:

const apiBaseUrl = window.__APP_CONFIG__?.apiBaseUrl;

if (!apiBaseUrl) {
  throw new Error("Runtime API configuration is missing");
}

这种方案的代价是:

  • HTML 必须在部署时被注入或由服务器动态生成;
  • CSP 需要允许该脚本,或者改成外部配置文件;
  • 配置加载必须早于应用启动;
  • 配置文件自身不能包含秘密。

6.4 客户端与服务端边界

在纯 SPA 中,src/ 下被导入的代码最终都可能进入浏览器资源。代码是否位于名为 server 的目录,不决定它是否安全;决定因素是它是否被客户端入口导入并打包。

安全的调用方向通常是:

浏览器 React 应用
   │
   │ 请求 /api/orders
   ▼
服务端 API
   │
   ├── 读取数据库凭据
   ├── 访问第三方私有 API
   └── 返回经过筛选的数据

不安全的方向是把第三方私有密钥直接放入 React 代码:

fetch("https://private-service.example.com", {
  headers: {
    Authorization: `Bearer ${import.meta.env.VITE_PRIVATE_TOKEN}`,
  },
});

即使变量名不使用 VITE_,也不能假设它自然安全。被客户端模块引用的秘密要么无法使用,要么会在构建链路中暴露,具体表现取决于配置和插件。

在 SSR 框架或 Vite SSR 中,服务端模块和客户端模块可能有不同的构建边界,但这属于框架的服务端运行模型。判断原则仍然是:会发送到浏览器的代码和数据都不再是秘密


7. 构建:从模块到可部署资源

7.1 vite build 做什么

执行:

npm run build

典型流程是:

读取 mode 和环境变量
  │
  ▼
解析 index.html 和入口模块
  │
  ▼
递归建立静态 import 图
  │
  ▼
转换 TypeScript、JSX、CSS 和资源引用
  │
  ▼
执行生产优化
  │
  ├── 删除不可达代码(取决于模块和代码形态)
  ├── 生成资源文件
  ├── 处理动态 import 的分块
  ├── 压缩代码
  └── 生成 dist/

构建输出一般位于 dist/,但文件名和目录会因版本、配置、资源类型而变化,不能依赖固定名称。生产验证应检查目录内容,而不是只检查命令是否成功:

find dist -maxdepth 2 -type f | sort

执行结果通常会列出:

  • 一个或多个 HTML 文件;
  • 带哈希的 JavaScript 文件;
  • CSS 文件;
  • 图片、字体等静态资源。

哈希文件名的目的之一是缓存失效控制:

app.abc123.js
app.def456.js

源码改变后内容哈希改变,浏览器可以长期缓存旧文件而不会错误复用新版本。

7.2 动态导入如何影响输出

代码:

import { lazy, Suspense } from "react";

const SettingsPage = lazy(() => import("./SettingsPage"));

export function App() {
  return (
    <Suspense fallback={<p>Loading settings...</p>}>
      <SettingsPage />
    </Suspense>
  );
}

import() 是异步模块边界。构建工具通常会将 SettingsPage 放入独立 chunk。运行时流程是:

  1. 首屏加载主 chunk;
  2. React 渲染 lazy 组件;
  3. 浏览器请求异步 chunk;
  4. 下载成功后完成渲染;
  5. 请求失败则进入错误处理路径,而不是自动保证页面可用。

因此,真实应用应准备错误边界和重试或刷新策略。网络断开、旧版本资源被删除、CDN 缓存不一致,都可能导致动态 chunk 加载失败。

7.3 base 与部署路径

如果站点部署在域名根路径:

https://example.com/

通常默认配置可以工作。

如果部署在子路径:

https://example.com/admin/

则需要配置:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  base: "/admin/",
  plugins: [react()],
});

否则构建产物可能请求:

/assets/index-xxx.js

而实际地址应为:

/admin/assets/index-xxx.js

这类错误的表现通常是页面返回 HTML,但脚本请求 404。诊断步骤是:

  1. 打开浏览器 Network 面板;
  2. 找到失败的 JavaScript 或 CSS 请求;
  3. 比较请求 URL 和实际部署前缀;
  4. 检查 base
  5. 重新构建并验证生成的 HTML 中资源路径。

7.4 SPA 路由回退不是 Vite 自动解决的

React Router 等客户端路由可能产生:

/orders/123

首次直接访问这个 URL 时,浏览器会向服务器请求 /orders/123。如果服务器只查找同名文件,可能返回 404。SPA 需要服务器将未知的前端路由回退到 index.html,但静态资源请求仍应返回真实资源或 404。

错误的回退配置可能把 JavaScript 请求也返回成 HTML,浏览器就会报类似:

Unexpected token '<'

因为浏览器把 index.html 当成 JavaScript 解析。

因此需要区分:

静态资源路径:/assets/index-xxx.js  → 返回 JavaScript
客户端路由:/orders/123              → 回退到 index.html

服务器、CDN 或托管平台的回退规则应分别处理两类请求。


8. vite preview 的定位与生产服务器的区别

构建后可以运行:

npm run preview

它用于本地预览已经生成的 dist/,适合验证:

  • 生产构建是否能启动;
  • 资源路径是否正确;
  • 构建时环境变量是否生效;
  • 某些开发模式下被隐藏的问题。

vite preview 不是通用生产服务器。生产环境通常还需要考虑:

  • TLS;
  • 压缩;
  • CDN;
  • 缓存头;
  • SPA 回退;
  • CSP;
  • 日志和监控;
  • 灰度发布与回滚;
  • 多实例和健康检查。

一个基本验证流程可以是:

npm run build
npm run preview

然后访问终端输出的本地地址,并检查:

首页能否加载
JavaScript、CSS 是否返回正确 Content-Type
刷新嵌套路由是否成功
动态 import 是否能加载
环境变量是否指向正确服务
浏览器控制台是否有 CSP 或资源错误

如果只在 npm run dev 下测试,可能漏掉构建后的路径、压缩、代码分块和静态服务器回退问题。


9. 生产构建中的配置、资源和安全边界

9.1 Source map 的取舍

可以在配置中生成 source map:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: {
    sourcemap: true,
  },
});

source map 有助于监控系统将压缩后的堆栈映射回源码,但它也可能暴露源码结构。是否公开 .map 文件取决于部署和监控方案:

  • 上传到错误监控服务后不公开;
  • 让 CDN 不直接提供 source map;
  • 或在确实需要调试的内部环境公开。

source map 不会恢复运行时秘密;秘密一旦进入 JavaScript,是否有 source map 都已经暴露。

9.2 CSP 不由 Vite 自动完成

内容安全策略(CSP)是浏览器约束资源来源和脚本执行方式的安全机制。它通常通过 HTTP 响应头设置,例如:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  connect-src 'self' https://api.example.com;
  img-src 'self' data:;

实际策略必须结合:

  • Vite 生成的资源形式;
  • 是否使用内联脚本;
  • 是否使用动态加载;
  • CSS 和字体来源;
  • API 域名;
  • 是否使用 nonce 或 hash。

开发服务器中的 HMR 通信可能需要额外的连接权限,不能直接把开发环境 CSP 原样复制到生产环境。

如果使用前面提到的 window.__APP_CONFIG__ 内联脚本,严格 CSP 可能阻止它。可改为外部配置文件:

<script src="/runtime-config.js"></script>

再通过 CSP 的 script-src 'self' 允许同源脚本。这样仍需保证配置文件不含秘密。


10. 一套可执行的项目检查流程

可以把本地和 CI 流程拆成以下命令:

npm ci
npm run lint
npx tsc --noEmit
npm run build

每一步的失败含义不同:

npm ci 失败

常见原因:

  • package-lock.jsonpackage.json 不一致;
  • Node.js 版本不满足依赖要求;
  • 网络或私有 registry 不可用。

恢复方法是先确认 Node.js 与项目约束,再检查锁文件和 registry。不要在 CI 中无条件执行 npm install 覆盖锁文件,因为这会使依赖解析结果发生变化。

npm run lint 失败

常见原因:

  • 代码违反 ESLint 规则;
  • 配置文件本身无法加载;
  • 忽略目录配置错误;
  • 插件版本与 Flat Config 用法不匹配。

应先直接运行:

npx eslint src

缩小检查范围,再根据第一条错误定位配置还是源码问题。

npx tsc --noEmit 失败

常见原因:

  • 类型不匹配;
  • 缺少模块声明;
  • tsconfig 未包含文件;
  • DOM、Node 或 Vite 类型未安装;
  • 路径别名在 TypeScript 和 Vite 中配置不一致。

例如 TypeScript 能解析:

import { Button } from "@/components/Button";

但 Vite 不一定知道 @ 指向哪里。路径别名必须在 TypeScript 和 Vite 两边保持一致,或者使用能同步两者的插件;只修改其中一边会出现“编辑器不报错但构建失败”或相反的情况。

vite build 失败

常见原因:

  • 模块导入路径大小写错误;
  • 生产模式环境变量缺失;
  • CSS 或资源导入失败;
  • 只在开发服务器存在的能力被错误依赖;
  • 插件配置不兼容;
  • 动态导入或循环依赖导致构建问题。

在大小写敏感的 Linux CI 上,下面的本地代码可能失败:

import Header from "./components/header";

而真实文件名是:

src/components/Header.tsx

某些大小写不敏感的本地文件系统不会暴露这个问题,CI 构建却会失败。因此版本库中的路径大小写必须和导入语句完全一致。


11. 一个小型端到端示例

11.1 目录

react-toolchain-demo/
├── .env.development
├── .env.production
├── eslint.config.js
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
    ├── App.tsx
    ├── env.d.ts
    └── main.tsx

11.2 配置文件

.env.development

VITE_API_BASE_URL=http://localhost:3000/api
VITE_ENABLE_BETA=true

.env.production

VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_BETA=false

src/env.d.ts

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string;
  readonly VITE_ENABLE_BETA: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

src/App.tsx

import { useState } from "react";

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const betaEnabled = import.meta.env.VITE_ENABLE_BETA === "true";

export default function App() {
  const [status, setStatus] = useState("尚未请求");

  async function checkApi() {
    setStatus("请求中");

    try {
      const response = await fetch(`${apiBaseUrl}/health`);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      setStatus("API 正常");
    } catch (error) {
      console.error(error);
      setStatus("API 请求失败");
    }
  }

  return (
    <main>
      <h1>React 工具链示例</h1>
      <p>当前 API:{apiBaseUrl}</p>
      <p>Beta 功能:{betaEnabled ? "开启" : "关闭"}</p>
      <button type="button" onClick={checkApi}>
        检查 API
      </button>
      <p>{status}</p>
    </main>
  );
}

运行:

npm run dev

预期结果:

  1. 开发模式显示 http://localhost:3000/api
  2. VITE_ENABLE_BETA=true 被解析为布尔值 true
  3. 点击按钮后,请求 /api/health
  4. API 返回非 2xx 时,response.okfalse,代码进入错误路径;
  5. API 不存在或网络失败时,catch 设置失败状态。

这个示例没有把数据库密码放入客户端。真正的 /api/health 由服务端提供,服务端可以读取私有环境变量,但只返回健康状态等允许公开的数据。

构建生产版本:

npm run build

此时 App.tsx 中的 apiBaseUrl 会使用生产 mode 的值。若随后只修改服务器进程环境变量而不重新构建,已经生成的静态 JavaScript 通常不会改变。


12. 常见误解和失败表现

误解一:安装了 TypeScript,浏览器就会做类型检查

浏览器执行的是 JavaScript。TypeScript 类型通常已经在转换阶段被擦除。类型检查必须由 tsc 或集成了类型检查的独立工具执行。

误解二:npm run build 成功就代表应用一定正确

构建成功只能说明构建阶段通过了。它不能证明:

  • API 地址可访问;
  • 用户权限流程正确;
  • 服务端返回数据符合预期;
  • 所有客户端路由能刷新;
  • 第三方脚本满足 CSP;
  • 生产 CDN 正确缓存和回源。

误解三:.env 文件中的值不会出现在前端

VITE_ 前缀的值就是为了暴露给客户端。它们不是秘密。即便文件被加入 .gitignore,构建产物仍可能包含其值。

误解四:Lint 能替代测试

Lint 只能从静态语法和规则层面检查代码。它不能替代单元测试、组件测试、端到端测试和生产监控。

误解五:vite preview 等于生产部署

vite preview 只是用于本地查看构建结果。生产环境还需要正确的 HTTP 服务、缓存、回退、安全策略和故障处理。

误解六:开发环境正常,生产环境就一定正常

开发和生产至少存在这些差异:

开发:
  模块按需转换、未必压缩、HMR 存在、路径通常简单

生产:
  资源被分块和压缩、变量已经注入、静态路径固定、没有 HMR

所以生产验证必须针对 dist/,而不是只验证开发服务器。


13. 与生产交付的连接

工具链的最终结果不是“终端显示构建成功”,而是一个需要被可靠交付的版本。生产发布至少要保存以下信息:

提交版本或构建版本
构建时 mode
非秘密配置摘要
生成的资源清单
source map 是否上传
部署目标和时间

在灰度发布中,可以让一小部分请求先使用新版本静态资源,观察:

  • JavaScript 加载失败率;
  • React 渲染错误;
  • API 错误率;
  • 动态 chunk 加载失败;
  • 页面性能和资源命中率。

回滚时,静态资源和 index.html 必须协调。一个常见风险是只回滚 HTML,却删除了旧版本仍需要的哈希资源,导致用户刷新或异步加载时出现 404。因此资源保留策略、CDN 缓存和版本目录应共同设计。

最终可以将一个 React 项目的交付条件表达为:

交付可接受
=
Lint 通过
∧ 类型检查通过
∧ 生产构建通过
∧ 静态资源路径正确
∧ 客户端没有泄漏秘密
∧ SPA 路由和动态 chunk 可加载
∧ 监控能识别版本
∧ 出错时能够恢复或回滚

这里的合取关系意味着任何一项失败都可能使“构建成功”失去实际意义。Vite 负责把源码转换为可部署资源,TypeScript 和 ESLint 负责在更早阶段减少错误,而环境变量和部署配置决定这些资源在不同环境中的真实行为。只有明确每一层的职责和边界,React 项目的开发、构建与生产交付才不会互相混淆。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。