require 怎样查找、包装和缓存模块?循环引用时拿到的是什么?
require 先把参数解析成一个绝对文件名,再用这个文件名查 require.cache,命中就直接返回 module.exports;没命中就创建 module 对象、先放进缓存,再用函数包装器 (exports, require, module, __filename, __dirname) 执行模块代码。因为缓存发生在执行之前,循环引用时对方拿到的是一个还没执行完的 exports 对象。
简答
写
require('./a')时,Node.js 做三件事:先把./a解析成一个绝对文件名,再用这个文件名去require.cache里查,查到了就直接返回上次的module.exports;查不到才去读文件、执行代码。执行之前,Node.js 会把模块代码包进一个函数,参数是
exports、require、module、__filename、__dirname。所以模块顶层的const、let只属于这个模块,不会变成全局变量,那几个看起来像全局的变量其实是函数参数。缓存的好处是同一个文件只执行一次,多处
require拿到的是同一个对象。这也是循环引用不会死循环的原因:模块先进缓存再执行,A 加载 B、B 又加载 A 时,B 拿到的是 A 还没执行完的那个exports。要注意两点。第一,缓存按解析后的文件名区分,从不同目录
require('foo')可能解析到不同的node_modules,拿到的就不是同一个对象。第二,循环引用里过早读取的属性可能还是undefined,Node.js 文档说这种依赖要仔细规划。
require 怎样找到文件
require 的参数分三种情况。以 /、./、../ 开头的是文件路径,相对路径按当前模块所在的目录来算。找不到精确的文件名时,Node.js 会依次补上 .js、.json、.node 再试;.json 当 JSON 解析,.node 当编译好的原生插件加载。其他扩展名(比如 .cjs)要写全。
不带路径前缀的,要么是内置模块,要么去 node_modules 里找。查找从当前模块所在目录开始,拼上 /node_modules,找不到就往上一级目录,一直找到文件系统根目录。比如 /home/ry/projects/foo.js 里写 require('bar.js'),会依次找 /home/ry/projects/node_modules/bar.js、/home/ry/node_modules/bar.js、/home/node_modules/bar.js、/node_modules/bar.js。
找到包以后,包里 package.json 的 exports 字段会决定实际加载哪个文件,这部分规则见《Node.js 怎样解析一个包的导入路径?require(esm) 有什么限制?》。全都找不到,require 会抛出 MODULE_NOT_FOUND 错误。
模块代码为什么被包进一个函数
Node.js 文档给出的包装器长这样:
(function (exports, require, module, __filename, __dirname) {
// 模块代码实际写在这里
});
这样做有两个结果。模块顶层用 var、const、let 声明的变量,作用域是这个函数,而不是全局对象,所以两个模块各写一个 const config 不会冲突。另外,module、exports、__filename、__dirname 看起来像全局变量,其实是每个模块各自的一份函数参数。
exports 是 module.exports 的一个引用,给 exports 整体赋新对象不会改变导出结果,这一点见《ES Modules 和 CommonJS 有哪些关键区别?》。
缓存是怎么工作的
Node.js 文档说,模块第一次加载后就会被缓存,只要解析到同一个文件,每次 require 都返回同一个对象,模块代码也不会再执行一遍。想让一段代码执行多次,应该导出一个函数,调用它。
缓存的键是解析后的文件名,这带来两个边界。一是同名包不一定是同一个对象:从不同位置 require('foo'),可能解析到不同的 node_modules/foo,各有一份缓存。二是在大小写不敏感的文件系统上,require('./foo') 和 require('./FOO') 即使是同一个文件,也会被当成两个模块,返回两个不同的对象。
缓存对象就是 require.cache。删掉其中一个键,下次 require 会重新加载那个模块,原生插件除外,重新加载会报错。要注意,删掉缓存只影响之后的 require,已经拿到旧对象的地方不会自动更新。
先进缓存再执行,循环引用就不会死循环
下面用一个对象模拟文件,手写一个最小的 myRequire,按“查缓存 → 建 module → 先登记缓存 → 执行包装函数”的顺序加载。a 和 b 互相引用,和 Node.js 文档 Cycles 小节的例子结构一样。
const sources = {
'./a': function (exports, require, module) {
exports.done = false;
const b = require('./b');
log.push('a 里读到 b.done=' + b.done);
exports.done = true;
},
'./b': function (exports, require, module) {
exports.done = false;
const a = require('./a');
log.push('b 里读到 a.done=' + a.done);
exports.done = true;
},
};
const cache = {};
const log = [];
function myRequire(id) {
if (cache[id]) return cache[id].exports;
const module = { exports: {}, loaded: false };
cache[id] = module;
sources[id](module.exports, myRequire, module);
module.loaded = true;
return module.exports;
}
const a = myRequire('./a');
console.log(log.join(';')); // b 里读到 a.done=false;a 里读到 b.done=true
console.log(a.done); // true
console.log(myRequire('./a') === a); // true
console.log(Object.keys(cache).join(',')); // ./a,./b
为什么 b 读到的 a.done 是 false?加载 a 时,a 的 module 先登记进了缓存,然后才开始执行。a 执行到 require('./b') 就停下来去加载 b,b 又 require('./a'),这时缓存里已经有 a,于是直接返回 a 的 exports。可是 a 只执行到一半,done 还是 false。
b 执行完以后,控制权回到 a,a 读到的 b.done 已经是 true,最后 a 也把 done 改成 true。Node.js 文档里真实的 a.js、b.js、main.js 运行结果是同样的顺序:先打印 in b, a.done = false,再打印 in a, b.done = true。用 Node.js 22.23.3 实际运行,输出与文档一致。
如果把“登记缓存”放到执行之后,b 再 require('./a') 时缓存里没有 a,就会再执行一遍 a,a 又去加载 b,无限循环下去。文档说缓存“允许返回部分完成的对象”,指的就是这个顺序。
循环引用里最容易出的错
上面的例子里 b 只是打印了 a.done,读到 false 不会出错。真正出问题的是在模块顶层就调用对方的函数,比如 b 顶层写 a.format(),而 a 要到 require('./b') 之后才给 exports.format 赋值,这时 a.format 是 undefined,调用就会抛 TypeError。
另一个坑是 module.exports = { ... } 整体替换。b 在循环中拿到的是 a 原来那个 exports 对象,a 后来把 module.exports 换成了新对象,b 手里的引用不会跟着变,永远是那个半成品。
修复思路和 ESM 一样:把双方共用的部分抽到第三个模块,或者把读取推迟到函数调用时,详见《ES Modules 和 CommonJS 有哪些关键区别?》。
常见追问
怎样判断一个文件是被直接运行还是被 require 的?
直接用
node foo.js运行时,require.main就是这个文件的module,所以可以判断require.main === module。被别的模块require时,这个判断是false。
删除 require.cache 能实现热更新吗?
能让下一次
require重新执行文件,但之前已经拿到旧exports的模块还在用旧对象,旧模块里开的定时器、注册的监听也还在运行。原生插件不能这样重新加载,会报错。所以它只是让下一次加载重新执行,谈不上完整的热更新。
require 能加载 ES 模块吗?
Node.js 22.12、20.19 起去掉了
--experimental-require-module标志,可以require()同步的 ES 模块;25.4 起文档把它标为不再是实验特性。模块里有顶层await时不能这样加载。两种模块格式怎样判定,见《Node.js 怎样解析一个包的导入路径?require(esm) 有什么限制?》。