CommonJS是Node.js生态中广泛使用的模块规范,其核心通过require函数实现模块的引入,而require函数的递归加载和缓存机制是保证模块高效运行的关键。理解这两个机制,能帮助开发者更好地处理模块依赖、循环引用等场景。

CommonJS模块的基本结构
Node.js在执行CommonJS模块时,会将模块内容包裹在一个函数中,这个函数会接收exports、require、module等参数,模块内部的变量不会污染全局作用域。我们可以通过以下代码查看模块的原始结构:
// 打印当前模块的包装函数字符串
console.log(require('module').wrapper);
输出的结果是一个数组,第一个元素是函数开头,第二个是函数结尾,组合起来就是模块的包裹函数:
function (exports, require, module, __filename, __dirname) {
// 模块内容写在这里
}
require函数的加载流程
当调用require函数引入一个模块时,会按照以下核心步骤执行:
- 解析模块路径,确定模块的完整路径
- 检查模块缓存,如果缓存中存在则直接返回缓存的
module.exports - 如果缓存不存在,则加载模块内容,执行模块的包裹函数
- 将执行后的
module.exports存入缓存,并返回结果
路径解析规则
require函数的路径解析遵循一定的优先级:
| 路径类型 | 解析规则 |
|---|---|
| 核心模块 | 直接返回Node.js内置的核心模块,如fs、path等,优先级最高 |
| 以/、./、../开头的路径 | 作为相对路径或绝对路径解析,查找对应文件,默认尝试.js、.json、.node后缀 |
| 非路径形式的名称 | 从当前目录的node_modules开始向上查找,直到根目录的node_modules |
模块的递归加载机制
递归加载是指当模块A依赖模块B,模块B又依赖模块C时,require函数会先加载模块C,再加载模块B,最后返回模块A的结果,这个过程是递归执行的。
我们可以通过三个模块来演示递归加载的过程:
模块c.js内容:
console.log('加载模块c');
exports.c = '我是模块c的内容';
模块b.js内容:
console.log('加载模块b');
// 引入模块c,触发递归加载
const c = require('./c');
exports.b = '我是模块b的内容,我引入了' + c.c;
模块a.js内容:
console.log('加载模块a');
// 引入模块b,模块b会先引入模块c
const b = require('./b');
console.log('模块a获取到的b内容:', b.b);
执行node a.js,输出结果如下:
加载模块a 加载模块b 加载模块c 模块a获取到的b内容: 我是模块b的内容,我引入了我是模块c的内容
可以看到加载顺序是a -> b -> c,c加载完成后返回结果给b,b加载完成后再返回结果给a,这就是递归加载的典型表现。
模块的缓存机制
CommonJS模块加载完成后,会将模块的module.exports存入缓存,后续再次require同一个模块时,直接返回缓存结果,不会重复执行模块代码。
缓存的存储位置是require.cache对象,键是模块的完整路径,值是模块对象。我们可以通过以下代码验证缓存的存在:
模块counter.js内容:
let count = 0;
// 每次加载时count加1,但是因为缓存,第二次加载不会执行这段代码
count++;
console.log('counter模块加载次数:', count);
exports.getCount = function() {
return count;
};
测试文件test.js内容:
// 第一次引入counter模块
const counter1 = require('./counter');
console.log('第一次获取count:', counter1.getCount());
// 第二次引入同一个counter模块
const counter2 = require('./counter');
console.log('第二次获取count:', counter2.getCount());
// 检查两个引入的是否是同一个对象
console.log('两个引用是否相等:', counter1 === counter2);
// 查看缓存中是否存在counter模块
console.log('缓存中是否有counter模块:', !!require.cache[require.resolve('./counter')]);
执行node test.js的输出结果:
counter模块加载次数: 1 第一次获取count: 1 第二次获取count: 1 两个引用是否相等: true 缓存中是否有counter模块: true
可以看到counter模块只加载了一次,两次引入返回的是同一个对象,缓存生效。
缓存的注意事项
- 缓存是基于模块的完整路径的,即使两个模块内容相同,路径不同也会被视为不同模块,分别缓存
- 如果需要清除缓存,可以删除
require.cache中对应的键,但不建议在生产环境使用 - 循环引用时,缓存机制可以保证不会陷入无限递归,被引用但未加载完成的模块会返回未执行完成的exports对象
循环引用场景下的加载表现
当两个模块相互引用时,CommonJS的递归和缓存机制会如何处理?我们来看一个例子:
模块a.js内容:
console.log('开始加载a模块');
exports.a = 'a模块初始值';
// 引入b模块
const b = require('./b');
console.log('a模块中b模块的内容:', b.b);
exports.a = 'a模块最终值';
模块b.js内容:
console.log('开始加载b模块');
exports.b = 'b模块初始值';
// 引入a模块,形成循环引用
const a = require('./a');
console.log('b模块中a模块的内容:', a.a);
exports.b = 'b模块最终值';
执行node a.js的输出结果:
开始加载a模块 开始加载b模块 b模块中a模块的内容: a模块初始值 a模块中b模块的内容: b模块最终值
加载流程分析:
- 加载a模块,执行到引入b模块时,暂停a模块的加载,开始加载b模块
- 加载b模块,执行到引入a模块时,检查缓存发现a模块已经在加载中,直接返回当前a模块的exports(此时a的值还是初始值)
- b模块加载完成,将结果存入缓存,回到a模块继续执行
- a模块加载完成,更新exports的值,存入缓存
这就是循环引用下的加载逻辑,缓存机制避免了无限递归,同时返回的是加载到当前步骤的exports对象。
总结
CommonJS的require函数通过递归的方式处理模块依赖,从入口模块开始逐层加载依赖的模块,直到所有依赖都加载完成。同时模块缓存机制保证了同一个模块不会被重复加载,提升了模块加载的效率。理解这两个机制,能帮助开发者更好地处理模块依赖、循环引用等场景,避免因为模块加载逻辑不熟悉导致的bug。