Nodejs 系列五:深入 Nodejs 模块 Modules 及其源码剖析

前言

本文是笔者所总结的有关 Nodejs 基础系列之一,

javascript 从其诞生的那一刻,其初衷并不是被设计为一门模块化的语言;而为了使得 javascript 能够支撑模块化,后人做了许多的努力,通过立刻执行函数的方式,找到了一种比较可行的解决方式;有关此部分的描述参见笔者的另一篇博文 javascript 面向对象编程(二):模块 Modules;而这也是 Node.js 实现模块化在其语法上的前提;

本文同样不打算照着官网上的内容照本宣科,相关 API 参考 Nodejs Module API,笔者打算从源码层面剖析 Nodejs 的模块是如何实现的;

注明:本文为作者的原创作品,转载需注明出处;

Demo

例子

先来看这样一个非常简单的例子,之后的源码分析将会基于该例子进行讲解;

project/modules/circle.js

1
2
3
4
5
const { PI } = Math;

exports.area = (r) => PI * r ** 2;

exports.circumference = (r) => 2 * PI * r;

project/test_modules.js

1
2
3
const circle = require('./modules/circle.js');

console.log(`The area of a circle of radius 4 is ${circle.area(4)}`);

备注,在 IntelliJ IDEA 2017.03 版本调试 test_modules.js 的时候出错,详情和解决办法参考 Waiting for the debugger to disconnect…

模块

在 Node.js 中,一个 .js 文件就是一个模块,从例子中可以看到,test_modules.js 与 circle.js 都是模块,只是在上面的用例中,test_modules.js 是主模块在 Nodejs 称为 main module;在 main module 中可以通过关键字 require 导入其它的模块;而本文,笔者重点要摸清的是,Nodejs 是如何将一个 .js 文件导入称为一个模块的,又是如何通过关键字 export 将对象导出的;

源码分析

以上述的例子为例,在执行到

1
const circle = require('./modules/circle.js');

以前,Nodejs 运行环境已经将 test_modules.js 封装为了 core module 模块了,然后调用 require('./modules/circle.js') 方法,加载 circle.js 模块,该加载的逻辑涉及到了底层源码的逻辑以及 V8 引擎的逻辑,笔者将顶层的流程图 Sequence 绘制如下,

顶层 Sequence

sourcecode-analysis-modules-sequence-highlevel.png

该 Sequence 稍显复杂,主要是为了完整的描绘出各个入口,以便将来随时找到关键入口并对源码进行再次温习;不过其实归纳起来其实无非就是如下的几个核心步骤,

  1. 生成 circle 的 Module 对象
    对应流程图中 1.1.1.1: Module._load $\to$ 1.1.1.1.2.1.1: Module_extensions[extension](module, filename) 之前的步骤;
  2. 调用 V8 引擎编译 circle Module 对象,生成 javascript 可以识别的 function 对象 $\to$ compileWrapper;
    1.1.1.1.2.1.1: Module_extensions[extension](module, filename) $\to$ 1.1.1.1.2.1.1.3.2 compileWrapper = vm.runInThisContext(wrapper…)
  3. compileWrapper.call(this.exports…)
    this 就是 circle module 对象,通过 compileWrapper.call(this.exports…) 便将 circle.js 中 exports 的值赋值给了 this.exports,也就是 circle module 对象的属性 exprots;对应步骤 1.1.1.1.2.1.1.3.3 compileWrapper.call(this.exports…)
  4. 直接将 circle module.exports 对象返回;所以例子返回的 circle 对象就是 circle module.exports 对象;
    1
    const circle = require('./modules/circle.js');

后续部分的分析主要是针对上述的四个步骤依次进行,

各个步骤详细分析

生成 circle Module 对象

该部分逻辑主要对应 /core-modules/modules.js 中的 Module._load 方法的逻辑,备注,该文件在我本地的目录如下,该文件通过 IntelliJ IDEA 是不能直接搜索到的,
the path of core-module-modulejs.png

