Commonjs规范及Node模块实现

前面的话

  Node在实现中并不是彻底按照CommonJS规范实现,而是对模块规范进行了必定的取舍,同时也增长了少量自身须要的特性。本文将详细介绍NodeJS的模块实现javascript

 

引入

  nodejs是区别于javascript的,在javascript中的顶层对象是window,而在node中的顶层对象是global前端

  [注意]实际上,javascript也存在global对象,只是其并不对外访问,而使用window对象指向global对象而已java

  在javascript中,经过var a = 100;是能够经过window.a来获得100的node

  但在nodejs中,是不能经过global.a来访问,获得的是undefinedjson

  这是由于var a = 100;这个语句中的变量a,只是模块范围内的变量a,而不是global对象下的a数组

  在nodejs中,一个文件就是一个模块,每一个模块都有本身的做用域。使用var来声明的一个变量,它并非全局的,而是属于当前模块下浏览器

  若是要在全局做用域下声明变量,则以下所示缓存

 

概述

  Node中模块分为两类:一类是Node提供的模块,称为核心模块;另外一类是用户编写的模块,称为文件模块架构

  核心模块部分在Node源代码的编译过程当中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,因此这部分核心模块引入时,文件定位和编译执行这两个步骤能够省略掉,而且在路径分析中优先判断,因此它的加载速度是最快的异步

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

  接下来,咱们展开详细的模块加载过程

 

模块加载

  在javascript中,加载模块使用script标签便可,而在nodejs中,如何在一个模块中,加载另外一个模块呢?

  使用require()方法来引入

【缓存加载】

  再展开介绍require()方法的标识符分析以前,须要知道,与前端浏览器会缓存静态脚本文件以提升性能同样,Node对引入过的模块都会进行缓存,以减小二次引入时的开销。不一样的地方在于,浏览器仅仅缓存文件,而Node缓存的是编译和执行以后的对象

  不管是核心模块仍是文件模块,require()方法对相同模块的二次加载都一概采用缓存优先的方式,这是第一优先级的。不一样之处在于核心模块的缓存检查先于文件模块的缓存检查

【标识符分析】

  require()方法接受一个标识符做为参数。在Node实现中,正是基于这样一个标识符进行模块查找的。模块标识符在Node中主要分为如下几类:[1]核心模块,如http、fs、path等;[2].或..开始的相对路径文件模块;[3]以/开始的绝对路径文件模块;[4]非路径形式的文件模块,如自定义的connect模块

  根据参数的不一样格式,require命令去不一样路径寻找模块文件

  一、若是参数字符串以“/”开头,则表示加载的是一个位于绝对路径的模块文件。好比,require('/home/marco/foo.js')将加载/home/marco/foo.js

  二、若是参数字符串以“./”开头,则表示加载的是一个位于相对路径(跟当前执行脚本的位置相比)的模块文件。好比,require('./circle')将加载当前脚本同一目录的circle.js

  三、若是参数字符串不以“./“或”/“开头,则表示加载的是一个默认提供的核心模块(位于Node的系统安装目录中),或者一个位于各级node_modules目录的已安装模块(全局安装或局部安装)

  [注意]若是是当前路径下的文件模块,必定要以./开头,不然nodejs会试图去加载核心模块,或node_modules内的模块 

//a.js
console.log('aaa');

//b.js
require('./a');//'aaa'
require('a');//报错

【文件扩展名分析】

  require()在分析标识符的过程当中,会出现标识符中不包含文件扩展名的状况。CommonJS模块规范也容许在标识符中不包含文件扩展名,这种状况下,Node会先查找是否存在没有后缀的该文件,若是没有,再按.js、.json、.node的次序补足扩展名,依次尝试

  在尝试的过程当中,须要调用fs模块同步阻塞式地判断文件是否存在。由于Node是单线程的,因此这里是一个会引发性能问题的地方。小诀窍是:若是是.node和.json文件,在传递给require()的标识符中带上扩展名,会加快一点速度。另外一个诀窍是:同步配合缓存,能够大幅度缓解Node单线程中阻塞式调用的缺陷

【目录分析和包】

  在分析标识符的过程当中,require()经过分析文件扩展名以后,可能没有查找到对应文件,但却获得一个目录,这在引入自定义模块和逐个模块路径进行查找时常常会出现,此时Node会将目录当作一个包来处理

  在这个过程当中,Node对CommonJS包规范进行了必定程度的支持。首先,Node在当前目录下查找package.json(CommonJS包规范定义的包描述文件),经过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。若是文件名缺乏扩展名,将会进入扩展名分析的步骤

  而若是main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当作默认文件名,而后依次查找index.js、index.json、index.node

  若是在目录分析的过程当中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。若是模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常

 

访问变量

  如何在一个模块中访问另一个模块中定义的变量呢? 

【global】

  最容易想到的方法,把一个模块定义的变量复制到全局环境global中,而后另外一个模块访问全局环境便可

//a.js
var a = 100;
global.a = a;

//b.js
require('./a');
console.log(global.a);//100

  这种方法虽然简单,但因为会污染全局环境,不推荐使用

【module】

  而经常使用的方法是使用nodejs提供的模块对象Module,该对象保存了当前模块相关的一些信息

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 = [];
}
module.id 模块的识别符,一般是带有绝对路径的模块文件名。
module.filename 模块的文件名,带有绝对路径。
module.loaded 返回一个布尔值,表示模块是否已经完成加载。
module.parent 返回一个对象,表示调用该模块的模块。
module.children 返回一个数组,表示该模块要用到的其余模块。
module.exports 表示模块对外输出的值。

