到这里,运行时这一侧的链路已经接上了:JavaScript 怎样执行、事件循环怎样调度、I/O 又怎样交给系统。但服务不可能永远只有一个文件。代码一旦拆成模块、装入第三方依赖并交给自动化环境部署,新的问题就从“任务怎么运行”变成了“代码究竟会加载哪一份、以什么方式执行”。
我们遇到的第一个工程化故障,甚至没有等到请求进来。
凌晨两点多,一次看似普通的部署把服务卡在了启动阶段。健康检查连续失败,日志里最醒目的一行是:
ReferenceError: require is not defined in ES module scope我第一反应是依赖没装全,于是检查 node_modules、重跑安装、核对环境变量。都没有用。有人把报错文件临时改成 .cjs,第一处错误消失了,紧接着又冒出 ERR_PACKAGE_PATH_NOT_EXPORTED。那一刻我才意识到,我们一直在修报错附近的代码,却没回答真正的问题:Node.js 到底把这个文件当成什么模块,又为什么会找到这个包入口?
事故的导火索只是一行配置:为了让新入口使用 import,项目根目录的 package.json 加了 "type": "module"。这行配置不仅影响入口,还改变了整个包范围内所有 .js 文件的解释方式。遗留文件仍在调用 require,某段代码还绕过包的公开入口,直接导入了内部路径。两种约定平时勉强共存,部署时被一次性揭开。
后来我把这类问题总结成三个问题。每次看到模块加载错误,先别急着重装依赖,依次问:
沿着这三问排查,require is not defined、MODULE_NOT_FOUND、ERR_PACKAGE_PATH_NOT_EXPORTED,甚至循环依赖造成的 undefined,都会从“玄学”变成一条可以验证的路径。
排查时我先做了一件很笨但很有效的事:把入口加载的文件逐个画出来,并在每个文件旁边写上“输入”和“输出”。画到第三个文件时,问题已经清楚了一半。模块不是把若干代码片段拼成一个大文件,而是建立边界,再通过明确的接口连接边界。
CommonJS 文件加载时,Node.js 会把代码放进一个类似下面的函数包装器中执行:
(function (exports, require, module, __filename, __dirname) {
// 这个文件的代码
});我们不需要手写这个包装器,但它解释了几个现象:文件顶层的 const、let 和函数默认只属于当前模块;require、module、__filename、__dirname 也不是浏览器意义上的全局变量,而是包装器传给当前 CommonJS 模块的局部值。因此,两个文件都声明 const config = ...,不会互相覆盖。
ESM 不使用这层 CommonJS 包装器,却同样拥有模块作用域。一个值只有经过 export 导出,再被另一个模块 import,才跨过边界。
我后来常用“工作室”来检查模块设计:内部变量是桌上的工具,导出接口是门口交付的成品。调用方应该依赖成品,而不是翻窗去拿内部零件。只要接口稳定,工作室里换算法、换缓存甚至重写实现,都不该迫使所有调用方一起改。

