刚接触Node.js的时候,我被 require 和 import 搞懵过很久。同一个项目里有人写const xx = require('xx'),有人写import xx from 'xx',混着用也能跑,但一报错就没有头绪。后来把 CommonJS 和 ESM 这套模块机制从头捋了一遍,很多所谓“玄学”问题才算真正通了。这篇东西就把我梳理过的 Node.js 模块化脉络整理出来——从底层加载机制到互操作边界,再到工程项目里的拆包设计,适合那些已经会 npm install、但还没真正搞懂模块系统为什么这么设计的开发者。
1. 从require到import:两套模块系统如何在一个运行时里共存
要理解 Node.js 的模块化,第一件事就是把“为什么会有两套模块系统”这个问题想清楚。这关系到你每次新建项目时面对的第一个选择:package.json 里的"type": "module"到底要不要写,.js 文件应该用 require 还是 import。
1.1 出身决定命运:CommonJS 与服务端同步加载
Node.js 诞生的时候,JavaScript 还没有官方的模块标准。浏览器端的<script>标签加载脚本,全局变量互相污染,没有作用域隔离;服务端要组织一个稍微大点的项目,很快就寸步难行。于是 Node.js 选择了当时社区里的 CommonJS 规范:每个文件是一个模块,模块内部独立作用域,通过require同步加载其他文件,用module.exports导出内容。
这是 Node.js 在 2009 年左右做出的决策,背景是服务端代码本来就来自本地磁盘,同步读文件在启动阶段不是什么大问题。于是require就带着同步、阻塞、缓存这几个特点被固定下来。哪怕后来磁盘换成 SSD,网络请求成了一等公民,require 的底层行为也没变过。直到今天,你在.cjs文件里看到的require依然是同样的加载逻辑:同步解析路径、同步读取文件、同步执行。
1.2 浏览器倒逼出来的 ESM:异步与静态分析
到了 2015 年,ECMAScript 官方终于发布了import / export模块语法,也就是 ES Module。它的设计目标主要是为了浏览器:网络环境不能同步加载,必须异步;模块之间不能有副作用顺序依赖,要有静态结构方便打包器做 tree shaking。所以 ESM 从骨子里就和 CommonJS 不同:加载是异步的,依赖关系是静态的,模块顶层可以await。
Node.js 从 8.5 开始实验性支持 ESM,到 12.17 之后逐渐稳定,再到 Node 18、20、22 里逐步把 ESM 变成了一等公民。但 CommonJS 生态太大了,几十万个 npm 包还在用 require 写,两套机制必须共存。于是你就看到了今天 Node.js 的处理方式:.mjs强制 ESM,.cjs强制 CommonJS,.js取决于最近的 package.json 里type字段;没有type字段时默认 CommonJS。
很多新手在这里就开始晕了。我建议你记住一个最直接的判断:入口文件用什么语法,取决于 Node.js 如何解释这个文件,而不是文件里写了什么关键字。当你看到“require is not defined in ES module”这类报错时,先检查启动命令、文件后缀和 package.json 的 type 字段,十有八九是解释方式错了。
2. require('./foo')背后:Node.js 模块加载器的完整工作链
如果你写过几年的 Node.js,对 require 的用法肯定不陌生。但“能用”和“懂它为什么这么工作”是两回事。这一章我把 require 的完整过程拆开,从Module._load到module.exports赋值,一条线讲到底。
2.1 模块查找:从路径解析到 node_modules 逐层上溯
当你在代码里写require('lodash')或require('./config'),Node.js 的Module._resolveFilename会按一套固定顺序处理:
- 如果第一个字符是
./或../,视为相对路径,从当前文件所在目录开始解析。 - 如果直接是一个包名,比如
lodash,Node.js 从当前目录的 node_modules 开始找,逐层向上,到/node_modules为止。这就是著名的“逐级向上查找”。 - 如果找不到 node_modules 里的包,检查是否为核心模块(如 fs、path、http 等),这些内置模块在编译期就注册好了,优先级最高。
- 都可以通过
NODE_PATH环境变量添加额外的查找路径,不过现在很少用了,如果你在代码里看到有人依赖它,建议尽早改掉。
找到目录之后还有扩展名解析:Node.js 会依次尝试.js、.json、.node;如果路径指向一个文件夹,则查找文件夹下的 package.json 的main字段,没有 main 再找index.js。ESM 里 import 的解析规则更严格,必须写全扩展名,或者依赖 package.json 的exports字段做映射,这是后面第五章的事。
2.2 module 对象的诞生与 wrapper 函数
解析到具体文件后,Node.js 会创建新的Module实例,把filename作为缓存的 key。真正执行代码前,文件内容会被包在一个 wrapper 函数里:
(function (exports, require, module, __filename, __dirname) { // 你的代码在这里 });这就是为什么 CommonJS 文件里不需要声明require、module、exports就能直接用。也不是什么黑魔法,就是 Node.js 在执行前给代码套了一层壳,把这五个变量注入进去。所有你写的顶层代码其实都发生在函数内部,所以“模块内部的 this 在最外层等于 module.exports”这个现象,本质上是 wrapper 函数调用方式的副作用。
模块之间是隔离的,每个文件都有自己独立的__dirname和__filename,这部分被包装后天然形成作用域,不存在全局污染的问题——除非你主动挂到 global 对象上,这种操作在工程上基本属于“慎用中的慎用”。
2.3 缓存机制:第二次 require 为什么不是重新执行
CommonJS 的Module._cache是一个全局对象,key 是绝对路径。第一次 require 完成后,模块的执行结果(包括赋值完的 module.exports)会被缓存。后续再 require 同一个文件,直接返回缓存里的 exports,不会再重新执行模块代码。
这个机制有两个非常实用的推论:
- 单例模式天然成立。你多次 require 同一个模块,拿到的其实是同一个对象,改它的属性,所有地方都会感知到。
- 你可以在模块执行完之前把缓存“短路”。实际写代码时,有些人利用这个特性做 mock,比如
require.cache[filename] = { exports: fake }替换某个依赖。这不是常规做法,但排查问题时你能看到很多库是这么做的。
注意:缓存 key 是绝对路径。如果你通过符号链接、不同的相对路径引用同一个真实文件,缓存依然只有一个,因为最终都会解析到同一个绝对路径。但如果你复制了整个项目到另一个目录,那就相当于两套缓存,互不干扰。
3. exports与module.exports:导出这件小事究竟有什么门道
CJS 模块里导出内容有两种写法:exports.foo = 1和module.exports = { foo: 1 }。很多文章会告诉你“exports 是 module.exports 的引用”,但这句正确的废话不结合实际报错场景,几乎等于没说。这一章我把所有导出形态的坑填平。
3.1 赋值陷阱:exports = module.exports = {} 的真相
模块初始化时,Node.js 创建module.exports为对象,同时让exports指向同一个对象。所以:
exports.name = 'Tom'; // 等价于 module.exports.name = 'Tom' module.exports.age = 18; // 等价于 exports.age = 18但如果你直接exports = { name: 'Tom' },事情就不一样了:exports 指向了新的对象,和 module.exports 的引用关系断开了。最终 Node.js 返回的是 module.exports,你这一行赋值什么都没导出去。
// bad.js exports = { name: 'Tom' }; // require('./bad') 得到 {}所以常见的修正习惯是这么写的:
// good.js module.exports = { name: 'Tom' };需要同时使用导出和引用时,最保险的做法是统一只在 module.exports 上操作。exports这个变量更多的是早期代码风格遗留,现在写新模块我基本不用它。
3.2 导出函数、类、实例:三种常见形态的注意点
实际写业务代码时,我们最常导出三类东西:普通函数、类、已创建的实例。对应写法如下:
// 导出函数 module.exports = function (a, b) { return a + b; }; // 导出类 class User { constructor(name) { this.name = name; } } module.exports = User; // 导出实例(单例) const config = { env: 'prod' }; module.exports = config;这里有个容易忽略的点:当你导出的是普通对象或函数时,是引用传递。也就是说,其他模块拿到这个对象后改了属性,原始模块里看到的值也会变。这在配置类模块里可能正是你要的效果,但在一些本该是纯函数的模块里会成为隐性问题。
提示:纯逻辑模块建议导出函数而不是对象。比如
module.exports = { add, subtract }和module.exports.add = add的区别不大,但你在另一个模块里const { add } = require('./math')解构时,如果导出对象被改动了,可能导致解构失败。函数的导出形态更稳定,测试也好写。
严格模式下还有一个细节:module.exports不能直接和exports混用。比如先exports.a = 1,再module.exports = { b: 2 },最终导出的只有 b。你之前的 a 白写了。这种问题在代码 review 时常看到,建议新模块从一开始就决定好导出风格,别在文件里一会儿赋值属性一会儿整体替换。
4. 循环依赖不一定会崩,但你要知道它什么时候会崩
循环依赖是面试常客,实际项目中也确实会出现。A 模块 require B、B 模块 require A,如果没处理对,轻则拿到一个空对象,重则直接 TypeError。理解 Node.js 对循环依赖的处理方式,你才能预判哪些场景安全、哪些场景危险。
4.1 一次循环依赖的完整执行轨迹
假设你有 a.js 和 b.js:
// a.js const b = require('./b'); console.log('a.js done, b =', b); module.exports = 'A';// b.js const a = require('./a'); console.log('b.js done, a =', a); module.exports = 'B';执行node a.js,你看到的结果是:
b.js done, a = {} a.js done, b = B为什么顺序是这样?关键在于 Node.js 的缓存机制:模块在开始执行前,就会把自己加入Module._cache,此时 module.exports 还是空对象{}。当 b.js 回过来 require('./a') 时,缓存命中,但 a.js 还没执行完毕,返回的只是那个尚未被赋值的不完整 exports。
所以 b.js 里拿到的 a 是{}。等 a.js 继续执行完成后,它的 module.exports 才被赋值为 'A'。换句话说,循环依赖遇到“在模块顶层直接取值”的情形,拿到的必然是残缺对象。
4.2 安全循环与危险循环的分界线
再看一个不那么容易崩的例子:
// api.js const utils = require('./utils'); function call() { return utils.format('hello'); } module.exports = { call };// utils.js const api = require('./api'); function format(msg) { return api.prefix + msg; } module.exports = { format };如果入口是 api.js,utils.js 里const api = require('./api')时,api.js 连module.exports都还没赋值(因为第一行就 require utils),所以 utils.js 里的 api 同样是空对象。但好在 utils.js 没有在模块顶层立即用 api,而是把它留到了format函数里,等到真正调用时,api.js 已经执行完成,api.prefix就能正常访问。
这就是安全循环的核心:别在模块顶层读取循环依赖的导出内容,把实际取值推迟到函数调用阶段。反过来说,如果你在顶层解构依赖:
const { helper } = require('./someModule');而这个模块和你互相引用,且在顶层就使用,那结果大概率是undefined甚至抛错。
破循环的根本办法其实不复杂:
- 拆公共模块。把 api.js 和 utils.js 都依赖的部分抽出来,单独放一个模块,从源头消灭循环。
- 延迟加载。在函数体内 require,而不是顶层 require。
- 依赖注入。通过构造函数、方法参数传入依赖,比模块级引用灵活得多,也更好测试。
我自己的经验是,循环依赖一旦出现,千万不要用“先这么写,等报错再说”的心态拖着。刚开始可能不崩,等模块越来越大,只要有人把顶层取值早于赋值时机的断言触发,这个雷就炸了。尽早把共享逻辑抽出去,一劳永逸。
5. ESM与CJS互操作的边界:import的坑都有哪些
现代 Node.js 项目里,CJS 和 ESM 混用几乎不可避免。你刚把新项目改成 ESM,但 npm 里一堆老包还是 CJS。这一章把两条方向的互操作边界讲清楚。
5.1 从 ESM 导入 CJS:哪些写法会翻车
在.mjs文件里 import 一个 CommonJS 模块,大部分情况下是正常工作的。Node.js 对 CJS 做了兼容处理,导入的 default 值就是 CJS 的 module.exports。比如:
// user.cjs module.exports = { name: 'Tom', getAge() { return 18; } };// index.mjs import user from './user.cjs'; console.log(user.name); // Tom但如果你想像 ESM 一样按命名导入,Node.js 用的是cjs-module-lexer做静态分析。它能识别出module.exports = { name, getAge }这种“对象字面量直接赋值”的形式,然后给你导出name和getAge这两个命名导出。所以import { name, getAge } from './user.cjs'也能跑。
翻车的情况出现在动态赋值或整体覆盖时:
// dynamic.cjs const result = {}; result.name = 'Tom'; module.exports = result;// index.mjs import { name } from './dynamic.cjs'; // SyntaxError因为 lexer 无法静态分析出 result 有哪些属性。这时只能用 default 导入,再从对象上取值。这是一个很实用的排查思路:在 ESM 里import一个 CJS 报“module does not provide an export named xxx”时,先去看目标模块是不是动态生成的 exports。
5.2 从 CJS 导入 ESM:require(esm) 的同步限制
反过来,在.cjs文件里require一个.mjs文件,很长时间里都是不行的。直到 Node.js 20.17 和 22.12 开始,require(esm)才默认启用,不再需要--experimental-require-module。但有一个很硬的限制:被加载的 ESM 模块图里不能有顶层await。
// async-util.mjs const data = await fetchData(); export default data;// main.cjs const util = require('./async-util.mjs'); // TypeError [ERR_REQUIRE_ASYNC_MODULE]这是因为 require 是同步的,而顶层 await 意味着模块加载必然异步。报错信息会明确告诉你:这个模块必须用import()异步加载。所以从 CJS 里拉取 ESM 模块,最稳妥的方式就是动态 import:
// main.cjs const util = await import('./async-util.mjs');动态import()是异步的,与 ESM 的加载方式完全匹配,在 CJS 里同样可用。这也是老项目往 ESM 迁移时的过渡方案:入口保持 CJS,新模块用 ESM,跨模块引用时统一走await import(),代码能跑,迁移风险也小。
5.3 ESM 里没有 __dirname:一个让你原地宕机的差异
刚切换到 ESM 的人,第一件头疼的事就是__dirname is not defined。CommonJS 的 wrapper 函数注入了__dirname,ESM 模块没有。解决办法是借助import.meta.url:
import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url));如果你需要在 ESM 里使用 require,也可以手动创建一个:
import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const oldPackage = require('./old-package.cjs');这两个技巧基本是每个 ESM 项目的标配。别想着用什么 hack 去全局注入__dirname,老老实实用官方 API 就行。
6. 工程视角的模块化:从语法到拆包设计
模块化的语法只是表面,真正决定一个项目能不能长期维护下去的,是依赖关系的组织方式。这一章不讲语法,讲我这些年做项目总结出来的拆包思路。
6.1 项目内模块目录:按业务域拆而不是按技术层拆
很多新手项目喜欢建三个大目录:controllers、services、models。刚开始还好,项目一膨胀每个目录下都有几十个文件,而且经常跨层依赖:controller 直接 require 另一个 controller,service 里也混着业务逻辑和数据库操作,剪不断理还乱。
我更推荐按业务域(feature)组织。以一个小型服务为例:
src/ features/ user/ user.routes.js user.service.js user.model.js user.validator.js order/ order.routes.js order.service.js order.model.js order.validator.js shared/ lib/ utils/ app.js每个业务域内部自成一体,跨域的共享逻辑下沉到 shared。这样做的核心收益是:当你需要改用户相关功能时,只需打开features/user这个目录,依赖范围一目了然,不会因为全局按技术分层导致“改 user 的 controller 却牵动 order 的 service”。
这里有个设计原则我一直在用:依赖走向保持单向。上层业务模块可以依赖底层共享模块,但共享模块绝不能反向依赖业务模块。一旦在 shared 里出现require('../features/user'),就说明某些逻辑放错了位置,该下沉的没下沉,该上提的没上提。
6.2 package.json 的 exports 字段:给模块包装一道门
如果你写过 npm 包或者负责公司内部公共库,package.json 的exports字段是必学的。它可以控制包的哪些子路径能被外部引用,相当于给包的入口装了一道门。
{ "name": "my-lib", "type": "module", "main": "./src/index.js", "exports": { ".": { "import": "./src/index.js", "require": "./src/index.cjs", "default": "./src/index.js" }, "./utils": { "import": "./src/utils.js", "require": "./src/utils.cjs" } } }配置exports后,外部只能引用你显式暴露的路径。比如想require('my-lib/src/internal'),如果没有在 exports 中声明,Node.js 会直接报错。这有效防止了用户依赖你未公开的内部实现。对于同时支持 CJS 和 ESM 的包,双入口是标准做法:import条件指向 ESM 版本,require条件指向 CJS 版本,打包器也能正确识别。
写exports时我有两个经验:一是default条件永远放在最后兜底;二是开发时一定要跑一下外部消费方的测试,因为 exports 配置错误不像 main 字段那样容易兜底,一旦写错,外部消费者会非常困惑。
6.3 什么时候该从单文件拆成独立包
很多人一上来就把所有工具函数放在一个大utils.js里,等文件超过一千行再拆。我的判断标准是看“依赖密度”和“变更频率”:
- 当多个业务域都依赖同一块逻辑,且这部分逻辑的变更会影响所有调用方时,就该从 utils.js 里抽出来,放到 shared 或者独立 npm 包。
- 当你发现一个模块的改动经常导致另一个业务模块的回归时,说明它们之间的边界画错了,需要重新审视依赖关系。
- 当共享代码需要加版本号管理时,就把它提成独立包,通过 semver 控制升级节奏,而不是让所有调用方被动升级。
拆包本身不是目的,让依赖关系清晰可查才是。我见过一些团队把每段公共代码都拆成一个 npm 包,最后依赖树变得极其恐怖,改一个函数要发十几个包。模块化的最优粒度应该以“团队能记住依赖关系”为准,别为了组件化而组件化。
拿我自己的项目举例,多年前我把服务端按 controller/service/model 三层堆了半年,后来重构改成按业务域拆分,改动反而小得多,因为每次需求变更影响面都被局限在单个 feature 内部。模块化这件事,语法只是工具,真正值钱的是对依赖关系的控制能力。当你有一天写新模块时第一反应不是“这个文件放哪个目录”,而是“这个模块应该依赖谁、谁应该依赖它”的时候,Node.js 的模块化就算真正入门了。