背景
在我的 monorepo 项目 pawHaven中,前端和后端并不是完全割裂的两套系统。
它们天然地共享了一部分代码,例如:
-
常量定义
-
配置结构
-
枚举 / 字典
-
一些纯函数工具
于是,一个看似理所当然的想法出现了:
把这些公共代码抽成一个 shared package,供前后端共同使用。****
在最开始,这个架构让我感到非常兴奋------
TypeScript、pnpm workspace、monorepo 都已经就位,这似乎是一个"马上就能实现"的设计。
问题开始出现
真正的问题,并不是在编写 shared 代码 的时候,而是在运行的时候。
当我:
-
分别打包前端和后端
-
再分别运行它们
问题开始接连出现:
-
Node 运行时报错:Unexpected token 'export'
-
前端构建通过,但运行时提示模块找不到
-
有时是 export 不被支持
-
有时是 require 找不到目标文件
这些错误表面上看起来零散、毫无关联,但本质上都指向同一个问题:
前端和后端对"模块系统"的期望是完全不同的。****
我最初的错误假设
一开始,我的假设是:
能不能在 shared 里打包出一个产物,同时兼容 CommonJS 和 ESM?****
于是我开始不断尝试各种组合:
-
module: ESNext
-
module: CommonJS
-
"type": "module"
-
不同的 moduleResolution(node / nodenext / bundler)
-
各种 tsconfig 的排列组合
结果是------
三天时间,我反复在不同的报错之间循环。****
直到某一刻我意识到一个事实:
试图用"一个构建产物"同时满足 CommonJS 和 ESM,本身就是一个互相矛盾的目标。****
关键认知转变
真正的转折点,来自一个简单但重要的问题:
为什么 shared package 一定要"只产出一个结果"?****
前端和后端的运行环境,本来就是不同的:
| 环境 | 模块期望 |
|---|---|
| 前端(Vite / Webpack) | ESM |
| Node 后端(Nest / require) | CommonJS |
| 既然需求不同,那么结论其实非常自然: |
shared package 不应该妥协成"一个都不完全适配的产物",
而是为不同环境提供各自合适的构建结果。****
最终解决方案:双构建(Dual Build)
最终的方案并不"取巧",而是非常工程化。 具体实现请参考我的真实monorepo流浪动物救助项目pawhaven中的shared模块shared
1️⃣ 一份源码(TypeScript,ESM 写法)
shared 中只维护一份源码,全部使用标准的 ESM 写法:
js
/**
* Remove all types of whitespace:
* spaces, full-width spaces, tabs, line breaks.
*
* @param value string | undefined | null
* @returns cleaned string
*/
export function stringTrim(value: string): string {
// Return empty string for null or undefined
if (value === null || value === undefined) return '';
// Convert to string safely
const str = String(value);
// Normalize full-width spaces to normal spaces
const normalized = str.replace(/\u3000/g, ' ');
// Remove all whitespace: spaces, tabs, newlines, full-width spaces
return normalized.replace(/\s+/g, '');
}
2️⃣ 两个 tsconfig,对应两种构建目标
为 shared package 分别维护两个 tsconfig:
js
packages/shared/
├─ tsconfig.esm.json
├─ tsconfig.cjs.json
json
// tsconfig.esm.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/esm",
"module": "ESNext",
"moduleResolution": "bundler"
},
"exclude": ["node_modules", "dist"]
}
json
// tsconfig.cjs.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/cjs",
"module": "CommonJS",
"moduleResolution": "node"
},
"exclude": ["node_modules", "dist"]
}
这样可以做到:
- ESM 构建:供前端和 bundler 使用
- CJS 构建:供 Node 后端使用
3️⃣ 通过 package.json 精准分流
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"
}
}
}
main / module / types
各自的职责
-
main****
- 给 Node(CommonJS)使用
- 当使用 require('@pawhaven/shared') 时加载
- 指向 dist/cjs/index.js
-
module****
- 给 bundler(Webpack / Rollup / Vite)使用
- 声明这是一个 ESM 入口
- 用于 tree-shaking
- Node 本身不会读取该字段
-
types****
- 给 TypeScript 使用
- 只在编译期生效
- 前后端共用一份类型声明
真正的"裁判":
exports
如果说 main 和 module 更像是"建议",
那么 exports 才是严格的规则定义。
json
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
实际加载行为如下:
| 使用方式 | 命中字段 | 加载产物 |
|---|---|---|
| import ... from '@pawhaven/shared' | exports.import | ESM 构建 |
| require('@pawhaven/shared') | exports.require | CJS 构建 |
| TypeScript 类型解析 | exports.types | .d.ts |
这意味着:
- 前端和后端在无感知的情况下拿到各自正确的实现
- 不需要 runtime 判断
- 不需要环境变量
- 行为在 CI 和本地完全一致
为什么这套方案是稳定的
因为模块的选择发生在:
解析阶段(resolve time),而不是运行阶段(runtime)****
这带来了几个关键好处:
- 没有运行时分支逻辑
- 没有 hack 或条件判断
- 构建结果完全可预测
最终总结
这三天的踩坑,让我真正理解了一件事:
Monorepo 中 shared package 的难点,不在"代码共享",
而在"模块边界的清晰定义"。**** 一个成熟、稳定的 shared 模块应该具备:
- 一份源码
- 多个明确的构建产物
- 严格通过 exports 进行消费分流
而不是试图通过某种"神奇配置",
让一个产物兼容所有运行环境。
这也是目前在大型 monorepo 项目中,
shared 模块最可靠、最可维护的实践之一。