如何在 Monorepo 中在前端和后端之间共享 TypeScript 代码
Source: Dev.to
请提供您希望翻译的完整文本内容,我将为您翻译成简体中文并保留原始的格式和代码块。
背景
在我的 monorepo 项目 pawHaven 中,前端和后端并未完全隔离。它们自然共享了一部分代码,包括:
- 常量
- 配置模式
- 枚举和字典
- 纯工具函数
将这些公共部分提取到一个共享包中,并在前端和后端之间使用,这种做法显得很自然。使用 TypeScript、pnpm 工作区以及已经搭建好的 monorepo,一切都显得很契合。
当问题出现时
真实的问题出现在分别构建前端和后端后运行应用时:
- Node 报告
Unexpected token 'export' - 前端构建成功但运行时失败
- 有些模块报告缺失
- CommonJS 无法处理 ESM 语法
虽然错误看似随机,但根本原因很明确:前端和后端期望完全不同的模块系统。
我的最初错误假设
我最初以为可以生成一个兼容 CommonJS 和 ESM 的单一构建产出。我花了几天时间尝试:
module: ESNextmodule: CommonJS"type": "module"- 不同的
moduleResolution策略 - 各种
tsconfig组合
经过近三天的反复试验后,我发现单一构建产出无法同时满足 CommonJS 和 ESM。这两个目标本质上是互不兼容的。
思维的关键转变
突破源于一个简单的问题:为什么共享包必须只生成一个输出?
前端和后端环境本质上是不同的:
| 环境 | 模块期望 |
|---|---|
| Frontend (Vite/Webpack) | ESM |
| Node backend (Nest/require) | CommonJS |
因此,正确的做法是为每个环境生成独立的构建,而不是妥协为单一产物。
1. 唯一的真实来源
共享包维护一个使用 ESM 语法编写的单一 TypeScript 源代码库。所有代码都位于唯一的 src 目录中,并使用标准的 export 语句。
2. 两套 TypeScript 配置,两种目标
该包使用两套独立的 TypeScript 配置:
packages/shared/
├─ tsconfig.esm.json
├─ tsconfig.cjs.json
- 一个用于 ESM 输出、面向前端和打包工具的配置。
- 一个用于 CommonJS 输出、面向 Node.js 后端的配置。
tsconfig.cjs.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/cjs",
"module": "CommonJS",
"moduleResolution": "node"
},
"exclude": ["node_modules", "dist"]
}
tsconfig.esm.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/esm",
"module": "ESNext",
"moduleResolution": "bundler"
},
"exclude": ["node_modules", "dist"]
}
该设置会生成:
- 一个面向前端和打包工具的 ESM 构建。
- 一个面向 Node.js 后端的 CommonJS 构建。
3. 通过 package.json 精确入口解析
{
"name": "@pawhaven/shared",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
package.json 定义了针对不同使用者加载哪个构建:
main→ Node.js(require)的 CommonJS 构建。module→ 打包工具(import)的 ESM 构建。types→ 用于类型检查的 TypeScript 声明文件。
exports 字段确保精确解析:
| 使用方式 | 字段 | 输出 |
|---|---|---|
import | exports.import | ESM 构建 |
require | exports.require | CommonJS 构建 |
| TypeScript | exports.types | 声明文件 |
这确保前端和后端都能获得正确的实现,而无需运行时检查或环境变量。
理解 main、module 和 types
main:在 CommonJS 模式下被 Node.js 使用;在调用require()时加载。module:供打包工具使用,以标识 ESM 入口点并启用 tree‑shaking;Node.js 运行时会忽略它。types:在 TypeScript 编译阶段使用,为前端和后端提供类型声明;与运行时无关。
为什么这种方法是稳定的
模块选择发生在 module resolution time,而不是运行时。
好处
- 应用代码中没有条件逻辑。
- 没有依赖环境的 hack。
- 在本地和 CI 环境中构建完全确定。
最后思考
这三天的反复试验让我领悟到一个关键教训:在 monorepo 中共享包的难点并不是代码复用,而是要划定清晰的模块边界。
一个健壮的共享包应当提供:
- 单一的真相来源。
- 多个明确的构建产出。
- 通过
exports严格控制的使用方式。
试图把所有环境都装进同一个产物会导致冲突。双构建策略是大规模 monorepo 中共享模块最可靠、最易维护的模式之一。
如果想看到实际案例,请查看用于流浪动物救助的真实 monorepo 项目: pawhaven‑shared