/core-modules/modules.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
Module._load = function(request, parent, isMain) {
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
}

if (isMain && experimentalModules) {
(async () => {
// loader setup
if (!ESMLoader) {
ESMLoader = new Loader();
const userLoader = process.binding('config').userLoader;
if (userLoader) {
const hooks = await ESMLoader.import(userLoader);
ESMLoader = new Loader();
ESMLoader.hook(hooks);
}
}
await ESMLoader.import(getURLFromFilePath(request).pathname);
})()
.catch((e) => {
console.error(e);
process.exit(1);
});
return;
}

var filename = Module._resolveFilename(request, parent, isMain);

var cachedModule = Module._cache[filename];
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}

if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request);
return NativeModule.require(filename);
}

// Don't call updateChildren(), Module constructor already does.
var module = new Module(filename, parent);

if (isMain) {
process.mainModule = module;
module.id = '.';
}

Module._cache[filename] = module;

tryModuleLoad(module, filename);

return module.exports;
};

代码的逻辑非常的直观,主要分为几大块,

  1. 异步加载远程文件,代码第 6 至 25 行,这里的逻辑主要是前端代码通过异步的方式加载 js 代码;这里是本地代码的加载过程,自然不会涉及到这里的逻辑;
  2. 构建 filename,代码第 27 行,这里会从 test_modules.js 的所在的相对路径中进行查找,然后返回 circle.js 的绝对路径;
  3. 从 Cache 中检查 circle.js 模块是否已经被加载过,如果已经被加载过,直接从 Cache 中返回该 module 的 exports 对象;并终止后续的操作;对应上述代码第 29 到 33 行;
  4. 如果 Cache 中并没有加载过 circle.js 模块,那么开始构建 circle Module 对象;代码 41 - 50 行;

备注,上述对 circle.js 模块的加载逻辑是通过明确的指明了 circle.js 的路径( 通过前缀 './'、'../' 或者 '/' 的方式指定 )而解析得到该文件的,但是这种方式非常的不够灵活,因为很多时候,当模块多了以后,去找到该模块所在的具体相对路径是比较麻烦的事情,因此我们需要一种更为聪明的做法,于是 Nodejs 的 Package Manager 孕育而生,我们可以直接在 test_modules.js 中使用 require('circle') 来引入 circle.js 模块,大概逻辑是,约定首先从 test_modules.js 的相对路径中的包 node_modules 中去查找,如果没有找到,便会从其上一级路径中的 node_modules 包中去查找,一般而言,对于查找某个模块的 dependencies 用的就是这样的方式,具体逻辑参考包管理器 Package Manager 小节,这里我们通过源码来窥探一下,在 Nodejs 的源码中是怎么实现的,

执行到上述代码第 50 行以后,进入 tryModuleLoad(module, filename) 方法,

1
2
3
4
5
6
7
8
9
10
11
function tryModuleLoad(module, filename) {
var threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
}
}
}

然后会调用 circle module 的 load(filename) 方法,
debug-node-modules-paths.png
从这里可以非常直观的看到,通过 Module._nodeModulePaths(path.dirname(filename)) 获得了当前模块 circle 的所有可能的 node_modules 的路径;当没有明确指定某个模块的相对路径的时候,Nodejs 是通过怎样的相对路径去查找对应的模块的了;这也同样是包管理器查找对应模块的基本逻辑;

V8 引擎编译 wrapped content

目前 circle Module 对象保存的仅仅是 circle.js 的文件路径,node moduels paths 以及 parents 相关的信息;如何将 circle.js 文件中的内容封装成 javascript 可以识别的对象呢?这部分代码的逻辑主要发生在 module.load 方法中

/core-modules/module.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
// Given a file name, pass it to the proper extension handler.
Module.prototype.load = function(filename) {
debug('load %j for module %j', filename, this.id);

assert(!this.loaded);
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));

