×

第二章 模块机制

96
Air_cc
2017.02.22 17:00* 字数 1943

之前 ECMAScript 的问题:

没有模块系统,标准库较少(如文件系统等缺失API),没有标准接口,无包管理系统

CommonJS

CommonJS 规范涵盖:模块、二进制、Buffer、字符编码、I/O流、进程环境、文件系统、套接字、单元测试、Web 服务器网关接口、包管理。

Node借鉴CommonJS的Modules规范实现了一套模块系统。

Node 与浏览器以及W3C组织、CommonJS组织、ECMAScript之间的关系

CommonJS 的模块规范

包括:模块引用、模块定义、模块标识3部分。

  • 模块引用

示例:

var math = require('math');
  • 模块定义

被引用模块的上下文提供 exports 对象用于导出当前模块的方法与变量,这是该模块唯一的导出出口。在模块中还包括一个标识模块本身的 module 对象。exports 正是 module 对象的一个属性的引用。

示例:

// math.js
let count = 0;
exports.incr = function () {
  count += 1;
  return count;
};

// program.js
var incr = require('match');
console.log(incr()); // 1
console.log(incr()); // 2
  • 模块标识

其实就是传递给 require() 方法的参数,它必须是符合小驼峰命名的字符串,或者一个相对路径,或者一个绝对路径。

模块加载的具体实现

Node 引入一个模块包括如下步骤:

  1. 路径分析
  2. 文件定位
  3. 编译执行

Node 中的模块分为:核心模块(Node本身提供的模块)、文件模块(用户编写的模块)。

  • 核心模块在Node源码编译过程中被编译成二进制执行文件。在Node进程启动过程中部分核心模块会直接被加载进内存,故这部分核心模块在引入时,不需文件定位和编译执行,且在路径分析中优先判断,所以它们的加载速度是最快的。

  • 文件模块是在运行时动态加载,需要完整经历模块引入的流程,加载速度较慢。

模块加载步骤:

优先从缓存加载

Node对引入过的模块都会进行缓存(缓存的是编译和执行后的对象),以避免二次引入时的开销。所以对于二次加载的模块,Node会优先从缓存中引入。另外核心模块的缓存检测优先于文件模块。

路径分析

即对模块标识符的分析。

  • 核心模块:如 httpfs 等,优先加载,不可以加载与核心模块标识符相同的自定义模块。
  • 路径形式的文件模块:相对路径或绝对路径,分析文件模块时,require() 方法会将该路径转为真实路径,并以此为真实路径作为该模块的索引来缓存被编译执行后的模块对象。
  • 自定义模块:不是核心模块也不以路径作为标识符的模块,可以是一个包或者文件,这类模块加载最慢。

Node 自定义模块的查找策略(类似于JavaScript的原型链或者作用域的查找方式):

  • 当前文件目录下的 node_modules 目录
  • 父目录下的 node_modules 目录
  • 逐级向上递归查找,直至根目录下的 node_modules 目录

文件定位

  • 文件拓展名分析:模块的标识符可不包含文件拓展名。Node 会按照 .js、.json、.node 的次序不足拓展名一次尝试。这里需注意的是:在尝试过程中,会调用 fs 模块同步阻塞式的判断文件是否存在,故在调用 .json .node 文件是最好带上拓展名,已提升文件定位的速度。
  • 目录分析:在分析模块标识符的过程中,发现是一个目录时会以包的形式来处理。 Node 会查找并解析 package.json 文件,从其 main 属性来定位该模块入口文件。如果无法通过 main 属性获取入口文件,则Node 默认以 index 作为入口文件名,依次查找 index.js、index.json、index.node;若未找到则进入下一个查找路径继续上述步骤,直至路径遍历完毕仍未找到抛出异常。

模块编译(文件模块)

定位到模块后便会编译执行该模块。每个文件模块都是一个对象,其定义如下:

function Module(id, parent) {
  this.id = id;
  this.exports = {};
  if (parent && parent.chidren) {
     parent.children.push(this);
  }

  this.filename = null;
  this.loaded = false;
  this.children = [];
}

