React 基础体系 · 第 58/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Monorepo:Workspace、共享包、构建图、版本和边界
Monorepo(单仓库)是把多个应用、库和工具放在同一个 Git 仓库中管理。它解决的是“代码如何共同演进、依赖如何复用、构建如何协调”的问题;它不是某个特定工具,也不等于把所有代码放进一个目录。
一个典型的 React Monorepo 可能同时包含:
repo/
├─ apps/
│ ├─ web/ # 浏览器端应用
│ ├─ admin/ # 另一个浏览器端应用
│ └─ api/ # 服务端应用
├─ packages/
│ ├─ ui/ # 可被客户端使用的 React 组件
│ ├─ config-eslint/ # ESLint 配置包
│ ├─ config-ts/ # TypeScript 配置包
│ ├─ server-auth/ # 仅服务端使用
│ └─ shared-types/ # 不包含运行时副作用的类型或纯函数
├─ package.json
├─ pnpm-workspace.yaml
└─ pnpm-lock.yaml
这里有四个容易混淆的概念:
- Workspace:包管理器识别和链接多个本地包的机制。
- 共享包:可被多个应用依赖的、拥有独立
package.json的包。 - 构建图:描述“哪些包或任务必须先于哪些包或任务执行”的有向图。
- 边界:规定某段代码允许在哪种运行时、由哪个方向依赖,以及哪些内容不能被导入。
Monorepo 的复杂度主要来自这四者的组合,而不是目录本身。
一、Workspace 到底解决什么问题
1. Workspace 是包管理器的多包工作区
以 pnpm 为例,根目录通过 pnpm-workspace.yaml 声明工作区:
packages:
- apps/*
- packages/*
每个匹配目录都必须具有自己的 package.json。例如:
{
"name": "@acme/ui",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.js"
}
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
应用依赖这个本地包:
{
"name": "@acme/web",
"private": true,
"dependencies": {
"@acme/ui": "workspace:*"
}
}
执行:
pnpm install
pnpm 会将 @acme/ui 链接到工作区中的实际包,而不是先从远程 Registry 下载一个同名版本。
此时:
pnpm --filter @acme/web exec node -p \
"require.resolve('@acme/ui/package.json')"
在具体构建环境中通常会解析到工作区内的 packages/ui/package.json 或其链接路径,而不是 node_modules 中的一份独立远程副本。
2. workspace:* 不是通用的 npm 版本语法
workspace:* 是 pnpm、Yarn 等工作区工具提供的协议。它表达的是:
这个依赖必须解析到当前工作区中的包,而不能悄悄使用 Registry 中的同名包。
因此它能防止一种常见错误:本地以为使用了正在修改的包,实际却加载了远程旧版本。
但发布时需要注意:
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}
这类协议不能原样发布给普通 npm 消费者。pnpm 在打包或发布过程中会把它转换成普通的 semver 版本范围,例如:
{
"dependencies": {
"@acme/ui": "1.0.0"
}
}
如果一个包需要独立发布,必须验证发布后的 package.json 和 tarball,而不能只验证 Workspace 内的链接状态。
3. Workspace 不负责构建顺序
Workspace 通常负责:
- 发现多个包;
- 安装依赖;
- 将工作区依赖互相链接;
- 统一维护锁文件。
它通常不负责:
- 判断
@acme/ui是否已经构建; - 推导“先构建哪个包”;
- 缓存任务结果;
- 只重跑受影响的测试;
- 根据源码变化决定哪些应用需要重新打包。
例如:
pnpm --filter @acme/web build
只是在 pnpm 的筛选规则下运行 @acme/web 的 build 脚本。它不天然意味着“先构建 @acme/ui”。如果 web 的构建工具直接读取 @acme/ui/src,可能暂时能工作;如果 web 读取的是 @acme/ui/dist,则可能得到:
Cannot find module '@acme/ui'
或者更隐蔽地继续使用旧的 dist 文件。
因此,Workspace 是依赖发现层;构建图需要由 Turborepo、Nx、Wireit、自定义脚本,或 TypeScript 项目引用等机制建立。
二、共享包不是“公共文件夹”
1. 一个共享包必须有自己的契约
共享包至少应明确:
- 包名;
- 版本;
- 入口;
- 类型入口;
- 导出的子路径;
- 运行时依赖;
- 对宿主提供的 peer dependency;
- 支持的运行环境。
例如 @acme/ui 的入口:
// packages/ui/src/index.ts
export { Button } from "./button";
export type { ButtonProps } from "./button";
组件实现:
// packages/ui/src/button.tsx
import type { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
tone?: "primary" | "danger";
}
export function Button({
tone = "primary",
className,
...props
}: ButtonProps) {
const toneClass = tone === "danger" ? "button-danger" : "button-primary";
return (
<button
className={[toneClass, className].filter(Boolean).join(" ")}
{...props}
/>
);
}
应用使用:
import { Button } from "@acme/ui";
export function DeleteAccountButton() {
return (
<Button tone="danger" type="button">
删除账户
</Button>
);
}
应用不应依赖:
import { Button } from "../../../../packages/ui/src/button";
相对路径绕过了包的 exports、类型声明、构建产物和版本契约。它会让代码“能跑”,却破坏包的独立性。
2. exports 是边界的一部分
下面的配置只公开根入口和 button 子路径:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.js"
}
}
}
于是下面的导入是合法的:
import { Button } from "@acme/ui";
import { Button } from "@acme/ui/button";
而下面的导入应该失败:
import { internalClassName } from "@acme/ui/src/internal";
这不是无关紧要的路径风格问题。未公开的文件可以自由重命名、拆分或改变实现;公开的 exports 才是包对外承诺的一部分。
3. React 依赖应区分 peerDependencies 和 dependencies
一个 React 组件库通常应该这样声明:
{
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
原因是组件库和应用应该共享宿主的 React 实例。若组件库把 React 放在普通 dependencies 中,安装结果可能出现两份 React:
app
├─ react@19.x
└─ @acme/ui
└─ react@19.x
在某些模块解析环境中,组件库代码和应用代码会得到不同的 React 对象。历史上这会导致:
Invalid hook call
以及 Hooks 状态无法匹配等错误。
peerDependencies 表达的是:
使用
@acme/ui的应用必须提供一个兼容的 React 版本。
但它不负责自动保证整个仓库只有一份 React。还需要检查:
pnpm why react
pnpm why react-dom
如果多个包要求互不兼容的 React 主版本,包管理器可能确实安装多份。此时应先统一版本范围,而不是盲目使用 overrides 隐藏冲突。
4. React 19 不会改变包边界的基本规则
React 19 提供了新的 React 能力,但 Monorepo 的包管理规则仍然适用:
- React 组件包仍需要明确入口和类型;
- React 仍通常作为 peer dependency;
- 浏览器端和服务端代码仍必须区分;
- 是否支持 Server Components 取决于框架和构建器,不是仅由
react包决定。
例如,一个普通共享 UI 包可以是:
// packages/ui/src/card.tsx
export function Card({ children }: { children: React.ReactNode }) {
return <section className="card">{children}</section>;
}
但如果其中使用了浏览器 API:
"use client";
import { useState } from "react";
export function Toggle() {
const [enabled, setEnabled] = useState(false);
return (
<button onClick={() => setEnabled((value) => !value)}>
{enabled ? "已开启" : "已关闭"}
</button>
);
}
那么它的使用者必须理解这个模块的客户端性质。"use client" 是被支持 Server Components 的框架识别的模块边界指示,不是 React 在所有运行环境中的通用运行时开关。
三、构建图:从“依赖列表”推导“执行顺序”
1. 包依赖图和任务图不是同一张图
设每个包是一个节点,包之间的依赖关系是有向边:
其中:
- 是所有工作区包;
- 表示包 依赖包 。
例如:
@acme/web -> @acme/ui -> @acme/shared-types
@acme/api -> @acme/shared-types
可以表示为:
graph LR
web["@acme/web"] --> ui["@acme/ui"]
ui --> types["@acme/shared-types"]
api["@acme/api"] --> types
但实际执行的是任务,例如:
@acme/ui#build@acme/ui#test@acme/web#build@acme/api#test
因此还需要任务图:
@acme/web#build 不仅可能依赖 @acme/ui#build,还可能依赖:
@acme/web的生成代码;- 环境变量;
- TypeScript 配置;
@acme/ui的构建产物。
2. 一个完整的依赖推导
假设:
shared-types#build
↓
ui#build
↓
web#build
执行 web#build 时,合法的顺序是:
- 构建
shared-types; - 构建
ui; - 构建
web。
不能反过来,因为 ui 的类型声明或 JavaScript 产物可能来自 shared-types,而 web 又依赖 ui 的产物。
若图是:
A -> B
B -> C
C -> A
则它含有环。拓扑排序不存在,严格的“先构建依赖再构建被依赖”无法成立。
常见环形来源包括:
ui -> shared
shared -> ui
例如 shared 为了复用 UI 类型而导入了 ui,同时 ui 又导入 shared。解决方法不是调整脚本顺序,而是重新划分层次:
shared-types
↑
ui
让低层包不能反向依赖高层包。
3. 用 Turborepo 描述任务依赖
根目录安装并配置 Turborepo 后,可以写:
{
"name": "acme-monorepo",
"private": true,
"packageManager": "pnpm@9.15.0",
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}
turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
}
}
}
这里的:
"dependsOn": ["^build"]
表示当前包的 build 任务依赖其工作区依赖包的 build 任务。于是:
pnpm build
会按图执行,而不是简单地按目录顺序执行。
outputs 说明任务产生哪些文件。它影响缓存正确性:如果构建实际生成了 lib/**,却只声明 dist/**,缓存系统可能认为结果完整,后续任务却找不到 lib 中的文件。
4. 缓存不是“永远不执行”
构建缓存通常可以抽象成:
其中:
- 是输入文件及其内容;
- 是任务脚本、配置和依赖关系;
- 是被声明的环境变量;
- 是哈希函数;
- 是缓存键。
当以下内容变化时,缓存应失效:
- 源文件;
package.json;tsconfig.json;- 构建脚本;
- 依赖包的构建结果;
- 影响产物的环境变量。
若 CI 中构建结果不对,应先查看:
pnpm turbo run build --force
如果强制执行后恢复,说明可能是缓存输入或输出声明错误;如果强制执行仍失败,则问题在实际构建过程,而不在缓存。
强制执行只能用于诊断,不能作为长期修复,因为它会失去缓存带来的增量构建收益。
四、TypeScript 如何参与 Monorepo 构建
1. 路径映射不等于包解析
很多仓库会在根 tsconfig.json 中写:
{
"compilerOptions": {
"paths": {
"@acme/ui": ["packages/ui/src"]
}
}
}
这只告诉 TypeScript 如何解析类型或源码路径。它不一定会改变:
- Node.js 的运行时解析;
- Vite、Webpack、Rspack 的模块解析;
- Jest 或 Vitest 的解析;
- 发布后的包解析。
如果 TypeScript 能通过,但运行时失败:
Cannot find package '@acme/ui'
就说明类型解析和运行时解析并不一致。
更稳妥的方式是让应用使用真正的包入口,并由应用构建工具支持 Workspace 包;如果确实使用 paths,必须同步配置每个运行时和测试工具。
2. 使用项目引用表达类型构建关系
TypeScript 项目引用可以把包之间的类型依赖显式化。
共享类型包:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
UI 包引用它:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"references": [
{ "path": "../shared-types" }
],
"include": ["src"]
}
根配置:
{
"files": [],
"references": [
{ "path": "./packages/shared-types" },
{ "path": "./packages/ui" },
{ "path": "./apps/web" }
]
}
执行:
pnpm exec tsc --build
TypeScript 会根据 references 的关系构建项目,并使用 .tsbuildinfo 判断哪些项目需要重新检查。
但 TypeScript 项目引用主要解决类型检查和声明构建,不会替代 React 应用的 JavaScript 打包器。一个项目可能:
tsc --build成功;- Vite 或 Next.js 构建失败;
- 浏览器运行时仍找不到导出的模块。
所以生产构建通常仍需同时验证类型构建和应用打包。
五、版本:Workspace 内联动和发布版本是两件事
1. 版本描述包的兼容契约
语义化版本通常使用:
主版本.次版本.补丁版本
MAJOR.MINOR.PATCH
通常约定:
- 补丁版本:修复兼容性问题;
- 次版本:增加向后兼容的能力;
- 主版本:存在不兼容变更。
假设 @acme/ui 当前是 1.4.0:
// 旧接口
export function Button(props: ButtonProps) {}
删除 Button 是破坏性变化,不能只把版本改成 1.4.1。如果应用声明:
{
"dependencies": {
"@acme/ui": "^1.4.0"
}
}
按照常见 semver 规则,它允许 1.x 的兼容更新,但不允许 2.0.0。错误的补丁版本会让依赖解析器在“看似合法”的范围内安装一个实际不兼容的版本。
2. workspace:* 和发布后的范围有不同含义
开发时:
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}
表示使用本地工作区版本。
发布后,它可能被转换为精确版本或范围,具体取决于包管理器和发布配置。若需要控制发布后的依赖表达,应在发布流程中检查打包结果:
pnpm pack --pack-destination /tmp/acme-pack
tar -xOf /tmp/acme-pack/acme-web-*.tgz package/package.json
预期应看到普通版本范围,而不是:
"@acme/ui": "workspace:*"
如果仍然存在 Workspace 协议,普通 npm 安装可能失败。
3. 固定版本和独立版本
Monorepo 常见两种版本策略。
固定版本(fixed version):
@acme/ui 2.3.0
@acme/shared-types 2.3.0
@acme/config-ts 2.3.0
多个包共享一个版本。优点是发布和回滚简单;缺点是一个包的小改动可能导致全体版本变化。
独立版本(independent version):
@acme/ui 2.3.1
@acme/shared-types 1.8.0
@acme/config-ts 3.0.0
每个包独立演进。优点是版本语义更精确;缺点是需要处理包之间的版本更新和发布顺序。
Changesets 是常见的版本变更记录工具。一次变更可以写成:
---
"@acme/ui": minor
"@acme/shared-types": patch
---
增加 Button 的 danger 样式,并更新共享类型声明。
随后工具根据变更记录生成版本和 changelog。关键点不是工具名称,而是变更记录必须回答:
- 哪个包改变了;
- 这是补丁、次版本还是主版本;
- 哪些依赖包需要同步更新;
- 发布顺序是否满足依赖关系。
4. peerDependencies 的版本策略必须一致
假设两个共享包分别声明:
// @acme/ui
{
"peerDependencies": {
"react": "^19.0.0"
}
}
// @acme/legacy-widget
{
"peerDependencies": {
"react": "^18.2.0"
}
}
应用同时依赖它们时,包管理器可能无法用一个 React 版本满足两个 peer 范围。结果可能是安装警告、重复 React,或者在更严格的 CI 安装模式下直接失败。
因此升级 React 时,不能只修改应用根依赖,还要检查:
pnpm -r list react react-dom
pnpm why react
并统一所有公开包的 peer 范围。若某个包确实只支持 React 18,应把它视为真实的兼容边界,而不是用强制覆盖掩盖。
六、客户端与服务端边界
React 本身可以用于客户端渲染、服务端渲染以及 Server Components;但“哪些模块可在浏览器运行、哪些模块只能在服务端运行”通常由具体框架和打包器定义。
因此,下面三件事必须分开:
- React 组件是否能渲染;
- 模块是否能被打进浏览器 Bundle;
- 当前框架是否允许该模块出现在 Server Component 或 Client Component 中。
1. 三类常见包
客户端包
@acme/ui
可以包含:
- JSX;
- CSS;
- 浏览器事件;
useState、useEffect等客户端 Hooks;- 不包含数据库、文件系统、服务端密钥。
服务端包
@acme/server-auth
可能包含:
// packages/server-auth/src/session.ts
import "server-only";
export async function getSessionFromDatabase(userId: string) {
// 访问数据库或服务端凭据
return { userId };
}
server-only 是某些框架生态提供的保护机制,用于在客户端错误导入时尽早报错。它不是 Node.js 标准模块,也不是 React 核心 API;是否可用取决于框架和构建环境。
服务端包还可能使用:
- 数据库驱动;
- 文件系统;
- 服务端环境变量;
- 私钥;
- 内部网络地址。
这些内容绝不能通过客户端依赖链进入浏览器。
跨环境包
@acme/shared-types
@acme/shared-utils
这类包只能包含真正跨环境的内容,例如:
// packages/shared-types/src/user.ts
export interface User {
id: string;
name: string;
}
或不依赖 Node、DOM 的纯函数:
export function isNonEmpty(value: string): boolean {
return value.trim().length > 0;
}
不能因为包名叫 shared 就把数据库查询、浏览器 API 和 React UI 全部放进去。一个包只要引入了服务端专用依赖,就不再是通用包。
2. 服务端依赖进入客户端的完整失败路径
假设浏览器端页面导入:
import { getSessionFromDatabase } from "@acme/server-auth";
export function Profile() {
// ...
}
可能出现三种结果:
- 构建阶段失败:框架识别
server-only或 Node 专用模块,直接报错; - 打包阶段包含服务端依赖:浏览器 Bundle 变大,且因
fs、数据库驱动等模块无法解析而失败; - 运行时泄露或失败:服务端环境变量、内部 URL 或数据库连接逻辑被错误地暴露到客户端。
正确的数据流不是“客户端直接调用服务端包”,而是:
浏览器事件
↓
客户端请求 /api/profile 或 Server Action
↓
服务端认证与数据库访问
↓
序列化后的安全数据
↓
客户端渲染
在 Next.js 等支持 Server Components 的框架中,服务端组件可以调用服务端包;客户端组件则应调用公开的请求接口、框架允许的 Server Action,或接收服务端传入的序列化数据。具体 API 和约束由框架版本决定,不能仅依据 React 文档推断。
3. use client 影响的是模块边界
例如:
// packages/ui/src/search-box.tsx
"use client";
import { useState } from "react";
export function SearchBox() {
const [value, setValue] = useState("");
return (
<input
value={value}
onChange={(event) => setValue(event.target.value)}
/>
);
}
在支持 React Server Components 的框架中,导入这个模块的依赖链会被视为客户端代码的一部分。于是 search-box.tsx 不能依赖:
import { queryDatabase } from "@acme/server-auth";
原因是客户端模块需要把相关代码交给浏览器构建,而数据库访问不能成为浏览器代码的一部分。
一个安全的分层是:
@acme/shared-types
↑
@acme/ui
↑
apps/web 的客户端组件
@acme/shared-types
↑
@acme/server-auth
↑
apps/web 的服务端组件或 API handler
箭头表示“依赖”。@acme/ui 可以依赖共享类型,但不应依赖 @acme/server-auth。
七、用包结构强制边界,而不是依赖口头约定
1. ESLint 规则检查依赖方向
可以用 eslint-plugin-boundaries、eslint-plugin-import 或框架提供的规则检查导入方向。抽象规则应类似:
apps/web/client -> packages/ui、packages/shared-types
apps/web/server -> packages/server-auth、packages/shared-types
packages/ui -> packages/shared-types
packages/server-auth -> packages/shared-types
禁止:
packages/ui -> packages/server-auth
packages/shared-types -> packages/ui
检查的重点不是目录名字,而是依赖关系。例如:
// 错误:客户端可达代码导入服务端实现
import { getSessionFromDatabase } from "@acme/server-auth";
即使 TypeScript 能找到这个模块,也不代表架构合法。
2. 通过 package.json 让包管理器帮助检查
应用只声明需要的包:
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/shared-types": "workspace:*"
}
}
不要因为根目录安装了 @acme/server-auth,就让任意子包直接使用它。严格的包管理设置、依赖检查工具和 lint 规则可以防止“隐式依赖”。
隐式依赖的表现通常是:
本地开发正常
CI 使用严格安装模式失败
单独发布或单独构建失败
因为本地 Node 模块解析可能“碰巧”找到根目录依赖,而独立包安装时并没有这个依赖。
3. 运行时边界和类型边界要同时检查
以下代码只使用类型:
import type { User } from "@acme/shared-types";
TypeScript 通常会在编译后删除这个导入,因此它不会形成运行时依赖。但下面的写法会形成真实运行时依赖:
import { normalizeUser } from "@acme/shared-types";
即使 normalizeUser 看起来是纯函数,也必须确认该包的构建产物能在当前环境运行。
import type 可以减少不必要的运行时边,但不能把一个包含服务端副作用的包变成客户端安全包。若包的初始化逻辑、顶层导入或导出结构仍会被打包器解析,边界问题仍然存在。
八、一个可验证的 Monorepo 构建流程
以下流程适用于 pnpm Workspace,并假设各包已经定义了 build、typecheck 等脚本。
1. 安装并查看工作区包
pnpm install
pnpm -r list --depth -1
预期能看到:
@acme/web
@acme/api
@acme/ui
@acme/shared-types
@acme/server-auth
如果某个目录没有被列出,首先检查:
pnpm-workspace.yaml的 glob;- 子目录是否存在
package.json; - 包名是否写错;
- 是否被
private或过滤条件排除。
2. 先验证依赖图
pnpm exec turbo run build --dry
预期输出应包含类似任务:
@acme/shared-types#build
@acme/ui#build
@acme/web#build
并且依赖包的构建任务先于应用任务。
如果没有出现 @acme/ui#build,常见原因是:
@acme/web的package.json没声明@acme/ui;- 使用了相对路径导入;
- 包名与依赖名不一致;
- Turbo 配置没有使用
^build; - 应用实际读取源码别名,但配置没有反映真实包依赖。
3. 执行类型检查和应用构建
pnpm typecheck
pnpm build
建议将两类错误区分处理:
TypeScript 错误:
类型不匹配、声明缺失、项目引用关系错误
Bundler 错误:
exports 不存在、Node 模块进入浏览器、CSS/资源处理失败
运行时错误:
重复 React、环境变量缺失、服务端 API 在浏览器执行
“类型检查通过”只说明 TypeScript 接受了类型程序,不说明构建图、模块环境和运行时都正确。
4. 用过滤命令缩小诊断范围
pnpm --filter @acme/ui build
pnpm --filter @acme/web... build
@acme/web... 表示选择 web 及其工作区依赖的常见写法;具体过滤语法以 pnpm 版本为准。它适合定位:
- UI 包单独构建失败;
- 应用及其依赖一起构建失败;
- 某个包的测试或 lint 失败。
如果单独构建 @acme/ui 失败,则问题通常在 UI 包自身;如果 UI 包单独成功、应用失败,则进一步检查应用的 bundler、exports 和客户端/服务端边界。
九、常见错误及其因果关系
错误一:共享包直接暴露 src
表现:
{
"main": "./src/index.ts"
}
本地某个构建器可能支持 TypeScript 源码,因此看起来正常;但另一个应用、测试环境或发布消费者可能无法处理 .ts 或 .tsx。
原因是包没有定义稳定的运行时产物。更可靠的契约是:
{
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
然后确保 build 真的产生这些文件。
错误二:包能被 TypeScript 找到,但运行时找不到
表现:
TS2307: Cannot find module '@acme/ui'
或 TypeScript 通过后,运行时出现:
ERR_MODULE_NOT_FOUND
常见因果链是:
tsconfig.paths 指向 src
↓
TypeScript 能检查
↓
Node 或 bundler 不认识相同别名
↓
运行时失败
修复方向是统一 TypeScript、bundler、测试工具和实际 exports,而不是单独修改 paths。
错误三:修改共享包后应用仍使用旧代码
表现:
packages/ui/src 已修改
web 仍显示旧行为
可能原因:
- 应用导入
@acme/ui的dist; - UI 没有重新执行
build; - 构建任务缓存命中;
dist没被声明为任务输出;- 应用开发服务器没有监听包的构建产物。
诊断:
pnpm --filter @acme/ui build
pnpm --filter @acme/web build --force
若这样恢复,应修正开发模式或任务图,而不是要求开发者每次手动删除所有目录。
错误四:React Hooks 报 Invalid hook call
诊断顺序应是:
pnpm why react
pnpm why react-dom
pnpm -r list react react-dom
然后检查共享包是否把 React 错误放在:
{
"dependencies": {
"react": "19.x"
}
}
组件库通常应改为 peer dependency,并在根应用提供实际 React 版本。还要确认打包器没有把 React 重复打包成多个实例。
错误五:服务端代码进入浏览器包
表现可能包括:
Module not found: Can't resolve 'fs'
或者浏览器包中出现数据库驱动、内部 URL 等不应公开的内容。
诊断:
- 查看客户端入口的 import 链;
- 搜索是否间接导入
server-auth; - 检查包是否使用
server-only或框架等价机制; - 分析产物中的模块;
- 将服务端访问改为请求接口、Server Action 或服务端组件调用。
不要仅通过删除一个 import 让构建通过,还要确认客户端的业务数据流仍然经过服务端授权检查。
十、Monorepo 的真实取舍
Monorepo 的直接收益是:
- 应用与共享包可以在一次提交中协同修改;
- 类型变更能够沿依赖图传播;
- 统一锁文件减少依赖漂移;
- CI 可以根据构建图只运行受影响任务;
- 共享包可以使用真实的本地版本进行集成测试。
它也会增加成本:
- 包管理器、构建器、测试工具和框架的解析规则需要统一;
- 依赖图变大后,循环依赖更容易出现;
- 发布版本需要额外的变更管理;
- 客户端和服务端边界必须持续检查;
- 缓存配置错误会产生“偶尔成功”的假象;
- 一个共享包的破坏性修改可能影响多个应用。
如果所有代码本来就只服务于一个小应用,Monorepo 可能只是增加配置数量。只有当多个应用确实共享代码、需要统一工具链,或需要跨包原子变更时,它的协调收益才足以抵消复杂度。
十一、判断设计是否成立的四个问题
一个 React Monorepo 的设计可以用以下因果链验证:
包管理器能否正确发现并链接包?
↓
包是否通过 package.json 和 exports 暴露稳定契约?
↓
构建图是否能从依赖关系推导正确执行顺序?
↓
版本范围是否真实表达兼容性?
↓
客户端、服务端和跨环境代码是否没有越过边界?
对应的最小检查命令可以是:
pnpm install
pnpm -r list --depth -1
pnpm exec turbo run build --dry
pnpm typecheck
pnpm build
pnpm why react
其中任意一步失败,都不应直接通过复制文件、增加路径别名或强制覆盖依赖来掩盖问题。Workspace 确认“包在哪里”,共享包定义“包提供什么”,构建图决定“先做什么”,版本系统说明“变化是否兼容”,边界约束“代码可以在哪里运行”。这五个层次同时成立,Monorepo 才不仅是一个多目录仓库,而是一个可独立验证、可持续演进的工程系统。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 国际化:消息目录、Locale、日期数字、路由和回退
- 下一篇:React 组件库发布:构建、类型、样式、Tree Shaking 和版本
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论