在 Monorepo 中如何让一个 TypeScript Shared 模块同时服务前后端 ,一次三天的挣扎与最终解法

背景

在我的 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 模块最可靠、最可维护的实践之一。

相关推荐
Web极客码7 小时前
深度解析 OpenClaw 2026.3.7 重磅更新:可插拔 ContextEngine 重塑智能体架构
架构
Maverick068 小时前
OceanBase 架构原理深入
架构·oceanbase
BPM6668 小时前
2026流程管理软件选型指南:从Workflow、BPM到AI流程平台(架构+实战)
人工智能·架构
Volunteer Technology9 小时前
中间件场景题归纳
中间件·面试·架构
Shining059610 小时前
AI 编译器系列(七)《(MLIR)AscendNPU IR 编译堆栈》
人工智能·架构·mlir·infinitensor·hivm·ascendnpu ir
GJGCY10 小时前
中小企业财务AI工具技术评测:四大类别架构差异与选型维度
大数据·人工智能·ai·架构·财务·智能体
飞Link10 小时前
具身智能核心架构之 Python 行为树 (py_trees) 深度剖析与实战
开发语言·人工智能·python·架构
九河云10 小时前
云上安全运营中心(SOC)建设:从被动防御到主动狩猎
大数据·人工智能·安全·架构·数字化转型
我真会写代码11 小时前
深入理解JVM GC:触发机制、OOM关联及核心垃圾回收算法
java·jvm·架构
码路高手11 小时前
Trae-Agent中的Function Calling逻辑分析
人工智能·架构