文章

模块机制深度解析

模块机制深度解析

一句话概括

Node.js 的模块机制基于 CommonJS 规范实现,通过 require 函数加载模块、module.exports 导出接口,结合模块缓存和循环依赖处理策略,构成了 Node.js 应用的组织基础,本文将深入剖析 require 的加载流程、模块包装、缓存机制以及循环依赖的解决原理。

背景与意义

在 JavaScript 诞生之初,这门语言并没有模块系统的概念。随着前端应用规模的扩大,「全局变量污染」「依赖管理混乱」「代码组织困难」等问题日益突出。2009 年,CommonJS 规范诞生,为 JavaScript 提供了服务器端模块化的标准。

同年,Ryan Dahl 发布了 Node.js,并将 CommonJS 作为其内置模块系统。从此,JavaScript 有了合理组织代码的方式:每个文件就是一个模块,暴露什么由自己决定,需要什么就引入什么。

理解 Node.js 模块机制,不仅能帮你正确使用 requiremodule.exports,更能帮你理解 Node.js 生态的底层设计哲学——为什么 npm 能成为世界上最大的包管理器?为什么循环依赖有时能工作而有时不行?模块缓存如何影响你的应用行为?

概念与定义

CommonJS:一个为 JavaScript 定义模块化标准规范的社区项目,Node.js 采用了其中的模块规范。

模块(Module):在 Node.js 中,每个文件都被视为一个独立的模块。模块内部的变量、函数、类默认对外不可见。

require:加载模块的函数,接受模块标识符作为参数,返回该模块导出的对象。

module.exports:每个模块都有一个 module 对象,其中 module.exports 是该模块对外暴露的接口。

exportsmodule.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 包装在一个函数中,这就是为什么模块内可以使用 requiremoduleexports__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 的区别?

exportsmodule.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)中,没有 requiremoduleexports__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 生态的基石。理解这些底层原理,能够帮助你:

  1. 正确使用模块系统:避免 exports 赋值陷阱
  2. 设计合理的模块结构:即使不存在真的循环依赖,紧密的相互引用也是设计坏味道
  3. 利用缓存机制:实现单例、状态共享、模块级别的初始化
  4. 处理兼容性问题:在 CJS/ESM 混用时代,理解两者的差异和互操作规则

扩展方向

  • ES Module:Node.js 对 ESM 的支持策略(.mjs、type: module、import() 动态导入)
  • UMD/AMD:通用模块定义的历史意义及其在现代开发中的位置
  • npm 依赖解析:yarn PnP、pnpm 的硬链接方案如何优化 node_modules 结构
  • Bundle 工具:Webpack/Rollup/ESBuild 如何模拟 CommonJS 运行环境
本文由作者按照 CC BY 4.0 进行授权