Node.js 中的模块

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

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

CommonJS

提到 nodejs 中的模块,就不能不提到 CommonJS。大部分人应该都知道 nodejs 的模块规范是基于 CommonJS 的,但其实 CommonJS 不只仅定义了关于模块的规范,完整的规范在这里:CommonJS。内容很少,感兴趣的同窗能够浏览一下。固然重点是在 模块 这一章,若是仔细读一下 CommonJS 中关于模块的规定,能够发现和 node 中的模块使用是很是吻合的。java

Contract

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

  • Requiregit

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

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

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

Usage

在 node.js 里使用模块的方式很简单,通常咱们都是这么用的:json

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

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

可是你们有没有考虑过,这里的 requireexports 是在哪里定义的,为何咱们能够直接拿来使用呢?api

实际上,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

相关文章
相关标签/搜索