那次事故中,依赖图里混着三类目标:
node:fs、node:path、node:http,不需要安装。./math.js 或 ../config/index.js,相对说明符必须以 ./ 或 ../ 开头。pino、express,裸包名会进入包解析流程。我习惯给内置模块加上 node: 前缀,例如 require('node:fs') 或 import { readFile } from 'node:fs/promises'。它不会改变核心能力,却能让代码审查者一眼看出依赖来自运行时,而不是同名的第三方包。
模块和包不是一回事。模块是一次加载和导出的代码单元;包是由 package.json 描述的一棵目录树。一个包可以包含许多模块,却只公开其中少数入口。
为了确认遗留代码的行为,我把它缩成了两个文件:
// math.cjs
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };// app.cjs
const { add, multiply } = require('./math.cjs');
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6在同一目录运行:
node app.cjsrequire('./math.cjs') 返回的是 math.cjs 执行结束时的 module.exports。这个值可以是对象、函数、类、字符串,甚至是 null,Node.js 不要求它具有固定形状。
exports 只是初始时指向 module.exports 的快捷变量。给它增加属性有效:
exports.add = add;
// 等价于 module.exports.add = add;但给局部变量 exports 换一个对象,不会替换真正的出口:
exports = { add }; // 错误:require 得不到这个新对象如果要整体导出新值,就写 module.exports = ...。我也会要求同一个文件尽量只采用一种导出风格。混用属性追加和整体替换,最后暴露什么会变得依赖执行顺序,排查起来很费眼睛。
第一次调用 require('./math.cjs') 时,CommonJS 加载器会解析说明符、创建模块对象、执行包装后的文件、记录导出值,再把 module.exports 返回给调用方。整个过程是同步的,目标模块执行完成前,调用方不会继续下一行。
这让我重新检查了入口链路。顶层适合声明函数、读取已经准备好的配置、连接对象;如果顶层塞入大量同步计算或磁盘读取,每一个依赖它的入口都会替这段工作买单。尤其要警惕“导入一个工具模块,顺便连数据库、启动定时器、注册信号处理”这种隐藏副作用。它会让加载顺序直接左右业务行为。
CommonJS 模块第一次按文件名解析后,会进入 require.cache。之后只要解析到同一个缓存键,通常就会直接拿到原来的导出对象,而不再执行模块体。
// counter.cjs
console.log('counter.cjs 正在执行');
let count = 0;
module.exports = {
next() {
count += 1;
return count;
},
};// cache-demo.cjs
const first = require('./counter.cjs');
const second = require('./counter.cjs');
console.log(first === second); // true
console.log(first.next()); // 1
console.log(second.next()); // 2运行时,“counter.cjs 正在执行”只出现一次。first 和 second 指向同一个导出对象,内部的 count 也被两处调用共享。
但“模块只执行一次”有前提:两次加载必须解析到同一个缓存键。调用位置不同,可能找到两份不同位置或不同版本的同名包;路径大小写在不同文件系统上的表现也可能带来意外。遇到重复初始化时,先打印实际解析结果,不要先怪缓存失效。
工程代码也不该靠删除 require.cache 实现日常热更新。需要重复执行的逻辑应该导出为函数,由调用方明确控制生命周期。

继续画依赖图时,我发现两个模块互相指着对方。A 加载 B,B 尚未执行完又回来加载 A。Node.js 不会无限递归地重跑 A,而是把 A 当时已经形成的导出交给 B,于是 B 看到的可能只是半成品。
// a.cjs
exports.ready = false;
const b = require('./b.cjs');
console.log('A 看到 B:', b.ready);
exports.ready = true;// b.cjs
exports.ready = false;
const a = require('./a.cjs');
console.log('B 看到 A:', a.ready);
exports.ready = true;node a.cjs输出是:
B 看到 A: false
A 看到 B: true缓存让这个环能够结束,却没有保证双方都初始化完整。我的处理顺序通常是:先问 A 和 B 是否承担了混杂职责,再把共同依赖的规则抽到 C。把 require 移进函数只能延迟读取,结构上的环仍然存在。
模块缓存不是业务数据库。它只属于当前进程;进程重启会清空,多进程或多容器部署也各有一份。它适合保存模块级配置和连接池引用,不适合保存必须持久化或跨实例一致的数据。
第二个报错出现时,我又差点去执行 npm install。后来改用 require.resolve,才确认包明明存在,失败的是它不允许访问的内部子路径。这也是我现在排查 MODULE_NOT_FOUND 一类错误时先给说明符分类的原因:
const fs = require('node:fs'); // 内置模块
const math = require('./math.cjs'); // 相对文件
const logger = require('pino'); // 第三方包内置模块由 Node.js 直接提供。相对路径从“发起加载的模块所在目录”开始解析,不是从终端当前目录凭感觉寻找。裸包名则从当前模块附近的 node_modules 开始,找不到就逐级检查父目录的 node_modules,直到文件系统根目录。
于是,同一仓库的两个文件即使都写 require('foo'),也可能拿到不同版本。决定结果的不是字符串相同,而是各自从哪里开始向上寻找。

