1

module 在 nodejs 里是一个非常核心的内容,本文通过结合 nodejs 的源码简单介绍 nodejs 中模块的加载方式和缓存机制。如果有理解错误的地方,请及时提醒纠正。

ppt 地址:http://47.93.21.106/sharing/m...

CommonJS

提到 nodejs 中的模块,就不能不提到 CommonJS。大部分人应该都知道 nodejs 的模块规范是基于 CommonJS 的,但其实 CommonJS 不仅仅定义了关于模块的规范,完整的规范在这里:CommonJS。内容不多,感兴趣的同学可以浏览一下。当然重点是在 模块 这一章,如果仔细读一下 CommonJS 中关于模块的规定,可以发现和 node 中的模块使用是非常吻合的。

Contract

CommonJS 中关于模块的规定主要有三点:

  • Require

    模块引入的方式和行为,涉及到常用的 `require()`。
    
  • Module Context

    模块的上下文环境,涉及到 `module` 和 `exports`。
    
  • Module Identifiers

    模块的标识,主要用于模块的引入
    

Usage

在 node.js 里使用模块的方式很简单,一般我们都是这么用的:

// format.js
const moment = require('moment');
/* 格式化时间戳 */
exports.formatDate = function (timestamp) {
    return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
}

上面是一个 format.js 文件,内容比较简单,引入了 moment 模块,并导出了一个格式化时间的方法供其他模块使用。

但是大家有没有考虑过,这里的 requireexports 是在哪里定义的,为什么我们可以直接拿来使用呢?

实际上,nodejs 加载文件的时候,会在文件头尾分别添加一段代码:

  • 头部添加

  (function (exports, require, module, filename, dirname) {
  • 尾部添加

  });

最后处理成了一个函数,然后才进行模块的加载:

(function (exports, require, module, __filename, __dirname) {
    // format.js
    const moment = require('moment');
    /* 格式化时间戳 */
    exports.formatDate = function (timestamp) {
        return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
    }
});

所以 exports, require, module 其实都是在调用这个函数的时候传进来的了。

这里还有两个比较细微的点,也是在很多面试题里面会出现的

  • 通过 varletconst 定义的变量变成了局部变量;没有通过关键字声明的变量会泄露到全局

  • exports 是一个形参,改变 exports 的引用不会起作用

第一点是作用域的问题,第二点可以问到 js 的参数传递是值传递还是引用传递。

证明

当然,如果只是这样讲,好像只是我的一面之词,怎么证明 nodejs 确实是这样包装的呢,这里可以用两个例子来证明:

➜  echo 'dvaduke' > bad.js
➜  node bad.js
/Users/sunhengzhe/Documents/learn/node/modules/demos/bad.js:1
(function (exports, require, module, __filename, __dirname) { dvaduke
                                                              ^

ReferenceError: dvaduke is not defined
    at Object.<anonymous> (/Users/sunhengzhe/Documents/learn/node/modules/demos/bad.js:1:63)
    at Module._compile (module.js:569:30)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:503:32)
    at tryModuleLoad (module.js:466:12)
    at Function.Module._load (module.js:458:3)
    at Function.Module.runMain (module.js:605:10)
    at startup (bootstrap_node.js:158:16)
    at bootstrap_node.js:575:3

我在 bad.js 里面随便输入了一个单词,然后运行这个文件,可以看到运行结果会抛出异常。在异常信息里面我们会惊讶地发现 node 把那行函数头给打印出来了,而在 bad.js 里面是只有那个单词的。

➜  echo 'console.log(arguments)' > arguments.js
➜  node arguments.js
{ '0': {},
  '1':
   { [Function: require]
     resolve: [Function: resolve],
     main:
      Module {
        id: '.',
        exports: {},
        parent: null,
        filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js',
        loaded: false,
        children: [],
        paths: [Array] },
     extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
     cache: { '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js': [Object] } },
  '2':
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js',
     loaded: false,
     children: [],
     paths:
      [ '/Users/sunhengzhe/Documents/learn/node/modules/demos/node_modules',
        '/Users/sunhengzhe/Documents/learn/node/modules/node_modules',
        '/Users/sunhengzhe/Documents/learn/node/node_modules',
        '/Users/sunhengzhe/Documents/learn/node_modules',
        '/Users/sunhengzhe/Documents/node_modules',
        '/Users/sunhengzhe/node_modules',
        '/Users/node_modules',
        '/node_modules' ] },
  '3': '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js',
  '4': '/Users/sunhengzhe/Documents/learn/node/modules/demos' }

