Node.js模块机制的深度解析(第一部分)

发表时间: 2019-10-09 00:51

深入浅出node.js总结-模块机制(1)

  • javascript先天就缺乏一项功能:模块

javasciprt 通过script标签引入代码的方式显得杂乱无章,语言自身毫无组织和约束能力。人们不得不用命名空间等方式人为地约束代码,以求达到安全和易用的目的。

  • 为了让javascript能在服务端有市场,社区为javascript制定了相应的规范——CommonJS

CommonJS规范

CommonJs的出发点

javascript有以下缺点:

  • ==没有模块系统==
  • ==标准库较少==-比如没有文件系统、I/O流等标准的API
  • ==没有标准接口==-几乎没有定义过web服务器或者数据库之类的标准统一接口
  • ==缺乏包管理系统==-javascript没有自动加载和安装依赖的能力

CommonJS就是来弥补这些缺陷的

CommonJS大部分规范依旧是草案,但是为javascript开发大型应用程序指明了一条道路

  • Node受到CommonJS的影响,CommonJS因Node表现优异而走入各个公司项目里,相互影响和促进

CommonJS的模块规范

分为模块引用、模块定义、模块标识

  1. ==模块引用== var math = require('math');

es6已更新一套支持模块引用的语法:import。在调用require时,可以把它放在某个判断条件下,但import不行;在打包编译时,如果require里的文件模块不存在,即便逻辑上不会进入其所在的判断条件,依旧会报错。可以使用try{}catch(e){}来处理

  1. ==模块定义==
  • 提供了exports对象用于导出当前模块的方法或变量

export 是es6的规范,与import一同用,不要弄混了

  • 一个模块里还有module对象,代表模块自身;exports是module的属性
  • 在Node中,一个文件就是一个模块,将方法挂载在exports对象上作为属性即可定义导出的方式
// math.jsexports.add = function(a,b){ return a+b;}exports.sum = function(a,b){ return a+b;}复制代码// index.jsvar math = reqire('math');exports.add = function(val){ return math.add(val,1);}复制代码
  1. ==模块标识==
  • 指的是传给require()方法的参数,必须是符合小驼峰命名的字符串,或者以.、..开头的相对路径,或者绝对路径

每个模块具有独立的空间,互不干扰,用户不必考虑变量污染

Node的模块实现

Node没有完全按照CommonJS规范实现,做了一定的取舍并加入自身需要的特性。

  • Node引入模块需经历如下3个步骤
  1. ==路径分析==
  2. ==文件定位==
  3. ==编译执行==
  • Node中模块分为两块:一类是Node提供的模块,称为==核心模块==;另一类是用户编写的模块,称为==文件模块==
  1. 核心模块在编译过程中,成为二进制执行文件;Node进程启动时,部分核心模块就直接加载进内存中

开发者引入这部分核心模块时,省略了文件定位、编译执行,且在路径分析中优先判断;故,加载速度最快

  1. 文件模块是在运行时动态加载,需要路径分析、文件定位、编译执行过程,速度比核心模块慢

先路径分析文件定位、最后再运行,所以前面require一个不存在的文件时,即便有判断条件,依然会报错

优先从缓存加载

  • Node对引入过的模块都会进行缓存,以减少二次引入时的开销。==Node缓存的是编译和执行之后的对象==

所以,所有模块exports/export出的的东西,在内存中有且仅有一份,不受exports/export后的表达式/new之类的语句所影响

  • require()方法/import语句对于相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。核心模块的缓存检查优于文件模块的缓存检查

路径分析和文件定位

因标识符的不同形式,模块的查找和定位有不同程度的差异

  1. 模块标识符分析
  • 标识符分为以下几类:
  • ==核心模块==,如http、fs、path
  • .或..开始的==相对路径文件模块==
  • 以/开始的==绝对路径文件模块==
  • ==非路径形式的文件模块==
  • 核心模块
  • 优先级仅次于缓存加载,加载速度最快
  • 不能将自定义模块的标识符与核心模块标识符定义得一致,除非你换用路径的方式
  • 路径形式的文件模块
  • . .. /开始的标识符,均视为文件模块
  • require将路径转为真实路径,以此做索引,编译执行后的结果放入缓存
  • 加载速度比核心模块慢
  • 自定义模块
  • 特殊的文件模块,查找费时,加载速度最慢

  • “模块路径”——Node定位文件模块时定制的查找策略。模块路径是一个路径组成的数组:
  • [
  •  当前文件目录下的xx目录,
  •  父目录下的xx目录,
  •  父目录下的父目录下的xx目录,
  •  沿路径向上逐级递归,直到根目录下的xx目录
  • ]
