我要学Node(二)-模块机制

648 阅读10分钟

模块

不同与其他高级语言,Java有类文件,Python有import机制。。。Javascript只能通过script标签引入代码,显得杂乱无章,并且会有命名冲突, 我们在开发过程中必须非常谨慎,同时也很受限制。

Commonjs

  • 初衷

    commonjs希望Javascript能在任何地方运行。

    Javascript在发展历程中,主要在浏览器中发光发热,而对于后端来说,Javascript还有很多弊端和缺陷:

    • 没有模块系统
    • 没有标准接口
    • 标准库较少,像文件操作、IO流等常见需求都没有对应的库可用
    • 缺乏包管理系统,无法进行依赖的安装,无法快速构建应用

    commonjs希望Javascript能够编写出这些应用,运行在各个宿主平台:

    • 命令行工具
    • 服务端应用程序
    • 桌面图形界面应用程序
    • 混合应用(Adobe AIR等)

Node借鉴Commonjs的Modules规范实现了一套非常易用的模块系统,NPM对Packages的支持让开发者事半功倍。

Commonjs模块规范

三大核心:

  • 模块引用

    通过require()方法来引入对应的模块API,它接收一个模块标识

  • 模块定义

    当前上下文提供一个exports对象用于导出当前模块的方法和变量,它是导出模块的唯一出口,还有一个module对象, 他代表模块自身,而exports对象就是module的属性。Node中,一个文件就是一个模块,我们可以直接将方法挂载在exports对象上, 就能导出该方法

    // a.js
    exports.sayHi = function() {
        console.log('hello');
    }
    // b.js
    const a = require('a');
    exports.sayHello = a.sayHi;
    
  • 模块标识

    就是传递给require方法的参数,必须符合小驼峰命名的字符串,或者路径字符串,末尾可以不加文件后缀名js。

模块的意义:

把类聚的方法和变量,限定在私有作用域内,提供引入和导出功能,顺畅连接上下文,每个模块独立,互不干扰。

Node的模块机制

Node并没有完全按照Commonjs的规范,它增加了自身需要的特性。

在Node中,引入一个模块有如下三个过程:

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

模块分类:

  • 核心模块:Node内部提供的;

    在Node源码编译过程中,这些模块就被编译成了二进制执行文件,在Node进程启动时,这些模块就被加载进内存,这部分模块在加载过程中就只有 路径分析阶段,加载速度是最快的。

  • 文件模块:用户自己开发的;

    属于动态加载,三个过程都会经历,稍慢一些。

优先从缓存加载

对于相同模块的二次引入,Node会从缓存中去查找引入过的模块,节省二次引入的开销。实际缓存的是编译和执行后的对象

加载模块时,从缓存中获取是第一优先级,不管是核心模块还是文件模块。

路径分析和文件定位

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


模块标识符分析

  • 核心模块,http、fs、path等;

    优先级仅次于缓存,如果想要加载一个标识符与核心模块相同的自定义文件模块,是不会成功的。

  • 相对路径的文件模块;

    require方法会将传入的路径转化为真实路径,把真实路径作为索引,将编译执行后的结果存放在缓存中,方便下次加载。 由于指明了文件路径,所以加载起来仅次于核心模块速度。

  • 以/开始的绝对路径的文件模块;

    同上

  • 非路径形式的文件模块,自定义的与核心模块命名不冲突的模块;

    最慢的加载速度,一种特殊的文件模块,可能是一个包或者文件,


    --模块路径--

    Node在查找文件模块时制定的查找策略,具体表现为一个路径数组

    如我们创建一个js文件,内容为console.log(module.paths),执行它,在windows中应该打印出类似这样的: [ 'D:\\workspace\\node_modules', 'D:\\node_modules' ]

    所以,我们大概可以得出这个模块路径的生成规则:

    从当前目录的node_modules开始,逐级往上,直到根目录下的node_modules。,这也跟Javascript中原型链的作用方式一样, 很容易看出,路径越深,耗时约长。


文件定位

  • 文件扩展名分析

    上面我们说了,模块标识符可以不带文件后缀名,这种情况下,搜索顺序为.js.json.node。 在搜索过程中,需要调用fs模块同步阻塞地判断文件是否存在,所以这可能会引起性能问题,我们在引入json或者node文件时,尽可能 加上后缀名,这样可以提升速度。

  • 目录分析和包

    如果在没有扩展名的情况下,并没有找到对应的文件,而是找到一个目录,那么此时Node会将这个目录当作一个, 该过程Node就对Commonjs的包规范做了一定的支持:

    • 在当前目录下查找package.json(commonjs的包描述文件),通过JSON.parse解析包描述对象,取出main属性指定的 文件名,进行定位。如果缺少后缀名,则再次进入扩展名分析阶段。
    • 如果main属性指定错误,或者不存在该属性/不存在package.json,此时Node会将index作为文件名进行扩展名分析查找。
    • 目录分析中没有找到对应的文件,则进入下一个模块路径中重复上述动作。
    • 模块路径数组遍历完成,还没找到,则报错。

模块编译 每一个文件模块都是一个对象

function Module(id, parent) {   
   this.id = id;   
   this.exports = {};   
   this.parent = parent;   
   if (parent && parent.children) {
       parent.children.push(this);   
   } 
   this.filename = null;   
   this.loaded = false;   
   this.children = []; 
} 

