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 通常负责:

  1. 发现多个包;
  2. 安装依赖;
  3. 将工作区依赖互相链接;
  4. 统一维护锁文件。

它通常不负责:

  • 判断 @acme/ui 是否已经构建;
  • 推导“先构建哪个包”;
  • 缓存任务结果;
  • 只重跑受影响的测试;
  • 根据源码变化决定哪些应用需要重新打包。

例如:

pnpm --filter @acme/web build

只是在 pnpm 的筛选规则下运行 @acme/webbuild 脚本。它不天然意味着“先构建 @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 依赖应区分 peerDependenciesdependencies

一个 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. 包依赖图和任务图不是同一张图

设每个包是一个节点,包之间的依赖关系是有向边:

Gp=(V,Ep)G_p = (V, E_p)

其中:

  • VV 是所有工作区包;
  • (A,B)Ep(A, B) \in E_p 表示包 AA 依赖包 BB

例如:

@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

因此还需要任务图:

Gt=(T,Et)G_t = (T, E_t)

@acme/web#build 不仅可能依赖 @acme/ui#build,还可能依赖:

  • @acme/web 的生成代码;
  • 环境变量;
  • TypeScript 配置;
  • @acme/ui 的构建产物。

2. 一个完整的依赖推导

假设:

shared-types#build
        ↓
ui#build
        ↓
web#build

执行 web#build 时,合法的顺序是:

  1. 构建 shared-types
  2. 构建 ui
  3. 构建 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. 缓存不是“永远不执行”

构建缓存通常可以抽象成:

K=H(I,S,E)K = H(I, S, E)

其中:

  • II 是输入文件及其内容;
  • SS 是任务脚本、配置和依赖关系;
  • EE 是被声明的环境变量;
  • HH 是哈希函数;
  • KK 是缓存键。

当以下内容变化时,缓存应失效:

  • 源文件;
  • 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。关键点不是工具名称,而是变更记录必须回答:

  1. 哪个包改变了;
  2. 这是补丁、次版本还是主版本;
  3. 哪些依赖包需要同步更新;
  4. 发布顺序是否满足依赖关系。

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;但“哪些模块可在浏览器运行、哪些模块只能在服务端运行”通常由具体框架和打包器定义。

因此,下面三件事必须分开:

  1. React 组件是否能渲染;
  2. 模块是否能被打进浏览器 Bundle;
  3. 当前框架是否允许该模块出现在 Server Component 或 Client Component 中。

1. 三类常见包

客户端包

@acme/ui

可以包含:

  • JSX;
  • CSS;
  • 浏览器事件;
  • useStateuseEffect 等客户端 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() {
  // ...
}

可能出现三种结果:

  1. 构建阶段失败:框架识别 server-only 或 Node 专用模块,直接报错;
  2. 打包阶段包含服务端依赖:浏览器 Bundle 变大,且因 fs、数据库驱动等模块无法解析而失败;
  3. 运行时泄露或失败:服务端环境变量、内部 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-boundarieseslint-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,并假设各包已经定义了 buildtypecheck 等脚本。

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/webpackage.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/uidist
  • 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 等不应公开的内容。

诊断:

  1. 查看客户端入口的 import 链;
  2. 搜索是否间接导入 server-auth
  3. 检查包是否使用 server-only 或框架等价机制;
  4. 分析产物中的模块;
  5. 将服务端访问改为请求接口、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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。