各位同学,大家好!欢迎来到今天的讲座现场。我是你们的老朋友,一个在代码堆里摸爬滚打多年,看着项目从“能跑”变成“能跑且美观”的资深工程师。
今天我们要聊的话题有点硬核,但绝对能拯救你们发际线。话题的标题很长:《React 跨包通信:分析 react 与 shared 文件夹之间的公共类型定义与常量共享模式》。
别被这个标题吓到了,咱们把它拆开来看。简单来说,我们今天要解决的问题是:在 Monorepo(单体仓库)里,React 应用、API 服务、以及那个神秘的 shared 文件夹(或者叫 packages/shared)之间,到底该怎么“谈恋爱”? 怎么让它们共享数据类型(TypeScript 的爱恨情仇),怎么共享常量(比如枚举、配置文件),而不是在三个地方分别定义一遍,最后导致“左边是 Active,右边是 active,后端是个大写的 ACTIVE”,最后在上线前一秒引发一场史诗级的 Bug。
准备好了吗?让我们开始这场代码的“联姻”之旅。
第一部分:复制粘贴的诅咒与 Monorepo 的诱惑
首先,我们得面对现实。很多同学,尤其是刚开始接触 Monorepo 的同学,都有一个坏习惯:Ctrl+C, Ctrl+V。
场景是这样的:
你在 apps/web 里写了一个登录页面,需要传一个 status 参数给后端。你定义了一个 enum Status { Success, Error }。
然后你在 packages/api 里写后端接口,为了保持一致,你也定义了一个 enum Status { Success, Error }。
最后你在 packages/shared 里写了一些通用工具函数,觉得这枚举太重要了,不能外泄,于是你又定义了一个 enum Status { Success, Error }。
结果呢?
当你修改了 packages/api 里的 Status.Error,改成了 Status.Failed,却忘了同步 apps/web 和 packages/shared。
然后,前端传了 Success,后端收到 Failed,后端抛了个错,前端显示个“成功”,用户懵了,你懵了,老板更懵了。
这就像三个朋友约好一起吃饭,A 说是吃火锅,B 说是吃烧烤,C 说是吃快餐。最后大家坐在快餐店门口,尴尬地嚼着汉堡。
解决方案只有一个:共享。
在 Monorepo 架构下,我们通常会有一个 packages/shared 包。这个包就像是我们的“公共厨房”。所有的常量、类型定义,都应该放在这个厨房里,然后供 React 应用和其他服务端包去“借用”。
第二部分:常量共享 —— 别让枚举在代码里流浪
常量共享是跨包通信中最基础的一环。这里的核心痛点在于:如何在运行时保证两边的数据是一致的?
1. 枚举 的“双刃剑”
TypeScript 的 enum 是一个非常方便的工具。但在 Monorepo 中使用它,你需要非常小心。
错误的示范:
你把 enum 定义在 packages/shared 里,然后在 apps/web 里直接 import { Status } from '@my-org/shared'。
问题出在哪里?
TypeScript 的 enum 会被编译成 JavaScript 对象。这意味着,你的 Status 对象会被打包进 web 的 dist 目录里。
如果你的 web 是纯前端项目,它不需要知道后端的 Status 具体长什么样,它只需要知道 Status 有哪些值,或者只需要一个字符串 'Success'。
如果 web 打包时包含了 Status 对象,而这个对象和后端不匹配,或者后端更新了枚举,前端没更新,就会出问题。
正确的做法:
我们需要利用 TypeScript 的类型系统,同时控制运行时的行为。
假设我们在 packages/shared 里定义:
// packages/shared/src/constants.ts
export enum StatusCode {
Success = 200,
Error = 500,
NotFound = 404
}
// packages/shared/src/types.ts
export type StatusCodeType = StatusCode; // 别急,后面有用
React 中的用法:
在 React 组件中,我们通常传递的是字符串,而不是枚举对象本身,除非我们需要在 UI 上展示这个枚举的文本。
// apps/web/src/components/Button.tsx
import { StatusCode } from '@my-org/shared';
interface ButtonProps {
status: StatusCode; // 类型检查:必须是枚举里的值
onClick: () => void;
}
export const Button = ({ status, onClick }: ButtonProps) => {
return (
<button onClick={onClick}>
{/* 这里我们只取枚举的数字值,或者字符串值 */}
Status Code: {status}
</button>
);
};
API 中的用法:
在后端代码中,我们需要确保返回的值和前端期望的一致。
// packages/api/src/handlers/user.ts
import { StatusCode } from '@my-org/shared';
export const getUser = () => {
// 返回的是数字 200
return { code: StatusCode.Success, data: { name: 'Alice' } };
};
关键点: 只要 shared 包的版本号管理得当,且 StatusCode 定义一致,前端和后端就能达成“共识”。这里的“共识”是 TypeScript 编译时检查和 JavaScript 运行时数值的一致。
2. 环境变量与配置常量
常量不仅仅是枚举,还有配置文件。
不要把 .env 文件到处放。
要在 shared 里定义配置接口,然后在 .env 中读取,或者直接在 shared 里导出常量。
// packages/shared/src/config.ts
export const APP_CONFIG = {
API_BASE_URL: process.env.REACT_APP_API_BASE_URL || 'http://localhost:3000',
ENABLE_DEBUG_MODE: process.env.REACT_APP_DEBUG_MODE === 'true',
MAX_RETRY_COUNT: 3
} as const;
// 这里的 as const 很关键,它会生成一个只读的常量对象
然后在 React 应用中:
// apps/web/src/App.tsx
import { APP_CONFIG } from '@my-org/shared';
console.log(APP_CONFIG.API_BASE_URL);
这样,所有的包都能访问到同一个配置对象,避免了“开发环境用 localhost,生产环境用 http://api.production.com”这种低级错误。
第三部分:类型共享 —— TypeScript 的灵魂伴侣
如果说常量是硬数据,那么类型就是软逻辑。这是 React 跨包通信中最微妙、最强大,也最容易踩坑的地方。
1. export type vs export interface
这是 TypeScript 开发者必须掌握的基本功。
- Interface (接口):主要用于定义对象的结构。它具有运行时的属性(虽然 TS 会擦除,但某些库如 React 的
ComponentProps会用到)。 - Type (类型):更宽泛,可以是联合类型、函数签名、元组等。
在 shared 包中,我们通常优先使用 export type,除非我们确实需要利用接口的“可扩展性”(即 class 实现 interface)。
场景:定义 API 请求和响应的契约
// packages/shared/src/api/types.ts
// 定义请求参数类型
export type LoginRequest = {
username: string;
password: string;
};
// 定义响应数据类型
export type LoginResponse = {
token: string;
user: {
id: string;
role: 'admin' | 'user'; // 联合类型
};
};
// 定义一个具体的接口,用于 React 组件 Props
export interface UserCardProps {
user: LoginResponse['user']; // 这种方式非常棒,直接引用类型,避免重复定义
onEdit: (id: string) => void;
}
在 React 中使用:
// apps/web/src/components/UserCard.tsx
import { UserCardProps, LoginResponse } from '@my-org/shared';
export const UserCard = ({ user, onEdit }: UserCardProps) => {
return (
<div>
<h3>{user.id}</h3>
<p>Role: {user.role}</p>
</div>
);
};
这种模式的优势:
- 单一事实来源:
LoginResponse只定义了一次。 - 自动同步:如果你在后端 API 文档里把
role改成了'super_admin',前端在导入LoginResponse时,TypeScript 会立刻报错提示你类型不匹配。你根本不需要手动去改UserCardProps。
2. 工具类型的魔法
shared 包不仅仅是一个存类型的地方,它还可以是一个存放“工具箱”的地方。
比如,我们经常需要处理深层的对象复制、部分属性提取等。
// packages/shared/src/utils/types.ts
// 工具类型:提取对象的所有 Key
export type KeysOf<T> = keyof T;
// 工具类型:将对象的所有属性变为可选
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// 工具类型:React 组件的 Props 类型推导
export type PropsWithOptionalChildren<P = object> = P & { children?: React.ReactNode };
在 React 组件中使用:
// apps/web/src/components/Form.tsx
import { PartialBy, KeysOf } from '@my-org/shared';
interface FormFields {
username: string;
password: string;
email: string;
}
// 使用工具类型,让表单的所有字段默认都是可选的
type FormProps = PartialBy<FormFields, 'username'>;
export const Form = (props: FormProps) => {
// TypeScript 会知道 username 是可选的,password 是必填的
return <input type="text" placeholder={props.username} />;
};
这种模式极大地提高了代码的复用率,减少了样板代码。
第四部分:工具链实战 —— Turborepo 与构建配置
光说不练假把式。有了类型和常量,我们还需要一个强大的工具链把它们打包在一起。在 2024 年,Turborepo 是 React Monorepo 的首选。
1. 构建配置的艺术
在 packages/shared 中,我们需要配置 tsconfig.json 和 tsconfig.build.json。
tsconfig.json:用于开发环境,包含源码、测试文件、示例文件。tsconfig.build.json:用于构建环境,排除测试文件和文档,只包含核心逻辑。
tsconfig.build.json 示例:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, // 生成 .d.ts 文件,这对类型共享至关重要
"declarationMap": true,
"removeComments": false
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/examples/**/*"]
}
为什么 declaration: true 这么重要?
当你把 shared 打包成 dist 后,TypeScript 编译器需要读取 .d.ts 文件来理解 shared 包里导出了什么类型。如果你不生成声明文件,React 应用在引用 @my-org/shared 时,会报错说找不到类型定义。
2. package.json 的 exports 字段
现在,shared 包构建好了,生成了 dist/index.js 和 dist/index.d.ts。怎么让 React 应用正确引用它?
不要只写 "main": "./dist/index.js"。我们需要利用 exports 字段来定义入口和类型入口。
{
"name": "@my-org/shared",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./constants": {
"types": "./dist/constants.d.ts",
"import": "./dist/constants.js",
"require": "./dist/constants.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js",
"require": "./dist/types.js"
}
}
}
这个配置的作用:
- 类型解析:当你
import { Status } from '@my-org/shared/constants'时,TypeScript 会自动去./dist/constants.d.ts找类型定义。 - 模块解析:运行时,Node.js 会加载
./dist/constants.js。
3. Turborepo 的管道
在 turbo.json 中,我们需要配置任务的依赖关系。
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
这意味着,当你运行 turbo run build 时,Turborepo 会先构建 shared,因为 web 依赖 shared(^build)。只有当 shared 构建成功并输出了 dist 文件夹后,web 才会开始构建。这保证了 React 应用永远能拿到最新的类型定义。
第五部分:实战演练 —— 从零构建共享层
让我们来一段代码实战。假设我们要构建一个电商系统的用户模块。
1. 项目结构
my-monorepo/
├── apps/
│ ├── web/ # React 前端
│ └── server/ # Node.js 后端
├── packages/
│ └── shared/ # 公共包
│ ├── src/
│ │ ├── constants.ts
│ │ └── types.ts
│ └── package.json
└── turbo.json
2. 定义共享内容
packages/shared/src/constants.ts
export enum UserRole {
Customer = 'CUSTOMER',
Admin = 'ADMIN',
Moderator = 'MODERATOR'
}
export const API_ROUTES = {
LOGIN: '/api/v1/auth/login',
REGISTER: '/api/v1/auth/register'
} as const;
packages/shared/src/types.ts
// 通用响应格式
export type ApiResponse<T = any> = {
success: boolean;
data?: T;
message?: string;
code: number;
};
// 用户实体
export type User = {
id: string;
email: string;
role: UserRole;
createdAt: Date;
};
// React 组件 Props
export interface UserListProps {
users: User[];
onUserClick: (user: User) => void;
}
3. React 组件使用
apps/web/src/components/UserList.tsx
import { UserListProps, UserRole, API_ROUTES } from '@my-org/shared';
export const UserList = ({ users, onUserClick }: UserListProps) => {
return (
<div>
<h1>User Management</h1>
<ul>
{users.map((user) => (
<li key={user.id} onClick={() => onUserClick(user)}>
{user.email} ({user.role})
</li>
))}
</ul>
</div>
);
};
apps/web/src/App.tsx
import { UserList } from './components/UserList';
import { UserRole } from '@my-org/shared';
// 假设这是从 API 获取的数据
const mockUsers = [
{ id: '1', email: '[email protected]', role: UserRole.Customer, createdAt: new Date() }
];
export default function App() {
const handleUserClick = (user: any) => {
console.log('Clicked user:', user);
};
return (
<div className="App">
<UserList users={mockUsers} onUserClick={handleUserClick} />
</div>
);
}
4. 后端服务使用
apps/server/src/handlers/user.ts
import { ApiResponse, UserRole } from '@my-org/shared';
export const getUserHandler = (): ApiResponse<{ id: string; role: UserRole }> => {
return {
success: true,
data: {
id: '1',
role: UserRole.Customer // 确保返回的值是共享枚举中的值
},
code: 200
};
};
关键点检查:
- 一致性:如果你在
shared里把UserRole的值改了,web和server都会报错。 - 类型安全:
UserListProps强制要求传入users必须是User[]。如果你传了一个对象数组,TypeScript 会直接在你脸上给你一巴掌。
第六部分:避坑指南 —— 别掉进这些“坑”
虽然共享包看起来很美好,但如果不注意,它也能成为噩梦。作为资深专家,我必须给你们提个醒。
1. 循环依赖 —— 头号杀手
这是 Monorepo 的死敌。绝对不要让 shared 包依赖 web 包,也不要让 shared 包依赖 server 包。
为什么?
因为 shared 是基础层。如果 shared 依赖了 web,那么当你构建 shared 时,它需要先构建 web。这会导致构建顺序极其复杂,甚至导致死锁。
正确姿势:
所有的业务逻辑、UI 组件,都不要放在 shared 里。shared 只放纯粹的逻辑、类型和常量。如果 UI 组件需要复用,应该放在 packages/ui 里,由 web 引用 ui,而不是 shared 引用 web。
2. 打包格式 —— CommonJS 还是 ESM?
这是近年来前端最大的争议之一。现在主流是 ESM (import/export),但 Node.js 旧版本和很多老项目还在用 CommonJS (require/module.exports)。
如果你的 shared 包同时被 React(支持 ESM)和 Node.js 服务端(可能需要 CJS)引用,你需要处理好打包输出。
通常,我们使用工具如 tsup 或 rollup 来打包,配置为同时输出 .js 和 .d.ts,并且处理 import 和 require 的互操作性。
简单粗暴的方案:
在 package.json 中,同时声明 type: "module" 和 main 字段(指向 CJS)。
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts"
}
这样,React 用 ESM,老项目用 CJS,皆大欢喜。
3. 版本地狱
在 apps/web 的 package.json 里,你写的是 "@my-org/shared": "workspace:*"。这很好。
但是,如果你在 shared 里改了一个常量的名字,记得更新版本号。如果你不更新,web 可能还在引用旧版本。
最佳实践:
使用 changesets 或者 lerna version 来管理版本。当你修改了 shared 的内容,发布一个新版本,web 更新依赖后,才能拿到新的类型定义。
第七部分:进阶模式 —— JSDoc 与生成文件
如果你不想依赖 TypeScript 编译器,或者你想让配置更灵活,可以使用 JSDoc。
JSDoc 的魔法:
/**
* @typedef {Object} User
* @property {string} id - 用户ID
* @property {string} email - 邮箱
* @property {'admin'|'user'} role - 角色
*/
/**
* @param {User} user
*/
export function processUser(user) {
console.log(user.email);
}
虽然现在有了 type 关键字,但在某些老旧项目或需要运行时验证时,JSDoc 依然有用。
生成文件:
还有一种高级模式。我们不手动维护 types.ts,而是用工具(比如 openapi-typescript)从 API 文档自动生成类型文件,然后放入 shared 包中。
npx openapi-typescript http://localhost:3000/api/docs -o packages/shared/src/generated-api-types.ts
这样,前端获取到的类型永远和后端文档是一一对应的。
第八部分:总结 —— 共享的艺术
好了,同学们,今天的讲座接近尾声。让我们回顾一下。
React 跨包通信的核心,不在于“通信”这个动作本身,而在于“契约”的制定。
- 常量共享:使用
enum和as const对象,确保运行时数据的一致性。 - 类型共享:使用
export type和export interface,确保编译时的类型安全,避免前端传错参数给后端。 - 工具链支持:利用 Turborepo 的构建管道,确保类型定义总是最新的。
- 架构原则:
shared包应该是纯函数和类型的集合,避免引入业务逻辑和框架依赖。
记住,代码复用是美德,但盲目复制粘贴是罪过。 一个设计良好的 shared 包,就像是一个标准的 API 接口文档,它告诉 React 应用:“嘿,我这里有 User 类型,你用的时候要注意 role 必须是字符串,而且后端那边定义的枚举值是固定的。”
当你理解了这一点,你会发现 Monorepo 的威力无穷。你的代码将变得整洁、可维护,而你的老板也会因为你减少了 Bug 而给你加薪(虽然这可能不在今天的讲座范围内,但值得期待)。
最后,我想说:类型安全不是一种限制,而是一种保护。 在 React 和 Shared 包之间建立坚固的连接,就是给未来的自己留一条后路。
好了,下课!希望大家在接下来的项目中,都能写出像瑞士钟表一样精准的跨包代码!