如何在 Monorepo 中在前端和后端之间共享 TypeScript 代码

发布: (2025年12月25日 GMT+8 23:02)
6 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的完整文本内容,我将为您翻译成简体中文并保留原始的格式和代码块。

背景

在我的 monorepo 项目 pawHaven 中,前端和后端并未完全隔离。它们自然共享了一部分代码,包括:

  • 常量
  • 配置模式
  • 枚举和字典
  • 纯工具函数

将这些公共部分提取到一个共享包中,并在前端和后端之间使用,这种做法显得很自然。使用 TypeScript、pnpm 工作区以及已经搭建好的 monorepo,一切都显得很契合。

当问题出现时

真实的问题出现在分别构建前端和后端后运行应用时:

  • Node 报告 Unexpected token 'export'
  • 前端构建成功但运行时失败
  • 有些模块报告缺失
  • CommonJS 无法处理 ESM 语法

虽然错误看似随机,但根本原因很明确:前端和后端期望完全不同的模块系统

我的最初错误假设

我最初以为可以生成一个兼容 CommonJS 和 ESM 的单一构建产出。我花了几天时间尝试:

  • module: ESNext
  • module: 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 字段确保精确解析:

使用方式字段输出
importexports.importESM 构建
requireexports.requireCommonJS 构建
TypeScriptexports.types声明文件

这确保前端和后端都能获得正确的实现,而无需运行时检查或环境变量。

理解 mainmoduletypes

  • main:在 CommonJS 模式下被 Node.js 使用;在调用 require() 时加载。
  • module:供打包工具使用,以标识 ESM 入口点并启用 tree‑shaking;Node.js 运行时会忽略它。
  • types:在 TypeScript 编译阶段使用,为前端和后端提供类型声明;与运行时无关。

为什么这种方法是稳定的

模块选择发生在 module resolution time,而不是运行时。

好处

  • 应用代码中没有条件逻辑。
  • 没有依赖环境的 hack。
  • 在本地和 CI 环境中构建完全确定。

最后思考

这三天的反复试验让我领悟到一个关键教训:在 monorepo 中共享包的难点并不是代码复用,而是要划定清晰的模块边界。

一个健壮的共享包应当提供:

  1. 单一的真相来源。
  2. 多个明确的构建产出。
  3. 通过 exports 严格控制的使用方式。

试图把所有环境都装进同一个产物会导致冲突。双构建策略是大规模 monorepo 中共享模块最可靠、最易维护的模式之一。

如果想看到实际案例,请查看用于流浪动物救助的真实 monorepo 项目: pawhaven‑shared

Diagram of package exports

Back to Blog

相关文章

阅读更多 »

在 Nest.js 中设置 MonoRepo

Monorepos 与 Nest.js Monorepos 正在成为管理多个服务或共享库的后端团队的默认选择。Nest.js 的表现非常出色……