3. 模块化与包管理

Node.js 后端工程的可维护性,很大程度上取决于模块系统与依赖管理是否清晰。模块系统决定代码如何被拆分、加载和复用;包管理决定第三方能力如何进入项目、如何锁定版本、如何在团队与 CI 中保持一致。本节围绕 CommonJS 与 ESM 的差异、npm/yarn/pnpm 的取舍、私有仓库接入,以及依赖治理与版本锁定展开。

CommonJS 与 ESM:两套模块语义

CommonJS(CJS)是 Node.js 早期默认模块系统,使用 require() 同步加载,通过 module.exports 或 exports 导出。它的加载发生在运行时,因此可以条件加载、动态拼接路径,也允许在模块顶层直接执行副作用代码。ESM(ECMAScript Modules)使用 import / export,加载关系在解析阶段静态确定,支持顶层 await、实时绑定和更好的 tree-shaking 基础,但导入路径必须是静态字符串,不能随意拼接。

在同一个项目中混用两套系统时,最容易踩的坑是“默认导出与命名导出互操作”。CJS 模块被 ESM 导入时,Node.js 会把 module.exports 整体作为 default 导出;而 ESM 被 CJS 导入时,通常只能通过动态 import() 完成,因为 CJS 的 require 无法同步解析 ESM 图。工程上建议:新项目统一使用 ESM,在 package.json 中声明 "type": "module";存量 CJS 项目若逐步迁移,可先用 .cjs / .mjs 扩展名区分边界,再逐模块替换。

// package.json
{
  "name": "order-service",
  "type": "module",
  "exports": {
    ".": "./src/index.js",
    "./internal/*": "./src/internal/*.js"
  }
}

// ESM 导入
import { createOrder } from './order.js';
import config from './config.json' with { type: 'json' };

// CJS 中动态加载 ESM
const mod = await import('./esm-only.js');

需要特别说明的是,exports 字段不仅控制对外暴露的入口,也影响子路径导入是否被允许。未在 exports 中声明的路径,外部包无法通过包名深层导入,这能有效减少“依赖内部实现”的耦合。

npm、yarn、pnpm:包管理器的选择

三者都遵循 package.json 与语义化版本(semver)约定,但安装策略和磁盘布局不同。npm 是 Node.js 自带方案,兼容性最好;yarn 早期以锁文件与并行安装提升体验,yarn berry 还引入 PnP 模式;pnpm 通过内容寻址存储与硬链接,把同一版本包在磁盘上只保留一份,并用严格的 node_modules 结构阻止“幽灵依赖”——即未在 package.json 声明却能被子包访问到的包。

选择建议:团队已有稳定 npm 流程时不必强行更换;对安装体积、依赖隔离和 monorepo 有较高要求时,pnpm 通常更合适。无论选哪个,都应在项目根目录提交锁文件,并在 CI 中使用 npm ci、yarn install --immutable 或 pnpm install --frozen-lockfile,保证构建可复现。

模块系统与依赖治理全景 模块系统:CJS 与 ESM CommonJS require() 同步加载 module.exports 导出 运行时解析 可条件加载 .cjs / 默认 ESM import / export 静态解析 顶层 await 实时绑定 .mjs / type:module 互操作要点 ESM 导入 CJS:整体作为 default CJS 导入 ESM:需动态 import() 迁移策略 新项目统一 ESM 存量项目用 .cjs/.mjs 划边界 包管理与依赖治理 npm Node 自带 兼容性最好 npm ci 可复现 yarn 锁文件稳定 PnP 可选 --immutable pnpm 内容寻址存储 硬链接省磁盘 严格依赖隔离 私有仓库接入 .npmrc 配置 registry 与 token scope 隔离:@company:registry=... 依赖治理与版本锁定 • 锁文件提交:package-lock / yarn.lock / pnpm-lock • 精确版本:dependencies 用 1.2.3 而非 ^1.2.3 • 定期审计:npm audit / pnpm audit • 依赖收敛:overrides / resolutions 统一版本 • 幽灵依赖:pnpm 严格结构天然规避

私有仓库:把内部包纳入统一治理

企业后端通常会有内部 SDK、公共中间件、协议定义等包,这些包不适合发布到公共 npm。私有仓库(如 Verdaccio、Nexus、Artifactory)可以托管内部包,并通过 scope 与公共源隔离。典型做法是在项目根目录放置 .npmrc:

# .npmrc
registry=https://registry.npmjs.org/
@company:registry=https://npm.company.com/repository/npm-private/
//npm.company.com/repository/npm-private/:_authToken=${NPM_TOKEN}
always-auth=true

其中 @company 是内部包的作用域,例如 @company/logger。CI 环境中通过环境变量注入 NPM_TOKEN,避免把令牌写入仓库。发布内部包时使用 npm publish --access restricted,并确保 package.json 中的 name 带 scope。

依赖治理与版本锁定

依赖治理的目标是让“今天能装上的版本”和“三个月后 CI 能装上的版本”一致。核心手段有三类:

  • 锁文件:package-lock.json、yarn.lock、pnpm-lock.yaml 必须提交到版本库。CI 使用冻结安装命令,禁止在流水线中自动升级依赖。
  • 版本范围收敛:生产依赖尽量使用精确版本或 ~ 补丁范围,避免 ^ 在主版本内引入行为变化。对确实需要统一的多版本依赖,使用 npm overrides 或 yarn resolutions 强制收敛。
  • 审计与升级节奏:定期执行 npm audit --production,对高危漏洞优先处理;升级依赖时先在小范围分支验证,再合并锁文件变更。
# CI 中的可复现安装
npm ci
# 或
pnpm install --frozen-lockfile

# 统一 lodash 版本(npm overrides)
{
  "overrides": {
    "lodash": "4.17.21"
  }
}
判断依赖是否“健康”,可以看三点:锁文件是否与 package.json 同步、CI 是否使用冻结安装、是否存在未声明却被直接引用的幽灵依赖。pnpm 的严格 node_modules 结构会让幽灵依赖在本地就暴露出来,越早发现越好。
不要在同一项目里同时保留多个锁文件。若从 npm 迁移到 pnpm,应先删除 node_modules 与旧锁文件,重新安装并提交新的锁文件,避免不同包管理器对依赖树的解析差异导致“本地能跑、CI 失败”。

综合来看,模块系统解决“代码怎么组织”,包管理解决“依赖怎么进入和锁定”,私有仓库解决“内部能力怎么分发”,依赖治理解决“长期怎么保持稳定”。四者共同构成 Node.js 后端工程的依赖底座。实际落地时,建议先统一模块规范与包管理器,再接入私有源,最后把锁文件、审计和版本收敛纳入 CI 检查,使依赖变更像代码变更一样可审查、可回滚。