阅读视图

发现新文章,点击刷新页面。

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按优先级分析:

  1. 核心模块(如'fs'、'http'):直接从内置加载,最快。
  2. 文件模块
    • 绝对路径:直接定位。
    • 相对路径(./ 或 ../):从调用者目录解析。
    • 无扩展名:依次尝试 .js → .json → .node(C++扩展)。
  3. 包模块(如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缓存起来,返回给调用者。

❌