第二章 模块机制

之前 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: "单元测试脚本",
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容