CommonJS 为历史兼容提供了不少回退。以 require('./math') 为例,加载器会尝试同名文件,并可能补上 .js、.json、.node 等扩展名;把目录作为目标时,还可能读取其中的 package.json 入口,或回退到 index.js、index.node。
ESM 的相对 import 按 URL 风格解析,不会照搬这些省略规则。本地文件名通常要写完整,目录也不会自动补成 index.js:
import { add } from './math.js';我最终把项目里的本地导入全部改成了完整文件名。这个小约定减少了加载器猜测,也让代码在 Node.js、浏览器和构建工具之间迁移时更容易解释。
当代码加载 require('some-package') 或 import 'some-package',Node.js 会先定位包目录,再根据 package.json 决定公开入口。现代包常用 exports,较早的包常用 main;两者同时存在时,支持 exports 的 Node.js 会优先采用 exports。
exports 不只声明主入口。它还可以公开多个子路径、针对 import 与 require 选择不同文件,并阻止调用方绕过公共接口访问内部实现。假如某个包只公开 . 和 ./format,调用方就能使用 pkg 与 pkg/format,却不能加载 pkg/src/private.js。
那次事故里的 ERR_PACKAGE_PATH_NOT_EXPORTED 正是在提醒我:文件存在,不等于它是公开 API。正确修复不是找到一个更隐蔽的内部路径,而是改用包承诺支持的入口。
CommonJS 中可以先让 Node.js 只解析、不执行目标模块:
console.log(require.resolve('./math.cjs'));
console.log(require.resolve('some-package'));
console.log(require.resolve.paths('some-package'));前两行给出最终文件名,失败时抛出 MODULE_NOT_FOUND;最后一行列出裸包名可能搜索的位置。它们比反复查看终端当前目录更接近加载器的真实路线。
先抄下错误中的原始说明符。node: 开头的是内置模块,./ 或 ../ 开头的是本地相对路径,其余通常是包名或包子路径。
本地路径用 require.resolve 核对最终文件名、大小写、扩展名和发起调用的文件;包名再检查 package.json、锁文件与实际安装位置。
包存在但某个深层路径失败时,检查 exports 是否公开该子路径。此时重复安装通常不会改变包的公开接口。
回到最初的 require is not defined。我没有继续在原项目里试错,而是建立两个最小文件:
// math.js
export function add(a, b) {
return a + b;
}
export const version = '1.0.0';// app.js
import { add, version } from './math.js';
console.log(version, add(2, 3));要让这两个 .js 文件明确按 ESM 运行,最近一层 package.json 写入:
{
"type": "module"
}然后执行 node app.js。ESM 导出的是实时绑定:如果导出模块更新某个 let,导入方之后读到的是更新值,不是导入时复制的一份快照;但导入方不能给导入绑定重新赋值。
我在事故记录里画了一张判断表,只保留三条硬规则:
.mjs 明确表示 ESM。.cjs 明确表示 CommonJS。.js 由最近父级 package.json 的 type 决定:module 表示 ESM,commonjs 表示 CommonJS。
这也解释了为什么根目录增加 "type": "module" 会产生大面积影响:包范围内的遗留 .js 不再是 CommonJS,原本由包装器提供的 module、exports、require、__filename 和 __dirname 自然也就不存在。
不要让项目依赖运行时对模糊 .js 的语法检测。显式写 type,需要局部例外时用 .cjs 或 .mjs,工具和维护者才能得出一致结论。
ESM 中需要当前模块位置时,可以使用 import.meta.url。如果运行时版本支持,也可以使用 import.meta.filename 与 import.meta.dirname;需要兼容较早版本时,则用 fileURLToPath(import.meta.url) 转换成文件系统路径。
真实项目很难一夜切换完。我把迁移拆成边界,而不是机械替换每个 require。
ESM 加载 CommonJS 包时,可以使用默认导入:
// ESM 中加载 CommonJS 包
import legacyPackage from 'legacy-package';CommonJS 的 module.exports 会作为 ESM 的默认导入。Node.js 还可能通过静态分析合成部分命名导出,但动态写法不一定能被识别;面对不熟悉的 CommonJS 包,默认导入后再读取属性更稳妥。
ESM 中若确实需要 CommonJS 的 require,可以创建一个局部版本:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const config = require('./config.cjs');CommonJS 加载 ESM 时,我更愿意使用动态 import():
// app.cjs
async function main() {
const { add } = await import('./math.mjs');
console.log(add(2, 3));
}
main().catch(console.error);一些 Node.js 版本也支持 require() 满足特定条件的同步 ESM,但目标依赖图不能包含顶层 await,跨版本表现也需要单独核对。动态 import() 把异步边界写在代码里,迁移意图更明确。
没有历史包袱的新项目,我通常会选择 ESM,并在根 package.json 明确写 "type": "module"。它使用标准 import、export,也与浏览器和现代工具的模块语法一致。
已有 CommonJS 项目不必为了统一外观一次性重写。可以保留 "type": "commonjs",在清晰的边界使用 .mjs 或动态 import(),逐步迁移启动脚本、测试入口与发布出口。迁移真正难的是生命周期、工具配置、条件导出和兼容范围,不是关键字替换。
不要在同一个 .js 文件顶层同时写 require 与 import,再期待 Node.js 自动猜意图。先用扩展名和最近的 package.json 明确模块格式,再采用对应语法。
事故恢复后,我首先补的不是更多目录,而是 package.json。它必须是合法 JSON,不能写注释或尾随逗号。一个不准备发布到 npm 的后端应用,可以从下面开始:
{
"name": "weather-service",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test"
}
}private: true 防止应用被意外发布到 npm。type 决定这个包范围内 .js 的模块格式。engines 表达预期的 Node.js 版本,但它不等于自动锁定运行时,CI 和部署环境仍要真正选择匹配版本。
scripts 则把团队命令收进项目。拿到代码的人可以直接执行 npm start、npm run dev、npm test,不用从聊天记录里拼凑参数。
应用内部可以通过相对路径组织文件;如果一个包要被其他项目安装,就该认真设计公共边界:
{
"name": "@example/text-tools",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./format": {
"import": "./dist/format.js",
"require": "./dist/format.cjs"
. 是包主入口,./format 是公开子路径。ESM 使用者进入 .js,CommonJS 使用者进入 .cjs。main 可以照顾未使用 exports 的旧工具;现代 Node.js 会优先按 exports 选择入口。
给既有包增加 exports 可能构成破坏性变更。原来直接加载 @example/text-tools/src/private.js 的代码,会收到 ERR_PACKAGE_PATH_NOT_EXPORTED。发布前要盘点已有入口,把真正承诺支持的路径列完整,而不是把内部目录无差别暴露出去。
type:包范围内的 .js 应按 ESM 还是 CommonJS 解释?main:传统的包主入口在哪里?exports:现代公共入口有哪些,哪些条件进入哪些文件?如果应用通过 node src/index.js 启动,main 往往不参与启动。控制团队启动方式的是 scripts.start;main 主要影响别人通过包名加载这个包时从哪里进入。把这三件事分开,配置就不容易靠猜。
那次排查中,重跑 npm install 之所以没解决问题,是因为模块格式错误根本不是安装问题。不过它也暴露了另一处风险:有人删掉锁文件后重新安装,得到了一棵不同的间接依赖树。
npm install pino
npm install --save-dev eslint第一条把 pino 写入 dependencies,第二条把 eslint 写入 devDependencies。npm 同时解析间接依赖,更新 package-lock.json,再把实际文件安装到 node_modules。
三者职责不同:package.json 描述项目接受的依赖范围,package-lock.json 记录解析出的精确依赖树,node_modules 是按前两者在本机生成的安装产物。通常应该提交前两个,不提交 node_modules,更不要直接改 node_modules 里的包源码,因为重新安装会覆盖它。

我判断一个包属于哪一类时,只问一个问题:生产环境只安装运行依赖后,部署产物能否启动并处理请求?
dependencies 保存运行时直接需要的包,例如 Web 框架、数据库驱动和日志库。devDependencies 保存开发、检查、测试或构建阶段使用的工具,例如 ESLint、测试运行器和类型工具。不要按“我只在写代码时看到它”来判断。TypeScript 编译器通常是开发依赖;但如果部署环境拿到源码后在启动时现场编译,它就是部署流程的一部分。先明确交付的是源码还是构建产物,再分类依赖。
包作者还可能使用 peerDependencies 表达“插件需要宿主提供兼容版本”,使用 optionalDependencies 表达“安装失败可以继续,但程序必须处理缺失分支”。普通后端应用不必为了字段齐全把依赖硬塞进这些分类。
npm 包常采用 主版本.次版本.修订版本。对已进入 1.0.0 以上的包,可以这样理解常见范围:
1.4.2 固定这个版本。~1.4.2 通常允许修订版本更新,但不到 1.5.0。^1.4.2 通常允许不改变主版本的更新,但不到 2.0.0。0.x 的兼容范围更收紧,不能机械套用上面的口径。无论如何,package.json 里的范围只是允许集合,锁文件才记下这次安装解析出的具体版本和完整依赖树。
没有锁文件时,同一份 package.json 在不同日期可能解析到不同的间接依赖版本。提交锁文件后,代码审查能看到依赖树变化,自动化环境也能复现安装。
日常添加或升级依赖时使用 npm install。CI 和部署构建更适合运行:
npm cinpm ci 要求锁文件已经存在;如果 package.json 与锁文件不匹配,它会直接失败,而不是现场改锁。它还会先清理当前 node_modules,安装过程中不写回清单或锁文件。这个“答案不同就失败”的约束,正是自动化构建需要的确定性。
提交 package.json 与 package-lock.json,在 CI 中使用 npm ci,不提交 node_modules。一次依赖升级就会留下可审查的清单和锁文件变化,构建环境也能复现同一棵依赖树。
我接手过不少只能在原作者电脑上运行的项目,原因往往不是源码,而是某个全局命令。更稳妥的做法是把工具装在项目里:
npm install --save-dev nodemon{
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "node --test",
"lint": "eslint ."
}
}执行 npm run dev 时,npm 会把项目的 node_modules/.bin 加入脚本进程的 PATH,所以脚本里直接写 nodemon 或 eslint 即可。工具版本进入清单和锁文件,另一台机器运行 npm ci 后就能得到同一套命令。
简单项目也可以直接用 Node.js 的监视模式:node --watch src/index.js。选哪个工具不重要,重要的是选择被记录在项目脚本里。
把额外参数传给脚本时,在脚本名后加 --:
npm test -- --test-name-pattern="缓存"npm 还支持同名的 pre、post 钩子。执行 npm test 时,可以依次触发 pretest、test、posttest。钩子适合固定的准备和收尾动作,但关键流程藏得太深,会让人只看 test 字段时误判完整行为。
npm 脚本最终由系统 shell 执行。类 Unix 系统与 Windows 的默认 shell 不同,rm -rf、FOO=bar command 之类的写法不能天然跨平台。团队需要覆盖多种系统时,可以把复杂流程写成 Node.js 脚本,再由 npm scripts 调用。
还有一个经常被忽略的边界:部分依赖带有安装生命周期脚本,因此安装不一定只是下载文件,也可能执行代码。选择维护状态清楚的依赖、审查锁文件变化,并限制自动化环境权限,都是依赖管理的一部分。
故障修完后,我没有立刻增加一堆目录,而是先把启动、业务和外部访问拆开。一个足够表达职责的结构可以是:
weather-service/
├── package.json
├── package-lock.json
├── src/
│ ├── index.js # 组合入口:读取配置、连接依赖、启动服务
│ ├── config.js # 配置解析与校验
│ ├── routes/
│ │ └── weather.js # HTTP 输入输出适配
│ ├── services/
│ │ └── weather.js # 业务规则
│ └── repositories/
│ └── forecast.js # 数据库或外部 API 访问
└── test/
└── weather.test.js
src/index.js 是组合根:它知道各个具体模块,并把它们连起来。路由层理解 HTTP,业务层处理规则,数据层访问外部系统。依赖箭头从入口流向下层,底层模块不要反过来加载入口。这样测试业务逻辑时,可以传入假的数据访问函数,而不必启动服务器或连接数据库。
“超过 200 行就拆文件”听起来容易执行,却没有普遍意义。我更关注变化原因:一个文件是否会同时因为路由、业务规则和数据库变化而修改?如果答案是肯定的,它承受了多个方向的变化,边界就该重新划分。
反过来,一个只有三行、只在一处使用的函数,不一定值得单独建文件。模块越多,依赖边也越多。拆分的目标是把变化限制在合理范围,而不是追求文件数量。
utils.js 是另一种预警信号。它起初像一个方便的抽屉,最后常常同时装着身份校验、日期格式化和数据库重试。parse-port.js、format-user.js、retry-request.js 这样的名字更容易暴露职责,也让错误堆栈更有信息量。
环境变量进入应用后,应尽早转换和校验:
// src/config.js
function readPort(value) {
const port = Number(value ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT 必须是 1 到 65535 之间的整数');
}
return port;
}
export
其他模块导入已经校验过的 config,不用各自猜默认值。数据库客户端、外部 API 客户端等依赖,则由组合根创建并通过函数参数传入业务模块。这样既减少隐式全局状态,也方便测试时替换实现。
工程化不是把目录做复杂,而是让模块格式、公开接口、运行命令、依赖版本和生命周期都能被追踪。维护者能从 package.json 知道怎么运行,从导出接口知道怎么调用,从锁文件知道安装了什么,项目就有了可靠的底座。
我排查模块问题时,常会做一个不依赖第三方包的最小 ESM 项目。它能把“源码问题”和“环境噪声”分开,也能验证模块边界是否真的清楚。
mkdir module-demo
cd module-demo
npm init -y
mkdir -p src/services test把 package.json 调整为:
{
"name": "module-demo",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test"
}
}运行 npm install --package-lock-only 可以生成锁文件。这个项目没有外部依赖,命令不会安装一批第三方包,但会把根项目状态记录到 package-lock.json。
业务模块只生成问候语:
// src/services/greeting.js
export function createGreeting(name) {
const normalized = name.trim();
if (!normalized) {
throw new Error('name 不能为空');
}
return `你好,${normalized}!`;
}入口负责组合并调用它:
// src/index.js
import { createGreeting } from './services/greeting.js';
const name = process.argv[2] ?? 'Node.js';
console.log(createGreeting(name));测试只依赖业务接口,不执行入口:
// test/greeting.test.js
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGreeting } from '../src/services/greeting.js';
test('去掉名字两侧空格', () => {
assert.equal(createGreeting(' 小明 '), '你好,小明!');
});
test('拒绝空名字', () => {
assert.
现在运行:
npm start -- 小明
npm test这个例子虽然小,却把关键约定都变成了可执行事实:type 明确模块格式,本地导入包含完整扩展名,入口与业务规则分开,运行和测试命令进入 scripts。以后增加 HTTP、数据库或日志库,只需要沿着现有边界扩展。
那次故障真正改变我的,不是记住了某个报错,而是形成了一套稳定顺序:
package.json#type,确认这个文件属于 CommonJS 还是 ESM。require.resolve 时,直接看最终文件和搜索路径。module.exports,ESM 检查实际导出的绑定,包子路径检查 exports。npm ci,运行命令放进 scripts。模块系统表面上是 require 与 import 的差别,真正决定项目能否长期维护的,却是边界是否明确、解析是否可预测、安装是否可复现。遇到故障时把这三层拆开验证,比重装依赖、改文件名或盲猜加载顺序可靠得多。
但模块只规定代码怎样组织和加载,不规定异步结果怎样交付。两个边界清楚的模块仍然可能因为一个回调执行两次、一次错误没有上浮,或者同步与异步时机不一致而互相污染。项目结构稳定以后,我们必须把注意力从“依赖指向哪里”转到“控制权什么时候交出去、又怎样拿回来”。