在 arguments.js 这个文件里打印出 argumens 这个参数,我们知道 arguments 是函数的参数,那么打印结果可以很好的说明 node 往函数里传入了什么参数:第一个是 exports,现在当然是空,第二个是 require,是一个函数,第三个是 module 对象,还有两个分别是 __filename__dirname

源码

发现这个地方之后我相信大家都会对 nodejs 的源码感兴趣,而 nodejs 本身是开源的,我们可以在 github 上找到 nodejs 的源码:node

实际上包装模块的代码就在 /lib/module.js 里面:

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
  });
  
  // ...
}

_compile 函数是编译 nodejs 文件会执行的方法,函数中的 content 就是我们文件中的内容,可以看到调用了一个 Module.wrap 方法,那么 Module.wrap 做了什么呢?这里需要找到另一个文件,包含内置模块定义的 /lib/internal/bootstrap_node.js,里面有对 wrap 的操作:

NativeModule.wrap = function(script) {
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

确实是前面说到的,添加函数头尾的内容。

彩蛋?

其实知道这个处理之后,我们可以开一些奇怪的脑洞,比如写一段好像会报错的文件:

// inject.js
});

(function () {
    console.log('amazing');

这个文件看起来没头没尾,但是经过 nodejs 的包装后,是可以运行的,会打印出 amazing,看起来很有意思。

Core

上面只是带大家看了一下 module.js 里的一小段代码,实际上如果要搞明白 nodejs 模块运作的机制,有三个文件是比较核心的:

  • /lib/module.js 加载非内置模块

  • /lib/internal/module.js 提供一些相关方法

  • /lib/internal/bootstrap_node 定义了加载内置模块的 NativeModule,同时这也是 node 的入口文件

我们知道 node 的底层是 C 语言编写的,node 运行是,会调用 node.cc 这个文件,然后会调用 bootstrap_node 文件,在 bootstrap_node 中,会有一个 NativeModule 来加载 node 的内置模块,包括 module.js,然后通过 module.js 加载非内置模块,比如用户自定义的模块。(所以说模块是多么基础)

调用关系如下:

invoking

Module

下面重点介绍一下 module。在 nodejs 里面,通常一个文件就代表了一个模块,而 module 这个对象就代表了当前这个模块。我们可以尝试打印一下 module:

echo "console.log(module)" > print-module.js
node print-module.js

打印结果如下:

Module {
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/sunhengzhe/Documents/learn/node/modules/demo-1.js',
  loaded: false,
  children: [],
  paths:
   [ '/Users/sunhengzhe/Documents/learn/node/modules/node_modules',
     '/Users/sunhengzhe/Documents/learn/node/node_modules',
     '/Users/sunhengzhe/Documents/learn/node_modules',
     '/Users/sunhengzhe/Documents/node_modules',
     '/Users/sunhengzhe/node_modules',
     '/Users/node_modules',
     '/node_modules' ] }

可以看到 module 这个对象有很多属性,exports 我们先不说了,它就是这个模块需要导出的内容。filename 也不说了,文件的路径名。paths 很明显,是当前文件一直到根路径的所有 node_modules 路径,查找第三方模块时会用到它。我们下面介绍一下 id、parent、children 和 loaded。

module.id

在 nodejs 里面,模块的 id 分两种情况,一种是当这个模块是入口文件时,此时模块的 id 为 .,另一种当模块不是入口文件时,此时模块的 id 为模块的文件路径。

举个例子,当文件是入口文件时:

➜  echo 'console.log(module.id)' > demo-1-single-file.js
➜  node demo-1-single-file.js
.

此时 id 为 .

当文件不是入口文件时:

➜  cat demo-2-require-other-file.js
const other = require('./demo-1-single-file');
console.log('self id:', module.id);
➜  node demo-2-require-other-file.js
/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-1-module-id/demo-1-single-file.js
self id: .

运行 demo-2-require-other-file.js,首先打印出 demo-1-single-file 的内容,可以发现此时 demo-1-single-file 的 id 是它的文件名:因为它现在不是入口文件了。而作为入口文件的 demo-2-require-other-file.js 的 id 变成了 .

module.parent & module.children

这两个含义很明确,是模块的调用方和被调用方。

如果我们直接打印一个入口文件的 module,结果如下:

➜  echo 'console.log(module)' > demo-1-single-file.js
➜  node demo-1-single-file.js
Module {
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
  loaded: false,
  children: [],
  paths:
   [...] }

篇幅限制,就不显示 paths 了。可以看到 parent 为 null:因为没有人调用它;children 为空:因为它没有调用别的模块。那么我们再新建一个文件引用一下这个模块:

➜  cat demo-2-require-other-file.js
require('./demo-1-single-file');
console.log(module);
➜  node demo-2-require-other-file.js
Module {
  id: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
  exports: {},
  parent:
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-2-require-other-file.js',
     loaded: false,
     children: [ [Circular] ],
     paths:
      [...] },
  filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
  loaded: false,
  children: [],
  paths:
   [...] }
------------------------
Module {
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-2-require-other-file.js',
  loaded: false,
  children:
   [ Module {
       id: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
       exports: {},
       parent: [Circular],
       filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
       loaded: true,
       children: [],
       paths: [Array] } ],
  paths:
   [...] }

上面输出了两个 module,为了方便阅读,我用分割线分隔了一下。第一个 module 是 demo-1-single-file 打印出来的,它的 parent 现在有值了,因为 demo-2-require-other-file.js 引用它了。它的 children 依旧是空,毕竟它没有引用别人。

而 demo-2-require-other-file.js 的 parent 为 null,children 有值了,可以看到就是 demo-1-single-file。

注意里面还出现了 [Circular],因为 demo-1-single-file 的 parent 的 children 就是它自己,为了防止循环输出,nodejs 在这里省略掉了,应该很好理解。

module.loaded

loaded 从字面意思上也好理解,代表这个模块是否已经加载完了。但我们会发现在上面的所有输出中,loaded 都是 false。

➜  cat demo-1-print-sync.js
console.log(module.loaded);
➜  node demo-1-print-sync.js
false

我们可以在 node 的下一个 tick 里面去输出,就能得到正确的 loaded 的值了:

➜  cat demo-2-print-next-tick.js
setImmediate(function () {
    console.log(module.loaded);
});
➜  node demo-2-print-next-tick.js
true

模块的加载

模块到底是如何加载的?在 /lib/module.js 里,可以找到模块加载的函数 _load,这里 node 的注释很好地描述了加载的次序:

// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call `NativeModule.require()` with the
//    filename and return the result.
// 3. Otherwise, create a new module for the file and save it to the cache.
//    Then have it load  the file contents before returning its exports
//    object.
Module._load = function(request, parent, isMain) {
//...

翻译一下,大概就是这个流程:

  1. 有缓存(二次加载)

直接读取缓存内容

  1. 无缓存(首次加载或清空缓存之后)

    1. 路径分析

    2. 文件定位

    3. 编译执行

无缓存

首先看一下无缓存的情况。nodejs 首先需要对文件进行定位,找到文件才能进行加载,其实所有的细节都隐藏在了 require 方法里面,我们调用 require,nodejs 返回模块对象,那么 require 是怎么找到我们需要的模块的呢?

简单来讲,大致是:

  • 尝试加载核心模块

  • 尝试以文件形式加载

    • X

    • X.js

    • X.json

    • X.node

  • 尝试作为目录查找,寻找 package.json 文件,尝试加载 main 字段指定的文件

  • 尝试作为目录查找,寻找 index.js、index.json、index.node

  • 尝试作为第三方模块进行加载

  • 抛出异常

这里涉及的代码细节比较复杂,建议先直接阅读 nodejs 的官方文档,文档对定位的顺序描述的非常详细:https://nodejs.org/dist/latest-v8.x/docs/api/modules.html#modules_all_together

缓存

如果有缓存的话,会直接返回缓存内容。比如这里有个文件,内容就是打印一行星号:

➜  cat print.js
console.log('********');

如果我们在另一个文件里引入这个文件两次,那么会输出两行星号吗?

➜  demo-5-cache cat demo-1-just-print-multiply.js
require('./print');
require('./print');
➜  demo-5-cache node demo-1-just-print-multiply.js
********

答案是不会的,因为第一次 require 后,nodejs 会把文件缓存起来,第二次 require 直接取得缓存的内容,参考 /lib/module.js 中的代码:

Module._load = function(request, parent, isMain) {
  var cachedModule = Module._cache[filename];
  if (cachedModule) {
      // 更新 parent 的 children
      updateChildren(parent, cachedModule, true);
      return cachedModule.exports;
  }

  //...

  Module._cache[filename] = module;

  tryModuleLoad(module, filename);

  //...
}

清空缓存

那么,如果我们要清空缓存,势必需要清除 Module._cache 中的内容。然而在文件里,我们只能拿到 module 对象,拿不到 Module 类:

➜  cat demo-2-get-Module.js
console.log(Module)
➜  node demo-2-get-Module.js
/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-5-cache/demo-2-get-Module.js:1
(function (exports, require, module, __filename, __dirname) { console.log(Module)
                                                                          ^

ReferenceError: Module is not defined
    at Object.<anonymous> (/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-5-cache/demo-2-get-Module.js:1:75)
    at Module._compile (module.js:569:30)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:503:32)
    at tryModuleLoad (module.js:466:12)
    at Function.Module._load (module.js:458:3)
    at Function.Module.runMain (module.js:605:10)
    at startup (bootstrap_node.js:158:16)
    at bootstrap_node.js:575:3

但是是否没有办法去清空缓存了呢?当然是有的。这里我们先看 require 是怎么来的。

之前提到,require 是通过函数参数的方式传入模块的,那么我们可以看一下,传入的 require 的到底是什么?回到 _compile 方法:

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
  });
  
  // ...
  
  var require = internalModule.makeRequireFunction(this);
  
  result = compiledWrapper.call(this.exports, this.exports, require, this,
                                  filename, dirname);
  // ...
  
  return result;
}