var extension = path.extname(filename) || '.js';
if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
this.loaded = true;

if (ESMLoader) {
const url = getURLFromFilePath(filename);
const urlString = `${url}`;
const exports = this.exports;
if (ESMLoader.moduleMap.has(urlString) !== true) {
ESMLoader.moduleMap.set(
urlString,
new ModuleJob(ESMLoader, url, async () => {
const ctx = createDynamicModule(
['default'], url);
ctx.reflect.exports.default.set(exports);
return ctx;
})
);
} else {
const job = ESMLoader.moduleMap.get(urlString);
if (job.reflect)
job.reflect.exports.default.set(exports);
}
}
};

在上一小节中,我们简单的看了一下这段代码,将 paths 的 node_modules 的所有可能路径打印了出来,而现在,我们将重心放到上述代码的第 11 行,

1
Module._extensions[extension](this, filename);

真正精彩的内容就是从这行代码进入的,它会调用 Module._extensions.js(module, filename) 方法来加载 circle.js 的模块的内容( 提示,js 可以通过 ['x'] 的方式来调用某个对象的 x 属性 );

1
2
3
4
Module._extensions['.js'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(internalModule.stripBOM(content), filename);
};
  • 首先通过 fs.readFileSync(filename, 'utf8') 读取 circle.js 的文本内容;
  • 调用 module._compile(content, filename),这个方法说有多重要就有多重要了,正是这里的逻辑,使得动态加载 js 模块成为可能,所以笔者将其喻为 Nodejs Modules 的灵魂所在;

    /core-modules/module.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    Module.prototype._compile = function(content, filename) {

    content = internalModule.stripShebang(content);

    // create wrapper function
    var wrapper = Module.wrap(content);

    var compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
    });

    ...
    执行 compiledWrapped 对象的逻辑
    ...
    };
    1. 首先完成对 circle.js 的代码进行封装,将 circle.js 的代码转换成一个立即执行函数的形式,上述代码第 6 行,转换之后的 circle.js 代码为

      1
      2
      3
      4
      5
      6
      7
      8
      (function (exports, require, module, __filename, __dirname) { 

      const { PI } = Math;

      exports.area = (r) => PI * r ** 2;

      exports.circumference = (r) => 2 * PI * r;
      });

      这里完成了一次重要的转换,意味着,circle.js 在代码的形式上,已经符合 javascript 的模块化标准了;那么下面,既是需要对这段代码也就是源码中的 wrapped 内容进行编译了;

    2. 上述代码 8 - 12 行,便是对 wrapped 内容进行编译了;

      1
      2
      3
      4
      5
      var compiledWrapper = vm.runInThisContext(wrapper, {
      filename: filename,
      lineOffset: 0,
      displayErrors: true
      });

      里面的逻辑不打算深究了,主要是通过 vm.js 中的 Script 对象进行编译,编译完成以后,得到如下的结果,
      debug-vm-run-this-wrapper.png
      这样,我们便通过 V8 引擎的编译过程动态的得到了一个 circle.js 的模块对象了,让笔者想到了 Java 的动态加载逻辑,通过类似的逻辑,Class.loadClass(…) 的方式,将一段 Java 字节码动态的进行加载,然后在 JVM 中得到对应的 Class 对象;看来语言之间有太多的共通之处,不过 Javascript 的这种加载模式明显简单了许多,直接对源码进行加载,而 Java 必须先将源码转换为字节码然后才能加载,所以 javascript 代码在编译构建环境更为简单;

    余下部分代码的逻辑便是执行 compiled wrapped 对象

执行 compiled wrapped 对象

如果说上一小节的 module._compile(content, filename) 是 Nodejs 的灵魂所在,那么这小节所介绍的内容就是画龙点睛的地方了,它将告诉我们,Nodejs 是如何将模块中的对象给 exports 出来的,

