2.2 Node的模块实现
好,我们深入第二章的第二小节:2.2 Node的模块实现!
这一小节是第二章的核心部分,朴灵作者详细剖析了Node.js如何从源码层面实现CommonJS的require机制。整个过程分为三个关键步骤:优先从缓存加载、路径分析和文件定位、模块编译。理解这里,你就知道为什么require这么高效、为什么有缓存、为什么循环依赖不会死锁等“黑魔法”。
下面是这一小节的子结构(原书划分):
- 2.2.1 优先从缓存加载
- 2.2.2 路径分析和文件定位
- 2.2.3 模块编译
为了让你更直观,我附上了一些来自读者笔记和网上分享的原书页面截图、模块加载流程图、包裹函数示意图等(这些是常见可视化辅助,很多开发者读书时都会截图或画图)。
详细讲解
2.2.1 优先从缓存加载
这是require最聪明的设计:模块只执行一次,后续直接返回缓存的exports对象。
- 原理:Node用
Module._cache(一个对象,以模块绝对路径为key)缓存已加载模块。 - 第一次require('foo'):加载、执行、缓存module.exports。
- 第二次require('foo'):直接从缓存取,返回同一个对象引用。
示例代码(书中有类似演示):
// a.js
console.log('a.js 执行了');
exports.done = false;
// b.js
var a = require('./a');
console.log('b.js 中,a.done =', a.done);
a.done = true;
console.log('b.js 中,修改后 a.done =', a.done);
// main.js
var a = require('./a');
var b = require('./b');
console.log('main.js 中,a.done =', a.done);
输出:
a.js 执行了 // 只打印一次!
b.js 中,a.done = false
b.js 中,修改后 a.done = true
main.js 中,a.done = true
关键点:
- 模块代码只执行一次,避免副作用重复。
- 导出的是对象引用,修改会共享(这是循环依赖能工作的基础:先导出空对象{})。
2.2.2 路径分析和文件定位
require的参数(模块标识符)可能是核心模块、相对路径、绝对路径或包名。Node按优先级分析:
- 核心模块(如'fs'、'http'):直接从内置加载,最快。
-
文件模块:
- 绝对路径:直接定位。
- 相对路径(./ 或 ../):从调用者目录解析。
- 无扩展名:依次尝试 .js → .json → .node(C++扩展)。
-
包模块(如require('express')):
- 在当前目录的node_modules找。
- 找不到?向上级目录逐级查找,直到根目录。
- 找到目录后,读package.json的"main"字段(默认index.js)。
这个过程在源码的Module._resolveFilename函数实现。书里强调:这是NPM能工作的基础!
2.2.3 模块编译
找到文件后,Node不直接执行,而是包裹成一个函数运行,创造独立作用域。
- 对于.js文件:读取内容,前面加
(function (exports, require, module, __filename, __dirname) {,后面加});,然后用vm.runInThisContext执行。 - 这五个参数就是模块的“私有空间”:
- exports:导出对象(module.exports的引用)。
- require:当前模块的require函数。
- module:模块对象本身。
- __filename / __dirname:当前文件路径和目录。
示例(书中原码简化版): 你的math.js内容是:
exports.add = (a, b) => a + b;
实际执行的是:
(function (exports, require, module, __filename, __dirname) {
exports.add = (a, b) => a + b;
// ... 你的代码
});
- .json文件:JSON.parse后赋值给module.exports。
- .node文件:用dlopen加载C++扩展。
编译后,module.exports缓存起来,返回给调用者。