简化后的代码如上,函数内容经过包装之后生成了一个新的函数 compiledWrapper,然后把一些参数传了进去。我们可以看到 require 是从一个 makeRequireFunction 的函数中生成的。

makeRequireFunction 函数是在 /lib/internal/module.js 中定义的,看下代码:

function makeRequireFunction(mod) {
  const Module = mod.constructor;

  function require(path) {
    try {
      exports.requireDepth += 1;
      return mod.require(path);
    } finally {
      exports.requireDepth -= 1;
    }
  }

  function resolve(request) {
    return Module._resolveFilename(request, mod);
  }

  require.resolve = resolve;

  require.main = process.mainModule;

  // Enable support to add extra extension types.
  require.extensions = Module._extensions;

  require.cache = Module._cache;

  return require;
}

如果我们直接打印 require,其实就和这里面定义的 require 是一样的:

➜  cat demo-1-require.js
console.log(require.toString());
➜  node demo-1-require.js
function require(path) {
    try {
      exports.requireDepth += 1;
      return mod.require(path);
    } finally {
      exports.requireDepth -= 1;
    }
  }

其实这个 require 也没有做什么事情,又调用了 mod 的 require,而 mod 是通过 makeRequireFunction 传进来的,传入的是 this,所以归根到底,require 是 module 原型上的方法,也就是 module.prototype.require,参考 /lib/module.js 中的代码。