/core-modules/module.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Module.prototype._compile = function(content, filename) {

...
V8 引擎编译 wrapped content
...
var dirname = path.dirname(filename);
var require = internalModule.makeRequireFunction(this);
var depth = internalModule.requireDepth;
if (depth === 0) stat.cache = new Map();
var result;
if (inspectorWrapper) {
result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
require, this, filename, dirname);
} else {
result = compiledWrapper.call(this.exports, this.exports, require, this,
filename, dirname);
}
if (depth === 0) stat.cache = null;
return result;
};

例子中的用例会直接进入代码 15 - 16 行的逻辑,回顾一下 compiledWrapper 对象,
debug-vm-run-this-wrapper.png

1
2
3
4
5
(function (exports, require, module, __filename, __dirname) { 
const { PI } = Math;
exports.area = (r) => PI * r ** 2;
exports.circumference = (r) => 2 * PI * r;
});

对,它现在在 javascript 的运行环境 V8 中就是代表一个 function 对象,因此我们可以直接通过方法 call 对其发起调用,注意这里的调用参数,

1
2
result = compiledWrapper.call(this.exports, this.exports, require, this,
filename, dirname);

可以看到,发起调用的时候总共使用了 6 个参数,上述方法调用的参数 $\to$ 方法定义的参数相关映射和关系描述如下,

  1. this.exports $\to$ this
    调用( call )的第一个参数表示的是 compiledWrapper 方法中所使用的 this, 也就是说,在调用 compiledWrapper 方法的时候,其内部的 this $\to$ module.exports,
  2. this.exports $\to$ exports
    用 this.exports 为 compileWrapper 的第一个参数 exports 进行赋值;什么意思?也就是说,compiledWrapper 方法中所使用到的对象 exports 实际上就是 module.exports 对象(这里的 module 就是 circle module),这就是画龙点睛的地方了,实际上 circle.js 中所定义的关键字 exports 在运行时刻,实际上使用的就是 circle module 对象的 exports,最后通过 module.exports 将对应的属性暴露出去,供 parent module 使用,这里就是 test_modules 模块;
  3. require $\to$ require
    这里的 require 就是 circle module 的 require 对象,该对象是在上述代码第 7 行中所创建的,构建出专属于 circle 模块的 require 对象,这点非常的重要,如果 circle.js 中有 require(‘xxx’) 其它的包 xxx,那么会从 circle.js 的相对路径中去查找;
  4. this $\to$ module
    this 赋值给 module,这里的 this 就是 circle module;所以这里要特别注意的是,当在撰写 circle.js 的代码的时候 module 表示的就是 circle module,所以在代码中 module.exports $\iff$ this.exports $\iff$ exports,这三者是等价的;
  5. 其它的参数略过

注意,circle.js 中没有定义任何的 return 语句,所以也就没有任何的返回,所以上述代码第 19 行返回的是 null;那么最后一个问题就是 test_module.js 中,通过 require 调用返回的是什么?

1
const circle = require('./modules/circle.js');

参看下一小节内容返回 module.exports

返回 module.exports

当上述的过程执行完成以后,便会跳转回 Module._load 方法,执行其余下的逻辑,

/core-modules/modules.js

1
2
3
4
5
6
7
8
Module._load = function(request, parent, isMain) {

....

tryModuleLoad(module, filename);

return module.exports;
};

可以看到,最后返回给 test_modules.js 的就是 module.exports 对象,这里的 module $\to$ circle module;所以 test_module.js 中通过 require 返回的就是 circle module 的 exports 对象,

1
const circle = require('./modules/circle.js');

而该 exports 对象 circle 中保存了通过 circle 模块暴露出来的所有属性和方法对象;因此当执行到,

1
console.log(`The area of a circle of radius 4 is ${circle.area(4)}`);

调用的就是 circle 模块中的 area 方法;输出,

