详解node中引入模块的原理

307 阅读8分钟

1. 模块机制

1.1 commonjs规范

  1. CommonJS规范为JavaScript制定了一个美好的愿景——希望JavaScript能够在任何地方运行。

  2. CommonJS对模块的定义十分简单,主要分为模块引用、模块定义和模块标识3个部分。

  • 模块引用:通过require方法引入模块
var math = require('math');
  • 模块定义:通过exports对象导出当前模块的方法或变量(exports是module的属性)
exports.add = function() {}
  • 模块标识:就是传递给require方法的参数,可以是以下几种形式(可以没有文件后缀名.js):
    • 符合小驼峰命名的字符串
    • 以.、..开头的相对路径
    • 绝对路径

1.2 node的模块实现(node中引入模块的过程)

node在实现中并非完全按照connonjs规范,而是对模块规范进行了一定的取舍,同时增加了少许自身的特性。

1. 在node中引入模块需要经历如下3个步骤:

  • 路径分析
  • 文件定位
  • 编译执行

2. node中,模块分为以下两类:

  • 核心模块:node提供的模块
    • 核心模块在node源代码编译过程中,编译进了二进制执行文件
    • 在node进程启动时,部分核心模块就被直接加载到了内存中(所以这部分核心模块在引入时,文件定位和编译执行这两部分是可以省略的,且在路径分析中优先判断,加载速度最快
  • 文件模块:用户编写的模块
    • 需要在运行时动态加载,需要完整的路径分析、文件定位和编译执行。速度较慢

以下为详细的模块加载过程:

1.2.1 优先从缓存中加载

  • node对引入过的模块都会进行缓存,以减少二次引入时的开销(和浏览器缓存的不同之处在于:浏览器仅仅缓存文件,而node缓存的是编译和执行后的对象
  • 对核心模块的缓存检查优先于文件模块

1.2.2 路径分析

因为标识符有几种形式,所以对于不同的标识符,模块的查找和定位有着不同程度的差异。模块分类如下:

  • 核心模块:如http、fs、path等
  • 以.或..开始的相对路径文件模块
  • 以/开始的绝对路径文件模块
  • 非路径形式的文件模块,如自定义的文件模块

1. 核心模块 核心模块的优先级仅次于缓存加载,它在node源代码编译过程中被编译成了二进制文件,加载速度最快。

2. 路径形式的文件模块 require会将路径转化为真实路径,并以真实路径作为索引,将编译执行后的结果放到缓存中,以使二次加载更快。

3. 自定义模块 node会逐个尝试模块路径中的路径,直到找到目标文件为止。

  • 模块路径概念:模块路径是node在定位文件模块的具体文件时指定的查找策略,具体表现为一个路径组成的数组(可通过module.paths输出)
  • 模块路径的生成规则:
    • 当前文件目录下的node_modules目录
    • 父目录下的node_modules目录
    • 父目录的父目录下的node_modules目录
    • 沿路径向上逐级递归,直到根目录下的node_modules目录

1.2.3 文件定位

  • 文件扩展名的分析
  • 目录和包的处理

1. 文件扩展名的分析:当传递给require的标识符不包括文件扩展名时,node会按照以下次序补足扩展名,依次尝试:

  • .js
  • .json
  • .node

在尝试的过程中,node会调用fs模块同步阻塞式的判断文件是否存在。因为node是单线程的,所以这里是一个会引发性能问题的地方。(解决:在引入.json和.node文件时,加上扩展名)

2. 目录和包的处理:require通过分析文件扩展名后,可能得到的是一个目录,此时node会将目录当做一个包来处理

  • 首先,node在当前目录下查找package.json。通过json.parse解析出包描述对象,从中取出main属性指定的文件名进行分析,若缺少扩展名,则会进入扩展名分析的步骤。
  • 若main指定的文件名错误,或者没有package.json,那么node会将index作为文件名。然后依次查找index.js、index.json、index.node
  • 如果在目录分析的过程中没有成功定位到任何文件,则自定义模块会进入到下一个模块路径继续查找。若遍历完模块路径后仍然没有,则会抛出查找失败的异常。

1.2.4 模块编译

定位到具体的文件后,node会新建一个模块对象,然后根据路径载入和编译。对于不同的扩展名,其载入方式如下:

  • .js文件:通过fs模块同步读取文件后编译执行
  • .node文件:这是用c/c++编写的扩展文件,通过dlopen加载最后编译生成的文件
  • .json文件:通过fs模块同步读取文件后,用Json.parse解析返回结果
  • 其余扩展名文件:都被当做.js文件载入

注:每个编译成功的模块,都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能

1. js模块的编译 node对获取的js文件进行了头尾的包装,包装后的代码会通过vm原生模块的runInThisContext()执行

(function(exports, requiure, module, __filename, __dirname){
    ...
}

2. c/c++模块的编译 node调用process.dlopen()进行加载和执行,(dlope在windows和*nux平台通过libuv进行了兼容).node模块并不需要编译,因为他是编写c/c++模块之后编译生成的

3. json文件的编译

  • 通过fs模块同步读取到json文件的内容
  • 通过JSON.parse()得到对象
  • 复制给module.exports

1.3 核心模块

  • c/c++编写(存放在node的src目录)
  • js编写(存放在lib目录)

1.3.1 js核心模块的编译过程

  • 转存为c/c++代码:node通过v8附带的js2c.py工具,将所有内置的js代码转换成c++里的数组,生成node_natives.h头文件。(在这个过程中,js代码以字符串的形式存储在node命名空间中。在启动node进程时,js代码直接加载进内存中
  • 编译js核心模块:也经历了头尾的包装过程(与文件模块的区别在于:获取源码的方式和缓存执行结果的位置)
    • 核心模块从内存中加载
    • 编译成功的模块缓存到NativeModule._cache对象; 文件模块则缓存到Module._cache对象

1.3.2 c/c++核心模块的编译过程

  • 内建模块:全部由c/c++编写(如buuffer、fs、os等)
  • c/c++完成核心部分,js实现封装

1. 内建模块

  • 每一个内建模块在定义之后,都通过NODE_MODULE宏将模块定义到node命名空间中。
  • nodex_extensions.h头文件将这些散列的内建模块统一放到了node_module_list数组
  • node提供了get_builtin_module()从node_module_list中取出内建模块
  • 内建模块的导出:通过process.binding()来加载内建模块(process是node在启动时生成的全局变量)

1.3.3 核心模块的引入流程

  • NODE_MODULE(node_os, reg_func)
  • get_builtin_module('node_os')
  • process.binding('os')
  • NativeModule.require('os')
  • require('os')

1.4 c/c++扩展模块

  • 模块编写: 普通的扩展模块和内建模块的区别在于:无需将源代码编译进node,而是通过dlopen()动态加载
  • 模块编译:通过gpy工具
  • 模块加载:通过require()加载
    • 对于.node文件使用process.dlopen()加载
      • 通过uv_dlopen()打开动态链接库
      • 通过uv_dlsym()找到动态链接库中通过NODE_MODULE宏定义的方法地址 (注:以上两个方法都是在libuv库中实现的。在不同的操作系统下分别调用不同的方法来分别加载.node在该操作系统下对应的文件.so和.dll