当然这里我们先不用追究 require 的实现方式,而是注意到 makeRequireFunction 中对 require 的定义,我们可以发现一行关于 _cache 的代码:

function makeRequireFunction(mod) {
  // ...

  function require(path) {}

  require.cache = Module._cache;
  
  //..

  return require;
}

所以 nodejs 很贴心地,把 Module._cache 返回给我们了,其实只要清空 require.cache 即可。而根据上面的代码,Module._cache 是通过 filename 来作为缓存的 key 的,所以我们只需要清空模块对应的文件名。

针对上面提到的例子,清空 print.js 的缓存:

require('./print');
// delete cache
delete require.cache[require.resolve('./print')];
require('./print');

然后再打印一下

➜  node demo-1-just-print-multiply.js
********
********

就是两行星号了。

这里用到了 require 的一个 resolve 方法,它和直接调用 require 方法比较像,都能找到模块的绝对路径名,但直接 require 还会加载模块,而 require.resolve() 只会找到文件名并返回。所以这里利用文件名将 cache 里对应的内容删除了。

调试 nodejs 的源码

本文介绍了一些 nodejs 中的源码内容,在学习 nodejs 的过程中,如果想查看 nodejs 的源码(我觉得这是一个必备的过程),那么就需要去调试源码,打几个 log 看一下是不是和你预期的一致,这里说一下怎么调试 nodejs 的源码。

  1. 下载 node 源码 git@github.com:nodejs/node.git

  2. 进入源码目录执行 ./configure & make -j

  3. 上一步之后会在 ${源码目录}/out/Release/node 里生成一个执行文件,将这个文件作为你的 node 执行文件。

  4. 每次修改源码后重新执行 make 命令。

比如修改代码之后,运行 make,然后这样运行文件即可:

➜ /Users/sunhengzhe/Documents/project/source-code/node/out/Release/node demo-1-single-file.js

参考

  1. 朴灵《深入浅出 Node.js》

  2. Node.js Documentation

  3. requiring-modules-in-node-js-everything-you-need-to-know


时间被海绵吃了
1.6k 声望93 粉丝

learning and coding