CommonJS 模块加载器:深入理解 require 的同步、缓存与导出机制
各位技术同仁,欢迎来到今天的技术讲座。我们将深入探讨 CommonJS 模块系统的核心机制,并通过手写实现一个简化的模块加载器来揭示 require 函数背后的秘密。理解 require 的同步加载特性、模块缓存机制以及灵活的导出方式,不仅能帮助我们更好地编写 Node.js 应用,更是理解现代 JavaScript 模块化演进历程的关键一步。
CommonJS 是 Node.js 早期采用的模块化规范,它通过 require 语句导入模块,通过 module.exports 或 exports 导出模块。其设计理念简洁而强大,尤其适用于服务器端同步加载的场景。与浏览器端的异步加载(如 AMD)形成鲜明对比,CommonJS 模块在被 require 时会立即执行,并返回其导出的内容。
1. CommonJS 模块化的核心概念
在 Node.js 环境中,每个文件都被视为一个独立的模块。模块内部的代码默认私有,不会污染全局作用域。这种隔离性是通过一个特殊的“模块包装器”实现的。
当 Node.js 加载一个模块文件时,它实际上会将其内容包裹在一个函数表达式中,这个函数在执行时会接收到几个重要的参数:
exports: 一个对象,用于导出模块的公共接口。require: 一个函数,用于加载其他模块。module: 一个对象,代表当前模块,module.exports是其最重要的属性。__filename: 当前模块文件的绝对路径。__dirname: 当前模块文件所在目录的绝对路径。
这个包装器函数的骨架大致如下:
(function(exports, require, module, __filename, __dirname) {
// 模块的实际代码内容
// 例如:
// const foo = require('./foo');
// module.exports = { ... };
})(exports, require, module, __filename, __dirname);
理解这个包装器是理解 CommonJS 模块加载机制的起点。它为每个模块提供了一个私有的作用域,并注入了必要的工具函数和对象。
2. 模块加载器的基本架构
要实现一个简化的 CommonJS 模块加载器,我们需要解决以下几个关键问题:
- 文件读取: 如何从文件系统读取模块的源代码?
- 路径解析: 如何将
require的路径参数解析为实际的文件路径? - 模块缓存: 如何确保模块只被加载和执行一次?
- 模块执行: 如何在一个隔离的环境中执行模块代码,并捕获其导出内容?
- 导出机制: 如何处理
exports和module.exports?
我们将围绕一个 Module 类和核心的 myRequire 函数来构建我们的加载器。
2.1. 模块状态与缓存机制
模块缓存是 CommonJS 模块加载器不可或缺的一部分。它保证了:
- 性能优化: 避免重复读取文件和执行代码。
- 一致性: 每次
require同一个模块,都返回同一个实例。 - 循环依赖处理: 帮助解决模块间的循环依赖问题。
我们将使用一个全局对象(或 Module 类的静态属性)作为缓存,以模块的绝对路径为键,存储已加载的 Module 实例。
2.2. Module 类的定义
首先,我们定义一个 Module 类,它将封装模块的各种状态和行为。
const path = require('path');
const fs = require('fs');
const vm = require('vm'); // 用于在沙箱中运行代码
class Module {
constructor(id, parent) {
this.id = id; // 模块的唯一标识符,通常是其绝对路径
this.filename = null; // 模块文件的绝对路径
this.parent = parent; // 引用加载此模块的父模块
this.exports = {}; // 模块的导出对象
this.loaded = false; // 标识模块是否已加载完成
this.children = []; // 此模块加载的所有子模块
}
// 静态属性,用于存储所有已加载的模块缓存
static _cache = {};
// 静态属性,用于处理不同文件扩展名的加载逻辑
static _extensions = {
'.js': (module, filename) => {
// 读取文件内容
const content = fs.readFileSync(filename, 'utf8');
// 编译并执行模块内容
module._compile(content, filename);
},
'.json': (module, filename) => {
// 读取 JSON 文件内容
const content = fs.readFileSync(filename, 'utf8');
// 直接解析为 JavaScript 对象并赋值给 module.exports
try {
module.exports = JSON.parse(content);
} catch (err) {
throw new Error(`Cannot parse JSON module ${filename}: ${err.message}`);
}
},
// 更多扩展名,如 .node (C++ addon) 等,此处简化不实现
};
// 静态方法,用于解析模块的绝对路径
static _resolveFilename(request, parentModule) {
// 1. 处理绝对路径
if (path.isAbsolute(request)) {
if (fs.existsSync(request)) {
return request;
}
}
// 2. 处理相对路径
const parentDir = parentModule ? path.dirname(parentModule.filename) : process.cwd();
let resolvedPath = path.resolve(parentDir, request);
// 尝试添加文件扩展名
const extensions = Object.keys(Module._extensions);
for (const ext of extensions) {
if (fs.existsSync(resolvedPath + ext)) {
return resolvedPath + ext;
}
}
// 尝试作为目录加载
if (fs.existsSync(resolvedPath)) {
if (fs.statSync(resolvedPath).isDirectory()) {
// 尝试加载 package.json 的 main 字段
const packageJsonPath = path.join(resolvedPath, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (packageJson.main) {
const mainPath = path.resolve(resolvedPath, packageJson.main);
// 再次尝试各种扩展名
for (const ext of extensions) {
if (fs.existsSync(mainPath + ext)) {
return mainPath + ext;
}
}
if (fs.existsSync(mainPath)) { // 如果 mainPath 本身就是一个文件
return mainPath;
}
}
} catch (e) {
// 忽略解析 package.json 的错误
}
}
// 如果 package.json 没有指定 main,或解析失败,尝试加载 index.js
if (fs.existsSync(path.join(resolvedPath, 'index.js'))) {
return path.join(resolvedPath, 'index.js');
}
} else { // 如果 resolvedPath 存在但不是目录,也不是带扩展名的文件,则直接返回
return resolvedPath;
}
}
// 3. 处理 npm 模块 (这里简化,只在当前目录的 node_modules 中查找)
// 实际的 Node.js 会向上遍历 node_modules 目录
const nodeModulesPath = path.join(process.cwd(), 'node_modules', request);
for (const ext of extensions) { // 尝试直接 require 'my-utility.js'
if (fs.existsSync(nodeModulesPath + ext)) {
return nodeModulesPath + ext;
}
}
// 尝试作为 npm 模块目录加载
if (fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory()) {
const packageJsonPath = path.join(nodeModulesPath, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (packageJson.main) {
const mainPath = path.resolve(nodeModulesPath, packageJson.main);
for (const ext of extensions) {
if (fs.existsSync(mainPath + ext)) {
return mainPath + ext;
}
}
if (fs.existsSync(mainPath)) {
return mainPath;
}
}
} catch (e) {
// 忽略解析 package.json 的错误
}
}
// 尝试加载 index.js
if (fs.existsSync(path.join(nodeModulesPath, 'index.js'))) {
return path.join(nodeModulesPath, 'index.js');
}
}
// 如果以上都找不到,抛出模块未找到错误
const error = new Error(`Cannot find module '${request}' from '${parentModule ? parentModule.filename : process.cwd()}'`);
error.code = 'MODULE_NOT_FOUND';
throw error;
}
// 加载模块的核心方法
load(filename) {
this.filename = filename;
this.id = filename; // 模块ID也设置为绝对路径
// 获取文件扩展名
const ext = path.extname(filename);
// 根据扩展名调用对应的加载器
if (Module._extensions[ext]) {
Module._extensions[ext](this, filename);
} else {
// 默认按 .js 处理 (如果文件没有扩展名,Node.js 也会尝试 .js, .json, .node)
// 在我们的简化实现中,如果_resolveFilename没有找到带扩展名的文件,这里可能已经报错了
// 如果需要更严格模拟,可以在这里再次尝试 .js
Module._extensions['.js'](this, filename);
}
this.loaded = true; // 标记模块已加载
}
// 编译并执行模块代码
_compile(content, filename) {
// 准备模块包装器的参数
const dirname = path.dirname(filename);
const requireFunc = this.createRequireFunction(); // 创建一个绑定到当前模块的 require 函数
const exports = this.exports; // 初始的 exports 对象
const module = this; // 当前模块对象
// 构建模块包装器函数字符串
const wrapper = `(function(exports, require, module, __filename, __dirname) {n${content}n});`;
// 使用 vm 模块在沙箱中执行代码
// 这里我们使用 vm.runInThisContext,这意味着模块代码将在当前 V8 上下文中执行
// 但通过闭包和传递参数,我们仍然实现了作用域隔离和变量注入。
// 更严格的沙箱会使用 vm.runInNewContext
const compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true,
});
// 调用包装器函数,传入参数
compiledWrapper.call(exports, exports, requireFunc, module, filename, dirname);
}
// 为当前模块创建 require 函数实例
createRequireFunction() {
const self = this; // 捕获当前模块实例
function localRequire(request) {
// 解析请求路径
const resolvedFilename = Module._resolveFilename(request, self);
// 检查缓存
if (Module._cache[resolvedFilename]) {
return Module._cache[resolvedFilename].exports;
}
// 创建新的模块实例
const module = new Module(resolvedFilename, self);
Module._cache[resolvedFilename] = module; // 立即放入缓存,以处理循环依赖
// 加载模块
module.load(resolvedFilename);
// 将新模块添加到父模块的子模块列表中
self.children.push(module);
// 返回模块的导出对象
return module.exports;
}
// 可以在 require 函数上添加一些辅助方法,例如 Node.js 的 require.resolve
localRequire.resolve = (request) => Module._resolveFilename(request, self);
return localRequire;
}
}
// 模拟 Node.js 的全局 require 函数,但我们自己实现
function myRequire(request) {
// 顶层 require 没有父模块
const resolvedFilename = Module._resolveFilename(request, null);
// 检查缓存
if (Module._cache[resolvedFilename]) {
return Module._cache[resolvedFilename].exports;
}
// 创建一个“主”模块实例,作为所有模块的根
// 在 Node.js 中,这个通常是入口文件 (process.argv[1])
// 我们这里简化为直接加载请求的模块作为主模块
const mainModule = new Module(resolvedFilename, null);
Module._cache[resolvedFilename] = mainModule; // 立即放入缓存
// 加载模块
mainModule.load(resolvedFilename);
return mainModule.exports;
}
// 导出 Module 类和 myRequire 函数,以便 main.js 可以使用
module.exports = {
Module,
myRequire
};
2.3. 核心 myRequire 函数
现在,我们可以定义一个顶层的 myRequire 函数,作为我们模块加载器的入口。
// my_loader.js 文件中的 myRequire 函数实现
// ... (上面定义的 Module 类和 myRequire 函数代码) ...
3. 深入理解 CommonJS 的关键机制
3.1. 同步加载(Synchronous Loading)
我们的 myRequire 实现天然地体现了 CommonJS 的同步加载特性。观察 Module.prototype.load 方法:
fs.readFileSync(filename, 'utf8'):这是一个同步的文件读取操作,它会阻塞当前线程,直到文件内容完全读取完毕。module._compile(content, filename):一旦内容读取完毕,模块代码会被立即编译和执行。
这意味着当你在一个模块中调用 require('./anotherModule') 时,程序会暂停执行当前模块,转而去加载、编译并执行 anotherModule。只有当 anotherModule 完全加载并返回其 exports 后,当前模块才会继续执行。
这种同步模型在服务器端(如 Node.js)非常有效,因为文件 I/O 通常发生在本地磁盘,延迟较低。但在浏览器环境中,同步加载会阻塞 UI 线程,导致页面假死,因此不适用。这也是为什么浏览器端普遍采用异步模块加载(如 ES Modules 或 AMD)的原因。
3.2. 模块缓存(Module Caching)
模块缓存是 CommonJS 模块系统的基石之一,由 Module._cache 静态对象实现。
缓存原理:
- 当
myRequire或localRequire被调用时,首先会尝试将请求的模块路径解析为绝对路径。 - 然后,它会检查
Module._cache中是否已经存在这个绝对路径对应的模块。 - 如果存在,直接返回缓存中的
module.exports对象,而不会再次读取文件或执行模块代码。 - 如果不存在,则创建一个新的
Module实例,将其加入缓存,然后加载并执行模块代码,最后返回其exports。
为什么重要:
- 避免重复工作: 模块代码只执行一次,节省 CPU 和 I/O 资源。
- 状态共享: 如果一个模块维护了内部状态(例如,一个单例数据库连接),所有
require它的地方都会得到同一个实例,从而共享这个状态。 - 循环依赖处理: 当一个模块 A 引用 B,B 又引用 A 时,缓存机制允许 A 在 B
requireA 时,立即返回 A 已经缓存的exports对象(即使 A 尚未完全执行完毕)。这避免了无限循环,尽管返回的exports对象可能还不完整。
示例:
// moduleA.js
// 假设这是通过 myRequire 加载的
console.log('moduleA 开始执行');
let counter = 0;
module.exports.increment = () => ++counter;
module.exports.getCounter = () => counter;
console.log('moduleA 执行完毕');
// main.js 中模拟调用
const myRequire = require('./my_loader').myRequire; // 假设 my_loader.js 导出了 myRequire
const a1 = myRequire('./modules/moduleA.js'); // 假设存在 './modules/moduleA.js'
const a2 = myRequire('./modules/moduleA.js'); // 从缓存中获取
console.log('a1 === a2:', a1 === a2); // true
console.log('Counter after a1.increment():', a1.increment()); // 1
console.log('Counter from a2.getCounter():', a2.getCounter()); // 1 (共享状态)
// 预期输出:
// moduleA 开始执行
// moduleA 执行完毕
// a1 === a2: true
// Counter after a1.increment(): 1
// Counter from a2.getCounter(): 1
从输出可以看出,moduleA 开始执行 和 moduleA 执行完毕 只打印了一次,证明了模块只执行一次。a1 和 a2 引用的是同一个对象,并且共享了 counter 变量的状态。
3.3. 导出机制:exports 与 module.exports
这是 CommonJS 中一个经常让初学者感到困惑的地方。理解它们的区别至关重要。
核心规则: require 函数最终返回的是 module.exports 对象的值。
初始状态:
当模块代码被包装器函数执行时,exports 和 module.exports 都指向同一个空对象 {}。
(function(exports, require, module, __filename, __dirname) {
// 在模块内部,初始时:
// exports === module.exports // true
// exports 和 module.exports 都指向同一个空对象 {}
// ... 模块代码 ...
});
两种导出方式:
-
通过
exports对象的属性添加:
当你通过exports.propertyName = value的方式导出时,实际上是在修改exports(以及module.exports)所指向的同一个对象的属性。这种方式适用于导出多个具名成员。// myModule.js exports.foo = 'hello'; exports.bar = function() { return 'world'; }; // require('./myModule') 将返回 { foo: 'hello', bar: [Function] } -
直接赋值给
module.exports:
当你直接将一个值赋值给module.exports时,你实际上是改变了module.exports的引用,使其不再指向原来的空对象,而是指向你赋给它的新值。此时,exports仍然指向原来的空对象,但require最终返回的是新的module.exports。这种方式适用于导出单个值(如函数、类、数组或任意对象)。// myModule.js (导出单个函数) module.exports = function() { console.log('This is a function exported directly.'); }; // require('./myModule') 将直接返回这个函数 // anotherModule.js (导出单个对象) const myData = { name: 'Alice', age: 30 }; module.exports = myData; // require('./anotherModule') 将直接返回 myData 对象
注意事项(陷阱):
-
直接赋值给
exports将会失效!
如果你尝试exports = { foo: 'bar' },这仅仅是将exports变量重新指向了一个新的对象。module.exports仍然指向最初的那个空对象,因此require将不会返回你期望的新对象。// badModule.js exports = { name: 'Bob' // 这不会生效! }; exports.age = 25; // 这也不会生效,因为它修改的是新的 exports 对象,而不是 module.exports 指向的原始对象 module.exports.gender = 'male'; // 这会生效,因为直接修改 module.exports 指向的对象 // main.js const m = myRequire('./badModule.js'); // 假设存在 './badModule.js' console.log(m); // 预期输出: { gender: 'male' } // name 和 age 不会被导出!解释: 模块包装器函数接收的是
exports对象的引用。当exports = { name: 'Bob' }时,你只是改变了函数作用域内局部变量exports的指向,使其不再指向module.exports所指向的对象。而module.exports仍然指向最初传入的那个对象。因此,外部require看到的module.exports没有改变。正确做法: 始终通过
module.exports进行整体赋值。// goodModule.js module.exports = { name: 'Bob', age: 25 }; // require('./goodModule') 将返回 { name: 'Bob', age: 25 }
总结表格:exports 与 module.exports 的行为
| 操作 | module.exports 的值 |
exports 的值 |
require() 返回的值 |
备注 |
|---|---|---|---|---|
| 初始状态 | {} |
{} |
N/A (尚未返回) | exports 和 module.exports 指向同一个对象 |
exports.foo = 'bar' |
{ foo: 'bar' } |
{ foo: 'bar' } |
{ foo: 'bar' } |
修改了共享对象的属性 |
module.exports = { baz: 'qux' } |
{ baz: 'qux' } |
{} (仍指向原始空对象) |
{ baz: 'qux' } |
module.exports 引用被改变,exports 不受影响 |
exports = { foo: 'bar' } |
{} (仍指向原始空对象) |
{ foo: 'bar' } (新的对象) |
{} (仍是原始空对象) |
错误用法! 仅仅是局部变量赋值,不会影响 module.exports |
module.exports.foo = 'bar' |
{ foo: 'bar' } |
{ foo: 'bar' } |
{ foo: 'bar' } |
直接修改 module.exports 指向的对象的属性 |
4. 实践演示:使用我们的加载器
为了演示我们实现的 CommonJS 模块加载器,我们创建几个示例文件。
文件结构:
.
├── my_loader.js // 我们的 Module 类和 myRequire 函数
├── main.js // 入口文件
├── modules
│ ├── a.js
│ ├── b.js
│ ├── c.js
│ └── data.json
└── node_modules
└── my-utility
└── index.js
my_loader.js (就是上面我们实现的所有 Module 类和 myRequire 函数的代码)
// (此处省略 my_loader.js 的完整代码,因为它已在前面给出)
// ... (上面定义的 Module 类和 myRequire 函数代码) ...
modules/a.js
// modules/a.js
console.log('Module A loading...');
const b = require('./b'); // 相对路径 require
console.log('Module A requires module B');
module.exports = {
name: 'Module A',
fromB: b.name,
getBValue: b.getValue,
counter: b.getCounter,
incrementB: b.increment,
jsonData: b.jsonData // 传递 B 模块中的 JSON 数据
};
console.log('Module A loaded.');
modules/b.js
// modules/b.js
console.log('Module B loading...');
const c = require('./c'); // 相对路径 require
const data = require('./data.json'); // JSON 文件 require
console.log('Module B requires module C and data.json');
let value = 100;
let callCount = 0;
exports.name = 'Module B';
exports.getValue = () => value;
exports.increment = () => {
callCount++;
return ++value;
};
exports.getCounter = () => callCount;
exports.cName = c.name;
exports.jsonData = data;
console.log('Module B loaded.');
modules/c.js
// modules/c.js
console.log('Module C loading...');
exports.name = 'Module C';
exports.version = '1.0.0';
console.log('Module C loaded.');
modules/data.json
{
"key": "value from json",
"number": 123
}
node_modules/my-utility/index.js
// node_modules/my-utility/index.js
console.log('My-utility loading...');
module.exports = {
utilName: 'My Utility',
doSomething: () => 'Doing something useful!'
};
console.log('My-utility loaded.');
main.js (入口文件)
// main.js
// 引入我们自己的模块加载器
const { Module, myRequire } = require('./my_loader');
const vm = require('vm'); // 引入 vm 模块用于模拟 exports vs module.exports 演示
console.log('Main application started.');
// 加载 modules/a.js
const moduleA = myRequire('./modules/a');
console.log('--- main.js loaded module A ---');
console.log('moduleA.name:', moduleA.name);
console.log('moduleA.fromB:', moduleA.fromB);
console.log('moduleA.getBValue():', moduleA.getBValue());
console.log('moduleA.counter():', moduleA.counter()); // callCount should be 0 initially
console.log('moduleA.incrementB():', moduleA.incrementB()); // value in B increments
console.log('moduleA.getBValue() after increment:', moduleA.getBValue());
console.log('moduleA.counter() after increment:', moduleA.counter()); // callCount should be 1
console.log('moduleA.jsonData:', moduleA.jsonData); // Access nested JSON data from Module A, which got it from Module B
console.log('n--- Demonstrating caching ---');
const moduleA_cached = myRequire('./modules/a');
console.log('moduleA === moduleA_cached:', moduleA === moduleA_cached); // Should be true
console.log('n--- Loading an npm-style module ---');
const myUtility = myRequire('my-utility'); // 模拟加载 npm 模块
console.log('myUtility.utilName:', myUtility.utilName);
console.log('myUtility.doSomething():', myUtility.doSomething());
console.log('n--- Demonstrating exports vs module.exports ---');
// 为了简化,我们直接在这里模拟一个模块的 _compile 过程来观察
const fakeModule = new Module('fake_module_id', null);
let fakeExports = fakeModule.exports; // 初始 {}
const testCode = `
exports.a = 1;
module.exports.b = 2; // 此时 module.exports 和 exports 依然指向同一个对象
module.exports = { c: 3 }; // module.exports 重新赋值,exports 仍指向旧对象
exports.d = 4; // 此时 exports 仍指向旧对象,这里修改的是旧对象,不会被 require 返回
`;
const wrappedTestCode = `(function(exports, require, module, __filename, __dirname) {n${testCode}n});`;
const compiledTestCode = vm.runInThisContext(wrappedTestCode, { filename: 'testExports.js' });
compiledTestCode.call(fakeExports, fakeExports, () => {}, fakeModule, 'testExports.js', '.');
console.log('Result from exports vs module.exports test:', fakeModule.exports); // 应该输出 { c: 3 }
console.log('nMain application finished.');
如何运行:
在项目根目录(与 my_loader.js 和 main.js 同级)下,执行:
node main.js
预期输出:
Main application started.
Module B loading...
Module C loading...
Module C loaded.
Module B requires module C and data.json
Module B loaded.
Module A loading...
Module A requires module B
Module A loaded.
--- main.js loaded module A ---
moduleA.name: Module A
moduleA.fromB: Module B
moduleA.getBValue(): 100
moduleA.counter(): 0
moduleA.incrementB(): 101
moduleA.getBValue() after increment: 101
moduleA.counter() after increment: 1
moduleA.jsonData: { key: 'value from json', number: 123 }
--- Demonstrating caching ---
moduleA === moduleA_cached: true
--- Loading an npm-style module ---
My-utility loading...
My-utility loaded.
myUtility.utilName: My Utility
myUtility.doSomething(): Doing something useful!
--- Demonstrating exports vs module.exports ---
Result from exports vs module.exports test: { c: 3 }
Main application finished.
这个输出清晰地展示了:
- 加载顺序:
main.js引用a.js,a.js引用b.js,b.js引用c.js和data.json。因此,模块加载的顺序是C -> data.json -> B -> A。 - 同步性: 模块的
console.log语句是按顺序立即执行的,证明了同步加载。 - 缓存: 第二次
require('./modules/a')时,Module A loading...不会再次打印,且moduleA === moduleA_cached为true,证明了缓存生效。 - 状态共享:
moduleA.incrementB()改变了modules/b.js内部的value变量,并且moduleA.getBValue()和moduleA.counter()都能正确反映这些变化。 exportsvsmodule.exports: 最后的手动模拟演示了当module.exports被重新赋值后,require最终只会返回module.exports的新值。
5. 模块化机制的精髓
我们实现的 CommonJS 模块加载器,尽管是简化的,但它捕捉了 Node.js 模块系统的核心精髓。一个完整的 CommonJS 实现还需要处理更复杂的路径解析、原生模块加载、更健壮的错误处理以及性能优化。然而,通过这次深入的探讨和实践,我们已经清晰地理解了 require 函数背后的同步加载、模块缓存和导出机制。这些基础知识为我们驾驭更复杂的 JavaScript 生态系统,以及理解 ES Modules 等现代模块化规范奠定了坚实的基础。