【exports】

  module.exports属性表示当前模块对外输出的接口,其余文件加载该模块,实际上就是读取module.exports变量

//a.js
var a = 100;
module.exports.a = a;

//b.js
var result = require('./a');
console.log(result);//'{ a: 100 }'

  为了方便,Node为每一个模块提供一个exports变量,指向module.exports。形成的结果是,在对外输出模块接口时,能够向exports对象添加方法

console.log(module.exports === exports);//true

  [注意]不能直接将exports变量指向一个值,由于这样等于切断了exportsmodule.exports的联系

 

模块编译

  编译和执行是模块实现的最后一个阶段。定位到具体的文件后,Node会新建一个模块对象,而后根据路径载入并编译。对于不一样的文件扩展名,其载入方法也有所不一样,具体以下所示

  js文件——经过fs模块同步读取文件后编译执行

  node文件——这是用C/C++编写的扩展文件,经过dlopen()方法加载最后编译生成的文件

  json文件——经过fs模块同步读取文件后,用JSON.parse()解析返回结果

  其他扩展名文件——它们都被当作.js文件载入

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

  根据不一样的文件扩展名,Node会调用不一样的读取方式,如.json文件的调用以下:

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

  其中,Module._extensions会被赋值给require()的extensions属性,因此经过在代码中访问require.extensions能够知道系统中已有的扩展加载方式。编写以下代码测试一下:

console.log(require.extensions);

  获得的执行结果以下:

{ '.js': [Function], '.json': [Function], '.node': [Function] }

  在肯定文件的扩展名以后,Node将调用具体的编译方式来将文件执行后返回给调用者

【JavaScript模块的编译】

  回到CommonJS模块规范,咱们知道每一个模块文件中存在着require、exports、module这3个变量,可是它们在模块文件中并无定义,那么从何而来呢?甚至在Node的API文档中,咱们知道每一个模块中还有filename、dirname这两个变量的存在,它们又是从何而来的呢?若是咱们把直接定义模块的过程放诸在浏览器端,会存在污染全局变量的状况

  事实上,在编译的过程当中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了(function(exports, require, module, filename, dirname) {\n,在尾部添加了\n});

  一个正常的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(模块对象自身),以及在文件定位中获得的完整文件路径和文件目录做为参数传递给这个function()执行

  这就是这些变量并无定义在每一个模块文件中却存在的缘由。在执行以后,模块的exports属性被返回给了调用方。exports属性上的任何方法和属性均可以被外部调用到,可是模块中的其他变量或属性则不可直接被调用

  至此,require、exports、module的流程已经完整,这就是Node对CommonJS模块规范的实现

【C/C++模块的编译】

  Node调用process.dlopen()方法进行加载和执行。在Node的架构下,dlopen()方法在Windows和*nix平台下分别有不一样的实现,经过libuv兼容层进行了封装

  实际上,.node的模块文件并不须要编译,由于它是编写C/C++模块以后编译生成的,因此这里只有加载和执行的过程。在执行的过程当中,模块的exports对象与.node模块产生联系,而后返回给调用者

  C/C++模块给Node使用者带来的优点主要是执行效率方面的,劣势则是C/C++模块的编写门槛比JavaScript高

【JSON文件的编译】

  .json文件的编译是3种编译方式中最简单的。Node利用fs模块同步读取JSON文件的内容以后,调用JSON.parse()方法获得对象,而后将它赋给模块对象的exports,以供外部调用

  JSON文件在用做项目的配置文件时比较有用。若是你定义了一个JSON文件做为配置,那就没必要调用fs模块去异步读取和解析,直接调用require()引入便可。此外,你还能够享受到模块缓存的便利,而且二次引入时也没有性能影响

 

CommonJS

  在介绍完Node的模块实现以后,回过头来再学习下CommonJS规范,相对容易理解

  CommonJS规范的提出,主要是为了弥补当前javascript没有标准的缺陷,使其具有开发大型应用的基础能力,而不是停留在小脚本程序的阶段

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

【模块引用】

var math = require('math');

  在CommonJS规范中,存在require()方法,这个方法接受模块标识,以此引入一个模块的API到当前上下文中

【模块定义】

  在模块中,上下文提供require()方法来引入外部模块。对应引入的功能,上下文提供了exports对象用于导出当前模块的方法或者变量,而且它是惟一导出的出口。在模块中,还存在一个module对象,它表明模块自身,而exports是module的属性。在Node中,一个文件就是一个模块,将方法挂载在exports对象上做为属性便可定义导出的方式:

// math.js
exports.add = function () {
    var sum = 0, i = 0,args = arguments, l = args.length;
    while (i < l) {
        sum += args[i++];
    }
    return sum;
};

  在另外一个文件中,咱们经过require()方法引入模块后,就能调用定义的属性或方法了

// program.js
var math = require('math');
exports.increment = function (val) {
    return math.add(val, 1);
};

【模块标识】

  模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者以.、..开头的相对路径,或者绝对路径。它能够没有文件名后缀.js

  模块的定义十分简单,接口也十分简洁。它的意义在于将类聚的方法和变量等限定在私有的做用域中,同时支持引入和导出功能以顺畅地链接上下游依赖。每一个模块具备独立的空间,它们互不干扰,在引用时也显得干净利落

相关文章
相关标签/搜索