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

相关推荐
苏近之2 小时前
Rust 基于 Tokio 实现任务管理器
后端·架构·rust
乾元2 小时前
AI 驱动的网络攻防演练与安全态势推演——从“规则检测”到“行为级对抗”的工程体系
网络·人工智能·安全·web安全·架构·自动化·运维开发
踏浪无痕2 小时前
像挑选书籍一样挑选技术:略读、精读,还是直接跳过?
后端·程序员·架构
lbb 小魔仙3 小时前
FP8赋能高效生成:Stable Diffusion 3.5架构解析与落地优化指南
stable diffusion·架构
倔强的石头1063 小时前
金仓数据库 MongoDB 兼容:多模融合下的架构之道与实战体验
数据库·mongodb·架构·kingbase
光锥智能3 小时前
昇思MindSpore打造HyperParallel架构,引领AI框架迈入“超节点时代”
人工智能·架构
齐鲁大虾4 小时前
Linux 系统上的开发 C/S 架构的打印程序
linux·c语言·架构
小明的小名叫小明4 小时前
5.Uniswap 技术架构详解
架构·区块链
fakerth4 小时前
【OpenHarmony】Hiview架构
架构·操作系统·openharmony