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,保证构建可复现。
私有仓库:把内部包纳入统一治理
企业后端通常会有内部 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 使用冻结安装命令,禁止在流水线中自动升级依赖。 - 版本范围收敛:生产依赖尽量使用精确版本或
~补丁范围,避免^在主版本内引入行为变化。对确实需要统一的多版本依赖,使用 npmoverrides或 yarnresolutions强制收敛。 - 审计与升级节奏:定期执行
npm audit --production,对高危漏洞优先处理;升级依赖时先在小范围分支验证,再合并锁文件变更。
# CI 中的可复现安装
npm ci
# 或
pnpm install --frozen-lockfile
# 统一 lodash 版本(npm overrides)
{
"overrides": {
"lodash": "4.17.21"
}
}
package.json 同步、CI 是否使用冻结安装、是否存在未声明却被直接引用的幽灵依赖。pnpm 的严格 node_modules 结构会让幽灵依赖在本地就暴露出来,越早发现越好。node_modules 与旧锁文件,重新安装并提交新的锁文件,避免不同包管理器对依赖树的解析差异导致“本地能跑、CI 失败”。综合来看,模块系统解决“代码怎么组织”,包管理解决“依赖怎么进入和锁定”,私有仓库解决“内部能力怎么分发”,依赖治理解决“长期怎么保持稳定”。四者共同构成 Node.js 后端工程的依赖底座。实际落地时,建议先统一模块规范与包管理器,再接入私有源,最后把锁文件、审计和版本收敛纳入 CI 检查,使依赖变更像代码变更一样可审查、可回滚。