React 跨包通信:分析 react 与 shared 文件夹之间的公共类型定义与常量共享模式

各位同学,大家好!欢迎来到今天的讲座现场。我是你们的老朋友,一个在代码堆里摸爬滚打多年,看着项目从“能跑”变成“能跑且美观”的资深工程师。

今天我们要聊的话题有点硬核,但绝对能拯救你们发际线。话题的标题很长:《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/webpackages/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 对象会被打包进 webdist 目录里。
如果你的 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>
  );
};

这种模式的优势:

  1. 单一事实来源LoginResponse 只定义了一次。
  2. 自动同步:如果你在后端 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.jsontsconfig.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.jsdist/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"
    }
  }
}

这个配置的作用:

  1. 类型解析:当你 import { Status } from '@my-org/shared/constants' 时,TypeScript 会自动去 ./dist/constants.d.ts 找类型定义。
  2. 模块解析:运行时,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
  };
};

关键点检查:

  1. 一致性:如果你在 shared 里把 UserRole 的值改了,webserver 都会报错。
  2. 类型安全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)引用,你需要处理好打包输出。

通常,我们使用工具如 tsuprollup 来打包,配置为同时输出 .js.d.ts,并且处理 importrequire 的互操作性。

简单粗暴的方案:
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/webpackage.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 跨包通信的核心,不在于“通信”这个动作本身,而在于“契约”的制定。

  1. 常量共享:使用 enumas const 对象,确保运行时数据的一致性。
  2. 类型共享:使用 export typeexport interface,确保编译时的类型安全,避免前端传错参数给后端。
  3. 工具链支持:利用 Turborepo 的构建管道,确保类型定义总是最新的。
  4. 架构原则shared 包应该是纯函数和类型的集合,避免引入业务逻辑和框架依赖。

记住,代码复用是美德,但盲目复制粘贴是罪过。 一个设计良好的 shared 包,就像是一个标准的 API 接口文档,它告诉 React 应用:“嘿,我这里有 User 类型,你用的时候要注意 role 必须是字符串,而且后端那边定义的枚举值是固定的。”

当你理解了这一点,你会发现 Monorepo 的威力无穷。你的代码将变得整洁、可维护,而你的老板也会因为你减少了 Bug 而给你加薪(虽然这可能不在今天的讲座范围内,但值得期待)。

最后,我想说:类型安全不是一种限制,而是一种保护。 在 React 和 Shared 包之间建立坚固的连接,就是给未来的自己留一条后路。

好了,下课!希望大家在接下来的项目中,都能写出像瑞士钟表一样精准的跨包代码!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注