4. 模块化与依赖管理

TypeScript 后端工程一旦超过单文件规模,模块系统与依赖管理就会从“语法细节”升级为“工程约束”。本节围绕四个相互牵连的问题展开:ESM 与 CommonJS 如何在同一工程中互操作、路径别名如何让深层目录引用保持可读、monorepo 工作区如何组织多包依赖、以及如何通过锁文件与版本策略保证构建可复现。

一、ESM 与 CommonJS 的互操作边界

Node.js 生态长期以 CommonJS(CJS)为主,而现代 TypeScript 项目普遍倾向 ESM。两者并非简单替换关系,而是存在明确的加载语义差异。CJS 使用 require() 同步加载,导出是运行时对象;ESM 使用 import 静态声明,导出是绑定,且顶层 await 只在 ESM 中可用。互操作的核心判断依据是:谁在加载谁。

当 ESM 文件导入 CJS 包时,Node 会把 module.exports 作为默认导出暴露,命名导出则依赖静态分析,常常不可靠。因此推荐写法是默认导入后再解构:

// ESM 中导入 CJS 包
import pkg from 'legacy-cjs-lib';
const { parse, format } = pkg;

反过来,CJS 无法用 require() 直接加载纯 ESM 包,必须使用动态 import(),它返回 Promise:

// CJS 中加载 ESM 包
async function loadEsm() {
  const mod = await import('modern-esm-lib');
  return mod.default;
}

在 TypeScript 侧,tsconfig.json 的 module 与 moduleResolution 必须与运行时一致。若输出 ESM,应设置 "module": "NodeNext"、"moduleResolution": "NodeNext",并在 package.json 中声明 "type": "module";若输出 CJS,则使用 "module": "CommonJS"。混用时,esModuleInterop 与 allowSyntheticDefaultImports 可缓解类型层面的默认导入报错,但不能改变运行时行为。

ESM 与 CommonJS 互操作加载路径 ESM 模块 import / export 顶层 await 可用 CommonJS 模块 require / module.exports 同步加载 默认导入 + 解构 import pkg from 'cjs-lib' 动态 import() 返回 Promise await import('esm-lib') tsconfig 对齐运行时 module: NodeNext / CommonJS package.json "type": "module" 或 "commonjs"

二、路径别名:从相对路径地狱到稳定引用

后端项目常见 src/modules/user/services/user.service.ts 这类深层结构,跨模块引用时会出现 ../../../shared/utils/logger 这样的相对路径。路径别名通过 tsconfig.json 的 paths 字段把逻辑路径映射到物理目录:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@shared/*": ["src/shared/*"],
      "@modules/*": ["src/modules/*"]
    }
  }
}

关键点在于:TypeScript 的 paths 只影响类型检查与编译期解析,不改变运行时模块解析。Node 运行时并不认识 @/。因此必须配合运行时解析方案,常见有三种:

  • tsc-alias:编译后把 JS 中的别名重写为相对路径,适合纯 tsc 构建。
  • tsconfig-paths:在启动时注册解析钩子,适合开发环境 ts-node。
  • 打包器 alias:如 esbuild、swc、tsup 的 alias 配置,适合产物打包场景。

若使用 ESM 输出,还需注意 Node 的 ESM 解析要求显式扩展名,别名重写后应生成 .js 后缀,否则运行时会报 ERR_MODULE_NOT_FOUND。

三、monorepo 工作区:包边界与依赖方向

当后端拆分为 api、core、shared、cli 等多个包时,monorepo 工作区能统一版本、统一构建、统一发布。以 pnpm workspace 为例,根目录 pnpm-workspace.yaml 声明:

packages:
  - 'packages/*'
  - 'apps/*'

各子包通过 workspace:* 协议互相引用,例如 apps/api/package.json 中写 "@my/core": "workspace:*"。这样本地开发直接链接源码,发布时由 pnpm 替换为真实版本号。工作区的核心纪律是依赖方向单向:shared 不依赖任何业务包,core 可依赖 shared,api 依赖 core 与 shared,禁止反向依赖,否则构建顺序与类型解析都会陷入循环。

构建编排上,可用 pnpm -r --filter 按拓扑顺序执行,或引入 Turborepo 声明 dependsOn: ["^build"],让上游包先构建。判断工作区是否健康,可以观察三点:安装后 node_modules/@my 下是否为符号链接、tsc --build 是否按引用顺序通过、以及是否存在跨包深层路径导入(如 @my/core/src/internal),后者应通过包导出字段 exports 收敛。

四、依赖版本锁定策略

锁文件是构建可复现的底线。npm 使用 package-lock.json,pnpm 使用 pnpm-lock.yaml,yarn 使用 yarn.lock。锁文件必须提交到版本库,CI 中使用冻结安装:pnpm install --frozen-lockfile 或 npm ci,一旦锁文件与 package.json 不一致就直接失败,而不是静默升级。

版本范围策略建议分层:应用层(apps/*)使用精确版本或 ~ 补丁范围,减少意外破坏;库层(packages/*)可放宽到 ^,但需配合锁文件与定期升级窗口。对于关键依赖,可在 package.json 的 pnpm.overrides 或 npm overrides 中强制统一版本,解决多包传递依赖冲突。判断锁定是否生效,可对比 pnpm why 的输出与锁文件中解析到的具体版本,若同一包出现多个不兼容主版本,应通过 overrides 收敛或明确隔离。

实践建议:在 CI 中增加一步 pnpm install --frozen-lockfile && pnpm -r build,把“锁文件一致性”和“工作区拓扑构建”同时作为门禁,能在合并前拦截绝大多数依赖漂移问题。

把四件事串起来看:ESM/CJS 决定模块如何被加载,路径别名决定源码如何被引用,monorepo 决定包如何被组织,锁文件决定依赖如何被复现。它们共同构成 TypeScript 后端工程化的模块与依赖底座,任何一环松动,都会在构建、启动或发布阶段以难以定位的错误暴露出来。