模块机制深度解析
一句话概括
Node.js 的模块机制基于 CommonJS 规范实现,通过 require 函数加载模块、module.exports 导出接口,结合模块缓存和循环依赖处理策略,构成了 Node.js 应用的组织基础,本文将深入剖析 require 的加载流程、模块包装、缓存机制以及循环依赖的解决原理。
背景与意义
在 JavaScript 诞生之初,这门语言并没有模块系统的概念。随着前端应用规模的扩大,「全局变量污染」「依赖管理混乱」「代码组织困难」等问题日益突出。2009 年,CommonJS 规范诞生,为 JavaScript 提供了服务器端模块化的标准。
同年,Ryan Dahl 发布了 Node.js,并将 CommonJS 作为其内置模块系统。从此,JavaScript 有了合理组织代码的方式:每个文件就是一个模块,暴露什么由自己决定,需要什么就引入什么。
理解 Node.js 模块机制,不仅能帮你正确使用 require 和 module.exports,更能帮你理解 Node.js 生态的底层设计哲学——为什么 npm 能成为世界上最大的包管理器?为什么循环依赖有时能工作而有时不行?模块缓存如何影响你的应用行为?
概念与定义
CommonJS:一个为 JavaScript 定义模块化标准规范的社区项目,Node.js 采用了其中的模块规范。
模块(Module):在 Node.js 中,每个文件都被视为一个独立的模块。模块内部的变量、函数、类默认对外不可见。
require:加载模块的函数,接受模块标识符作为参数,返回该模块导出的对象。
module.exports:每个模块都有一个 module 对象,其中 module.exports 是该模块对外暴露的接口。
exports:module.exports 的引用别名,方便直接添加属性。
模块缓存:Node.js 会缓存已经加载过的模块,避免重复加载。
循环依赖:两个或多个模块互相引用,形成一个循环引用链。
核心知识点拆解
1. require 的加载流程
当你在代码中写下 require('./foo') 时,Node.js 内部经历了哪些步骤?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* Node.js require 的简化实现
* 展示模块加载的核心流程
*/
const path = require('path');
const fs = require('fs');
const Module = require('module');
// Node.js 内部 require 的实现逻辑(简化版)
function myRequire(modulePath) {
// 第 1 步:解析模块路径
const resolvedPath = resolveModulePath(modulePath);
// 第 2 步:检查模块缓存
if (Module._cache[resolvedPath]) {
return Module._cache[resolvedPath].exports;
}
// 第 3 步:创建新模块实例
const module = new Module(resolvedPath);
// 第 4 步:缓存(先缓存再加载,以处理循环依赖)
Module._cache[resolvedPath] = module;
// 第 5 步:加载模块
tryLoadingModule(module, resolvedPath);
// 第 6 步:返回 exports
return module.exports;
}
function resolveModulePath(modulePath) {
// 1. 如果是核心模块(如 'fs', 'path'),直接返回
if (Module.builtinModules.includes(modulePath)) {
return modulePath;
}
// 2. 如果是相对路径或绝对路径
if (modulePath.startsWith('./') || modulePath.startsWith('../') || path.isAbsolute(modulePath)) {
return resolveFileOrDir(modulePath);
}
// 3. 否则在 node_modules 中查找
return resolveFromNodeModules(modulePath);
}
/**
* 文件名解析策略:.js → .json → .node → index.js → index.json → index.node
*/
function resolveFileOrDir(basePath) {
const fullPath = path.resolve(basePath);
const exts = ['.js', '.json', '.node'];
// 尝试直接匹配文件
for (const ext of exts) {
const filePath = fullPath + ext;
if (fs.existsSync(filePath)) return filePath;
}
// 尝试作为目录处理
for (const ext of exts) {
const indexPath = path.join(fullPath, 'index' + ext);
if (fs.existsSync(indexPath)) return indexPath;
}
throw new Error(`Cannot find module '${basePath}'`);
}
require 的加载全流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
require('./utils')
│
├─ 1. 路径解析 ──────────────────────────┐
│ ├─ 核心模块(fs, path...)→ 直接返回 │
│ ├─ 相对路径 → 拼接当前文件目录 │
│ ├─ 绝对路径 → 直接使用 │
│ └─ node_modules → 逐级向上查找 │
│ │
├─ 2. 文件定位 ──────────────────────────┤
│ ├─ 补全后缀名:.js → .json → .node │
│ ├─ 目录:查找 index.js/index.json │
│ └─ package.json:查找 main 字段 │
│ │
├─ 3. 缓存检查 ──────────────────────────┤
│ ├─ 已缓存 → 直接返回 module.exports │
│ └─ 未缓存 → 继续 │
│ │
├─ 4. 创建模块对象 ──────────────────────┤
│ ├─ new Module(fullPath) │
│ └─ 加入缓存 Module._cache[path] │
│ │
├─ 5. 加载模块内容 ──────────────────────┤
│ ├─ .js → fs.readFileSync + 编译执行 │
│ ├─ .json → JSON.parse │
│ └─ .node → process.dlopen(C++插件) │
│ │
└─ 6. 返回 module.exports ────────────────┘
2. 模块包装与执行上下文
每个模块文件在执行前都会被 Node.js 包装在一个函数中,这就是为什么模块内可以使用 require、module、exports、__dirname、__filename 等全局变量——它们实际上是函数参数。
1
2
3
4
5
6
7
// 这是一个普通的模块文件 foo.js
const name = 'Foo Module';
console.log(__dirname); // 当前目录
console.log(__filename); // 当前文件完整路径
module.exports = { name };
exports.greet = function() { return `Hello from ${name}`; };
Node.js 实际上是这样包装它的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Node.js 使用 Module.prototype._compile 来编译模块
// 实际包装形式如下:
function Module._compile(content, filename) {
// 包装函数
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
const wrappedContent = wrapper[0] + content + wrapper[1];
// 编译并执行
const compiledWrapper = vm.runInThisContext(wrappedContent, {
filename,
lineOffset: 0,
displayErrors: true,
});
// 调用包装函数
const result = compiledWrapper.call(
module.exports, // this 指向 module.exports
module.exports, // exports 参数
require, // require 参数
module, // module 参数
__filename, // __filename 参数
__dirname // __dirname 参数
);
return result;
}
为什么 exports 和 module.exports 指向同一个对象?
exports只是module.exports的引用- 直接给
exports赋值(exports = something)会切断这个引用 - 正确做法是给
exports添加属性,或者直接赋值module.exports
1
2
3
4
5
6
7
8
9
10
// ✅ 正确使用方式
exports.name = 'Alice'; // 等同于 module.exports.name = 'Alice'
module.exports = { // 替换整个导出对象
name: 'Alice',
greet() { return 'Hi'; }
};
// ❌ 错误:exports 重新赋值后不再是 module.exports 的引用
exports = { name: 'Bob' }; // module.exports 仍然是原来的空对象
// 外部 require 得到的是 module.exports,拿不到这个 { name: 'Bob' }
3. 模块缓存机制详解
Node.js 的模块缓存是单例模式的天然实现。被加载过的模块会以模块的完整路径为 key 存储在 Module._cache 对象中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* 模块缓存的行为演示
*/
const path = require('path');
// 查看当前缓存
console.log('初始缓存内容:', Object.keys(require.cache));
// 加载一个模块
const utils = require('./utils');
console.log('加载后缓存内容:', Object.keys(require.cache));
// 再次加载同一个模块——直接返回缓存
const utilsAgain = require('./utils');
console.log('是否同一个对象:', utils === utilsAgain); // true
// 强制清除缓存(用于开发环境热更新)
delete require.cache[require.resolve('./utils')];
const utilsFresh = require('./utils');
console.log('清除缓存后是否是新对象:', utils !== utilsFresh); // true
/**
* 模块缓存的实用技巧
*/
// 技巧 1:查看当前进程所有已加载的模块
console.log('已加载的模块列表:');
for (const [path, mod] of Object.entries(require.cache)) {
console.log(` ${path}`);
console.log(` 导出: ${Object.keys(mod.exports)}`);
}
// 技巧 2:实现模块热更新(开发环境)
function hotReload(modulePath) {
// 递归清除目标模块及其子模块的缓存
function cleanCache(modulePath) {
const mod = require.cache[require.resolve(modulePath)];
if (!mod) return;
// 清除子模块缓存
mod.children.forEach(child => {
cleanCache(child.id);
});
// 清除自身缓存
delete require.cache[require.resolve(modulePath)];
}
cleanCache(modulePath);
return require(modulePath);
}
// 技巧 3:利用缓存实现单例
// db.js
// let connection = null;
// module.exports = {
// getConnection() {
// if (!connection) {
// connection = createConnection();
// }
// return connection;
// }
// };
// 由于模块缓存,所有 require('./db') 得到同一个 connection
缓存的影响:缓存意味着模块只在首次加载时执行一次。如果模块初始化时有副作用(如连接数据库、写入日志),这些操作只执行一次。
4. 循环依赖的处理
循环依赖是模块系统中的一大难题,Node.js 用「先缓存后执行」的策略优雅地处理了它:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// a.js
console.log('a 开始执行');
const b = require('./b');
console.log('a 中 b 的值:', b);
module.exports = { name: 'module A', fromB: b };
console.log('a 执行完毕');
// b.js
console.log('b 开始执行');
const a = require('./a');
console.log('b 中 a 的值:', a);
module.exports = { name: 'module B', fromA: a };
console.log('b 执行完毕');
// 输出结果:
// a 开始执行
// b 开始执行
// b 中 a 的值: {} <-- !!!
// b 执行完毕
// a 中 b 的值: { name: 'module B', fromA: {} }
// a 执行完毕
原理分析:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
加载 a.js:
1. 创建 Module('a.js') 并加入缓存(此时 module.exports = {})
2. 开始执行 a.js 的代码
3. 执行到 require('./b')
↳ 加载 b.js:
1. 创建 Module('b.js') 并加入缓存(此时 module.exports = {})
2. 执行 b.js 的代码
3. 执行到 require('./a')
↳ 检查缓存,发现 a.js 已经在缓存中!
↳ 返回 a.js 当前导出的内容 → {} (因为 a.js 还没执行完!)
4. 继续执行 b.js 后续代码
5. b.js 执行完成,module.exports = { name: 'module B', fromA: {} }
4. a.js 拿到 b.js 的完整导出 { name: 'module B', fromA: {} }
5. 继续执行 a.js 后续代码
6. a.js 执行完成,module.exports = { name: 'module A', fromB: { name: 'module B', fromA: {} } }
解决循环依赖的最佳实践:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 方案 1:提取公共依赖(推荐)
// 将 A 和 B 共同依赖的代码提取到 C 中
// a.js → c.js ← b.js
// 方案 2:延迟访问(临时方案)
// a.js
const b = require('./b');
module.exports = {
name: 'module A',
getFromB() {
return require('./b').value; // 执行到这时 b 已加载完成
}
};
// 方案 3:根据生命周期重构
// 将初始化逻辑拆分为异步的 init 方法
// 在应用启动时按正确顺序手动调用
实战案例
实现一个简单的模块加载器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/**
* 迷你 CommonJS 模块加载器
* 帮助我们理解 Node.js 模块系统的内部实现
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
class MiniModule {
static _cache = {};
static _extensions = {
'.js'(module) {
const content = fs.readFileSync(module.filename, 'utf8');
module._compile(content);
},
'.json'(module) {
const content = fs.readFileSync(module.filename, 'utf8');
module.exports = JSON.parse(content);
}
};
constructor(filename) {
this.id = filename;
this.filename = filename;
this.exports = {};
this.loaded = false;
this.children = [];
}
_compile(content) {
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'});'
];
const compiledWrapper = vm.runInThisContext(
wrapper[0] + content + wrapper[1],
{
filename: this.filename,
lineOffset: 0,
displayErrors: true,
}
);
const __dirname = path.dirname(this.filename);
compiledWrapper.call(
this.exports,
this.exports,
(id) => myRequire(id, this), // 传入父模块
this,
this.filename,
__dirname
);
this.loaded = true;
}
}
/**
* 迷你 require 实现
*/
function myRequire(id, parentModule) {
// 1. 路径解析
const resolvedPath = resolvePath(id, parentModule);
// 2. 核心模块直接返回
if (isCoreModule(resolvedPath)) {
return require(resolvedPath);
}
// 3. 缓存检查
if (MiniModule._cache[resolvedPath]) {
return MiniModule._cache[resolvedPath].exports;
}
// 4. 创建模块
const module = new MiniModule(resolvedPath);
MiniModule._cache[resolvedPath] = module;
if (parentModule) {
parentModule.children.push(module);
}
// 5. 根据扩展名加载
const ext = path.extname(resolvedPath);
if (MiniModule._extensions[ext]) {
MiniModule._extensions[ext](module);
}
return module.exports;
}
function resolvePath(id, parentModule) {
if (id.startsWith('.')) {
const dir = parentModule ? path.dirname(parentModule.filename) : __dirname;
const fullPath = path.resolve(dir, id);
// 尝试各种后缀
const exts = ['.js', '.json'];
for (const ext of exts) {
const filePath = fullPath + ext;
if (fs.existsSync(filePath)) return filePath;
}
// 尝试目录
for (const ext of exts) {
const indexPath = path.join(fullPath, 'index' + ext);
if (fs.existsSync(indexPath)) return indexPath;
}
}
return id;
}
function isCoreModule(id) {
return ['fs', 'path', 'http', 'http2', 'os', 'crypto'].includes(id);
}
// 使用迷你模块加载器
// const mod = myRequire('./my-module', module);
底层原理
Module 类的构造函数与原型
Node.js 源码中的 Module 类(lib/internal/modules/cjs/loader.js)揭示了模块系统的全貌:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Node.js 源码简化
function Module(id = '', parent) {
this.id = id; // 模块 ID(通常是完整路径)
this.path = path.dirname(id); // 模块所在目录
this.exports = {}; // 导出的对象
this.parent = parent; // 父模块
this.filename = null; // 文件完整路径
this.loaded = false; // 是否已加载完成
this.children = []; // 子模块列表
this.paths = []; // 模块搜索路径
}
// Module._resolveFilename - 路径解析
// Module._load - 加载模块(缓存检查 + 创建 + 加载 + 返回)
// Module._resolveLookupPaths - node_modules 搜索路径
// 以 node_modules 搜索为例:
// 如果文件在 /home/user/project/app.js
// 那么 require('express') 会依次查找:
// 1. /home/user/project/node_modules/express
// 2. /home/user/node_modules/express
// 3. /home/node_modules/express
// 4. /node_modules/express
require.resolve 的原理
require.resolve 只做路径解析不执行模块,它的本质是执行 Module._resolveFilename,返回解析后的完整路径。这常用于检查模块是否存在或在清除缓存前获取模块路径。
1
2
3
4
5
6
// require.resolve 的使用
const modPath = require.resolve('lodash');
console.log(modPath); // /home/user/project/node_modules/lodash/lodash.js
// 清除缓存
delete require.cache[require.resolve('./my-module')];
高频面试题解析
面试题1:exports 和 module.exports 的区别?
exports 是 module.exports 的引用,初始指向同一个对象。直接给 exports 赋值会断开这个连接,导致外部无法获取新赋值的对象。而 module.exports 才是模块真正导出的内容。
最佳实践:要么一直用 module.exports,要么一直用 exports.xxx 形式。不要混用。
面试题2:require 和 import 的区别?
| 特性 | require (CommonJS) | import (ES Module) |
|---|---|---|
| 加载时机 | 同步、运行时 | 异步、编译时 |
| 语法 | 动态,代码任意位置 | 静态,必须在文件顶部 |
| 值绑定 | 导出值的拷贝 | 导出值的动态引用 |
| 循环依赖 | 部分支持(返回不完整对象) | 支持更好(实时绑定) |
| Tree Shaking | 不支持 | 支持 |
| 文件扩展名 | .js/.json/.node | .mjs 或 package.json 配置 type: module |
面试题3:什么是模块的「双重加载」问题?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 如果一个模块同时支持 CJS 和 ESM,如何优雅处理?
// 方式一:通过 package.json 的 exports 字段
{
"exports": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
// 方式二:判断环境
// random-lib.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = { random };
} else {
// ESM 环境
export { random };
}
面试题4:如何优雅地实现模块热替换(HMR)?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 开发环境下可以清除缓存实现 HMR
function hmrReload(moduleName) {
const modulePath = require.resolve(moduleName);
// 递归清除所有关联模块的缓存
const cleanCache = (id) => {
const mod = require.cache[id];
if (!mod) return;
// 清除子模块
mod.children.forEach(child => cleanCache(child.id));
// 清除自身
delete require.cache[id];
};
cleanCache(modulePath);
// 注意:父模块持有的旧引用不会被更新
// 所以 HMR 通常需要配合模块的销毁和重建机制
return require(moduleName);
}
面试题5:__dirname 和 __filename 在 ESM 中为什么不可用?
在 ESM(ES Module)中,没有 require、module、exports、__dirname、__filename 这些变量,因为 ESM 没有 CommonJS 的包装函数。替代方法:
1
2
3
4
5
6
// 在 ESM 中获取 __dirname 和 __filename
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import.meta.url 是 ESM 提供的当前模块的 file:// URL,通过 fileURLToPath 转换为文件路径。
总结与扩展
Node.js 的 CommonJS 模块机制虽然设计的年代久远,但其核心设计——文件即模块、单例缓存、先缓存后加载——至今仍然是 Node.js 生态的基石。理解这些底层原理,能够帮助你:
- 正确使用模块系统:避免 exports 赋值陷阱
- 设计合理的模块结构:即使不存在真的循环依赖,紧密的相互引用也是设计坏味道
- 利用缓存机制:实现单例、状态共享、模块级别的初始化
- 处理兼容性问题:在 CJS/ESM 混用时代,理解两者的差异和互操作规则
扩展方向:
- ES Module:Node.js 对 ESM 的支持策略(.mjs、type: module、import() 动态导入)
- UMD/AMD:通用模块定义的历史意义及其在现代开发中的位置
- npm 依赖解析:yarn PnP、pnpm 的硬链接方案如何优化 node_modules 结构
- Bundle 工具:Webpack/Rollup/ESBuild 如何模拟 CommonJS 运行环境