Node 对于不同类型的文件模块执行不同的加载方法。

  • .js 文件:
    fs 模块同步读取文件内容,wrap 模块内容,vm 编译模块内容返回包含上下文的function,传入之前 wrap 的参数,执行该函数。

wrap 内容:

(function (exports, require, module, __filename, __dirname) {
  /* 实际文件内容 */
})
  • .node 文件
    Node 调用 process.dlopen() 方法来加载和执行 .node 文件。dlopen() 方法通过 libuv 兼容层封装了 Windows 和 *nix 平台下的不同实现。实际上,.node模块并不需要编译,它已经是C/C++模块编译生成好的二进制文件了,执行的过程中,会将模块的 exports 对象与.node 模块产生联系,返回给调用者。

  • .json 文件
    调用 fs 模块通过读取文件内容,调用 JSON.parse() 解析,将其赋给模块对象的exports。

Module._extensions['.json'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  try {
    module.exports = JSON.parse(internalModule.stripBOM(content));
  } catch (err) {
    err.message = filename + ': ' + err.message;
    throw err;
  }
};

注:文件模块加载的具体代码实现可以参考这里

核心模块包括:c/c++ 编写的模块、js 编写的模块

JavaScript 核心模块的编译过程

  1. 通过 v8 附带的 js2c.py 工具将 js 代码以字符串的形式存储到 node 的命名空间中;
  2. 通过 process.binding('natives') 取出代码,存放到 NativeModule._cache 对象中;
  3. 当 require() 方法调用时,从 NativeModule._cache 中取出对应 id(模块标识符) 的代码,通过 NativeModule.compile() 方法 wrap、执行相应的代码。

C/C++ 核心模块的编译过程

这里分为:纯 C/C++ 编写的模块、核心部分由 C/C++ 编写,对外封装由 JS 完成的模块。其中纯 C/C++ 编写的部分称为内建模块

内建模块

Node 内建模块的结构体定义:

struct node_module {
  int nm_version;
  unsigned int nm_flags;
  void* nm_dso_handle;
  const char* nm_filename;
  node::addon_register_func nm_register_func;
  node::addon_context_register_func nm_context_register_func;
  const char* nm_modname;
  void* nm_priv;
  struct node_module* nm_link;
};

可通过 get_builtin_module() 方法取出该模块。内建模块在编译 Node 源代码时会被编译成二进制文件,在 Node 进程启动时,直接加载进内存中,可直接被外部(核心模块、C/C++拓展模块-但不建议直接调用)调用。这里同 JS 核心文件加载一样通过 process.binding() 方法加载,但它将 exports 对象缓存到 binding_cache_object 中。

os 原生模块引入流程

C/C++ 拓展模块

C/C++ 拓展模块的编写基本同内建模块一致,可借助 node-gyp 进行编译,只是不需要注册到 node builtin 模块中,而是通过 process.dlopen() 动态加载进来。由于 .node 文件已是编译后的二进制文件,所以被加载进来后不需编译直接执行,相较于 JavaScript 模块会略快一点。

.node 文件引入流程

包与 NPM

包实际被打包成一个存档文件(zip 或 tar.gz 格式)。CommonJS 规范的包结构:

  • package.json: 包描述文件
  • bin: 可执行文件
  • lib: JavaScript 文件
  • doc: 项目文档
  • test: 单元测试

NPM

依赖安装:

  • 全局安装:只是将包描述文件中 bin 字段下的可执行脚本以软连接的方式链接到 node 执行目录下的 ../../lib/node_module 中。path.resolve(process.execPath, '..', '..', 'lib', 'node_modules')
  • 本地安装:npm install <package.json 文件所在目录 or url>

一些钩子: package.json 文件的scripts 中定义。

scripts: {
  "preinstall":  "install 该包之前执行的脚本",
  install: "install 该包时执行的脚本",
  uninstall: "卸载该包时执行的脚本",
  test: "单元测试脚本",
}
Web note ad 1