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

相关推荐
笔画人生1 小时前
系统级整合:`ops-transformer` 在 CANN 全栈架构中的角色与实践
深度学习·架构·transformer
程序猿追1 小时前
深度解码计算语言接口 (ACL):CANN 架构下的算力之门
架构
程序猿追1 小时前
深度解码AI之魂:CANN Compiler 核心架构与技术演进
人工智能·架构
艾莉丝努力练剑2 小时前
跨节点通信优化:使用hixl降低网络延迟的实战
架构·cann
程序猿追2 小时前
深度解读 CANN HCCL:揭秘昇腾高性能集体通信的同步机制
神经网络·架构
程序员泠零澪回家种桔子3 小时前
Spring AI框架全方位详解
java·人工智能·后端·spring·ai·架构
GIOTTO情3 小时前
舆情监测系统选型与技术落地:Infoseek 字节探索全栈架构解析与实战
架构
island13144 小时前
CANN ops-nn 算子库深度解析:神经网络计算引擎的底层架构、硬件映射与融合优化机制
人工智能·神经网络·架构
C澒4 小时前
前端整洁架构(Clean Architecture)实战解析:从理论到 Todo 项目落地
前端·架构·系统架构·前端框架
roman_日积跬步-终至千里4 小时前
【架构实战-Spring】动态数据源切换方案
架构