1
The area of a circle of radius 4 is 50.26548245743669

特性

包管理器 Package Manager

包查找的逻辑

refernece: https://nodejs.org/dist/latest-v8.x/docs/api/modules.html#modules_loading_from_node_modules_folders

If the module identifier passed to require() is not a core module, and does not begin with ‘/‘, ‘../‘, or ‘./‘, then Node.js starts at the parent directory of the current module, and adds /node_modules, and attempts to load the module from that location. Node will not append node_modules to a path already ending in node_modules.

如果当通过 require() 加载 modules 的时候不是内核模块,或者不是以 '/', '../', or './' 的路径方式;那么 Node.js 开始从其 parent 的路径中的 /node_modules 目录中去找该模块,相关的寻找逻辑如下,

For example, if the file at ‘/home/ry/projects/foo.js’ called require(‘bar.js’), then Node.js would look in the following locations, in this order:

  • /home/ry/projects/node_modules/bar.js
  • /home/ry/node_modules/bar.js
  • /home/node_modules/bar.js
  • /node_modules/bar.js

上述逻辑在笔者的源码分析生成 circle Module 对象 小节中有所涉及;

Folders as Modules( package.json 的由来 )

References: https://nodejs.org/dist/latest-v8.x/docs/api/modules.html#modules_folders_as_modules

可以通过 package.json 来组织某个模块需要的包路径,这样,在每次通过 require() 加载该模块的时候,既不需要指定模块的具体路径(但相对路径仍然是需要的),连文件的后缀 .js 也可以被省略;

比如,我们有一个主模块 foo,假设它的相对路径是 foo/,然后我们有一个子模块 bar,它的相对路径为 foo/bar/,通过 bar 模块的 package.json 使得 foo 可以非常方便的引用 bar,bar 模块的 package.json 配置如下,

1
2
{ "name" : "bar-lib",
"main" : "./lib/bar-start.js" }

注意,package.json 中的 name 不一定要与子模块的路径名 bar/ 相同,且 package.json 文件必须放置在 foo/bar/ 路径下;上面的配置表示,当在 foo 模块通过

1
require('./bar')

来加载 bar 模块的时候,会主动去加载 bar 模块的入口文件也就是由 main 参数所指定的文件 ./lib/bar-start.js,若该文件不存在,则会报错,

1
Error: Cannot find module 'bar-lib'

要注意的一点是,在 foo 模块中,是通过 bar 模块的包名来进行加载的,具体的形式就是 ./bar,这也是为什么官网上将其命名为 Folder as Modules 的原因了;

下面来看一些默认的行为,

如果在子模块 bar 中没有定义 package.json,当仍然在 foo 模块中使用包名的方式来加载 bar 模块,如下,

1
require('./bar')

那么会是如何的一种解析和加载的方式呢?Node.js 会默认去加载 ./bar 路径下的 index.js 和 inde.node 文件,如下所述,

  • ./bar/index.js
  • ./bar/index.node

当然,若都没有找到,则会报错;Cannot find the module ‘./bar’

NPM

由此可知,NPM 要做的事情就非常的简单了,就是帮助你非常方便的生成 package.json 文件,而 package.json 就类似于 maven 的 pom.xml;

Globals

Referneces: https://nodejs.org/dist/latest-v8.x/docs/api/globals.html

官网上解释的很清楚了,虽然定义为 Globals,但其实仍然是每个 Module 各自作用范围内的一些通用的属性和方法而已,

These objects are available in all modules. The following variables may appear to be global but are not. They exist only in the scope of modules,

说白了,这些 Globals 属性就是 Module 中的属性和方法,如下,

  • __dirname
    模块的绝对 package 路径
  • __filename
    模块的文件名,比如 circle.js
  • exports
    该模块所暴露出来的属性和方法对象
  • module
  • require()

References

Module Require: http://fredkschott.com/post/2014/06/require-and-the-module-system/