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
这里有几个重要事实:
npm create vite@latest执行的是一个项目脚手架;react-ts模板表示 React + TypeScript;npm install根据package.json安装依赖;npm run dev执行package.json中定义的脚本;- 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.json 和 tsconfig*.json 为准,而不是机械复制某个版本的配置。
3. Vite 的核心模型:开发时模块图,构建时资源图
3.1 开发模式不是“先打包再刷新”
传统开发服务器通常先把整个应用打包,然后浏览器加载这个包。Vite 的常见开发模型则是:
- 浏览器请求 HTML;
- HTML 中的模块入口通过
<script type="module">加载; - 浏览器继续请求被
import的模块; - Vite 按请求转换这些模块;
- 文件变化后,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 这类按模块处理的工具;noUnusedLocals和noUnusedParameters:发现无用声明。
这些选项不是 React 规范,而是 TypeScript 与现代前端打包器之间的工程配置。项目模板可能把它们拆到 tsconfig.app.json 和 tsconfig.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>
);
}
可以分成三部分:
UserCardProps只在类型检查阶段存在;- 参数解构和条件表达式会转换为 JavaScript;
- 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; dist和node_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。运行时流程是:
- 首屏加载主 chunk;
- React 渲染
lazy组件; - 浏览器请求异步 chunk;
- 下载成功后完成渲染;
- 请求失败则进入错误处理路径,而不是自动保证页面可用。
因此,真实应用应准备错误边界和重试或刷新策略。网络断开、旧版本资源被删除、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。诊断步骤是:
- 打开浏览器 Network 面板;
- 找到失败的 JavaScript 或 CSS 请求;
- 比较请求 URL 和实际部署前缀;
- 检查
base; - 重新构建并验证生成的 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.json与package.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
预期结果:
- 开发模式显示
http://localhost:3000/api; VITE_ENABLE_BETA=true被解析为布尔值true;- 点击按钮后,请求
/api/health; - API 返回非 2xx 时,
response.ok为false,代码进入错误路径; - 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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 下一篇:React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key
- 延伸:React 生产交付:配置、静态资源、CSP、灰度、监控和回滚
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论