编译和执行是引入一个模块的最后步骤,定位到具体文件后,Node会新建一个对象,根据路径载入并编译,以文件扩展名区分:

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

每一次编译成功的模块都会将其模块路径作为key缓存在Module.cache对象中。

根据不同的文件扩展名,Node会调用不同的方式进行读取,如.json文件

Module.extensions['.json'] = function (module, fileName) {
	const content = NativeModule.require('fs').readFileSync(fileName, 'utf-8');
	try {
		module.exports = JSON.parse(stripBOM(content));
	} catch (e) {
		throw e;
	}
}

其中的Module.extensions会被赋值到require.extensions属性上。

在确定文件的扩展名后,Node会调用具体的编译方式执行文件后再将结果返回给调用者。

  • Javascript文件的编译

    Commonjs中,每个模块文件中有require、module、exports、__filename、__dirname变量存在, 但是我们并未声明过,那它们是怎么来的呢?

    在编译过程中,Node将整个Javascript文件做了头尾包装,如下:

    (function (exports, require, module, __filename, __dirname) {   
        var math = require('math');   
        exports.area = function (radius) {     
            return Math.PI * radius * radius;   
        }; 
    });
    

    每个文件都进行了作用域隔离,包装后的代码会通过vm原生对象的runInThisContext方法(类似eval,只是明确上下文,不污染全局)执行, 返回一个function对象。再将当前模块的exports、require、module以及在文件定位中获取的文件路径和目录作为参数传给这个函数执行。

为什么不直接exports = xxx,而是module.exports = xxx

因为exports是作为形参传入的,直接赋值改变形参会改变对形参的引用,无法修改作用域外部的变量。

  • C/C++模块的编译 Node调用process.dlopen方法进行加载和执行,.node文件并不需要编译,执行过程中,模块的exports对象与.node模块产生联系,返回给调用者。

  • .json文件的编译 直接利用fs模块同步读取文件内容,通过JSON.parse方法解析后赋值给exports对象。

核心模块

核心模块分为:

  • C/C++模块:存放在Node项目的src目录
  • Javascript模块:存放在lib目录
Javascript核心模块的编译

在编译所有C/C++模块之前,会将所有Javascript模块编译成C/C++代码,但此时并不是可执行文件

  • 转存为C/C++代码

    Node采用了V8内部的js2c.py工具,将所有内置的Javascript代码src/node.js,lib/*.js转为C++里面的数组, 生成node_natives.h文件。

    此时Javascript代码是以字符串的形式存在Node命名空间中,不可执行,Node进程启动后,加载进内存。

  • 编译Javascript核心模块

    与文件模块一样,在引入核心模块过程中,也经历了头尾包装,执行并导出exports对象,不同的是,源文件的获取方式与缓存执行结果的位置。

    其源文件通过process.binding('natives')取出,缓存在NativeModule._cache对象上,而文件模块是存放在Module._cache对象上。

内建模块

核心部分由C/C++编写完成、封装对象由Javascript完成是Node提升性能的主要方式,而纯C/C++编写的模块我们称为内建模块, 几乎不被用户所调用。fs Buffer等模块都是不是内建模块。

AMD

Node的模块引入几乎都是同步的(调用fs模块同步加载文件),如果在前端系统中采用这种方式,那很可能会阻塞UI渲染。 所以另一个模块规范出现了,AMD,异步模块定义。

  • 定义模块

    define(id?, dependencies?, factory);factory实际上就是模块代码内容。 具体例子:

    define(function() {
        var exports = {};
        exports.sayHi = function() {
            alert('hi');
        };
        return exports;
    });
    

    与Node内部不同,这里需要显式define定义一个模块(同样为了封闭作用域),而且需要返回导出对象。

CMD

这种规范更接近Commonjs,与AMD不同,CMD支持依赖动态引入,他将require、exports、module三个变量当作参数传入define方法, 进行模块定义。

define(function(require, exports, module) {
    // do something
});

AMD在定义模块时就必须把依赖全部引入,不管是否用得到,CMD则可以灵活引入。

总结:

  • 为了把类聚的方法和变量,限定在私有作用域内,提供引入和导出功能,顺畅连接上下文,每个模块独立,互不干扰。Nodejs的模块功能出现了;
  • 遵循Commonjs规范,模块主要有三个变量exports、require、module
  • 模块的三个核心:模块引用、模块定义、模块标识;
  • 模块引入的流程:
    • 路径分析(模块标识符分析)
      • 核心模块优先
      • 相对、绝对路径其次
      • 自定义模块最后
    • 文件定位
      • 扩展名分析,顺序为.js->.json->.node
      • 目录和包,会查找package.json文件
    • 编译执行
      • 新建一个对象
      • 载入文件并执行,将返回结果根据文件路径缓存起来
      • 将执行结果返回给调用者
  • 模块分为核心模块与文件模块(开发者自己编写的),加载机制不同,导致加载速度也不同。缓存加载是第一优先级。
  • Javascript文件的编译会经历一个头尾包装的过程,这也是module、require、exports、__filename、__dirname五个变量的由来。
  • Commonjs同步加载模块的方式只适合在Node环境,在浏览器环境,应该异步加载模块,所以AMD出现了。
  • AMD在模块定义时就需要把所有依赖当作参数传入,而CMD则可以动态引入依赖。