类似于js的原型链或作用域链的查找方式。也正因如此,一旦路径越深,查找耗时就越多,所以加载速度最慢
  1. 文件定位

有缓存的存在和前面的路径分析,文件定位相对比较简单。这里注意一些细节:

  • 文件扩展名分析
  • 有时标识符没有扩展名,CommonJS规范不允许不包含文件扩展名,但Node会按.js、.json、.node的顺序依次尝试
  • 尝试时,会调用fs模块同步阻塞时判断文件是否存在。由于Node单线程,所以这里会引起性能问题——建议.node/.json文件加上扩展名
  • 目录分析和包
  • 有时没查找到文件,而是一个目录

  • Node会先找当前目录下的package.json,解析出main属性指定的文件名进行定位;如果文件名没有扩展名,则会进入扩展名分析的步骤
解析方式就是JSON.parse()
  • 如果main提供的文件名有误,或者没有package.json,则查index(.js、.json、.json)

  • 还没定位成功,则按照自定义模块的模块路径 策略,去父目录查询;如果模块路径数组遍历完毕都没找到,则抛出查找失败的异常

模块编译

(这里指的都是文件模块,非核心模块) 当定位到具体文件后,Node会新建一个模块对象,将文件载入并编译。载入方法根据不同文件扩展名而区分

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

编译成功的模块会缓存在Module._cache上,以文件路径作为索引

  1. javascript模块的编译
  • 编译过程中,Node对获取的javascript的文件内容进行了头尾包装,头部添加(function(==exports, require, module, __filename, __dirname==){\n,尾部添加\n})

__filename 完整的文件路径;__dirname 文件目录

  • 包装后的代码vm原生模块的runInThisContext()执行,返回一个具体的function对象

  • 最后,将当前模块对象的exports、require()、module、文件路径和目录作为参数传入给function对象
  1. 执行后exports返回给调用方,其上的任何方法与属性均可被外部调用,但是模块中的其余变量或属性不可。==以此达到模块间的作用域隔离==

这就是Node对CommonJS模块规范的实现

  1. exports的误用 编写代码时,理论上只要这样写就行:
exports = function(){//My Class};复制代码
  1. 但是这样写是有问题的,我们来看看编译过程:
  2. 头尾包装:
(function(exports, require, module, __filename, __dirname){ exports = function(){//My Class};})复制代码
  1. 看,你把形参改了。。。但是exports最后是要返回给调用方然后被外部调用的,==改形参根本不能真正改变exports对象的内容==
  2. 所以,要不老老实实地写:exports.add = ...;或者写module.exports = ...
  3. c/c++模块的编译
  • 事实上,.node模块不需要编译,因为它是c/c++模块之后编译生成。它只需要加载和执行
  • 执行中exports对象与.node模块产生脸型,返回给调用者
  • 优势:执行效率高;劣势:编写门槛高
  1. JSON文件的编译
  • fs模块同步读取JSON文件
  • JSON.parse()方法得到对象
  • 赋给exports
  1. 如果.json文件作为配置文件(如package.json),则不必调用fs模块去读取和解析,直接reqire()引入即可。同理它也是享受缓存的便利,二次引入时没有性能影响

核心模块

核心模块分为c/c++编写的和javascript编写的两部分

js核心模块的编译过程

编译程序会将js模块文件编译成c/c++代码

  1. 转存为c/c++代码
  • 将所有内置的js代码转换成c++的数组,此时js代码不可直接执行,启动Node进程后,js代码直接加载进内存,将来查找比文件模块要快得多
  1. 编译js核心模块
  • 经历头尾包装、执行、导出exports对象。与文件模块区别地方在于:它从内存中加载;缓存执行结果的位置

核心模块在NativeModule._cache对象上,文件模块在Module._cache对象上

(未完待续~)