CommonJS規範

前言

CommonJS規範的提出,使得javascript具有開發大型應用的基礎能力,規範制定者但願用CommonJS API寫出的應用能夠具有跨宿主環境的能力,可以在任何地方運行。這樣javascript不只能夠用開發富客戶端應用,並且還能夠編寫:javascript

  1. 服務器端javascript應用程序
  2. 命令行工具
  3. 桌面圖形界面應用程序。
  4. 混合應用。

目前,該規範依舊在成長。它涵蓋了模塊、二進制、Buffer、字符集編碼、I/O流、進程環境、文件系統、套接字、單元測試、web服務器網關接口、包管理等。
node借鑑CommonJS的Modules規範實現了一套很是易用的模塊系統,NPM對Packages規範的無缺支持使得Node應用在開發中事半功倍。
<!-- more -->java

CommonJS的模塊規範

CommonJS對模塊的定義十分簡單,主要分爲模塊引用、模塊定義和模塊標識3個部分。node

1.模塊引用web

var math = require('math');

require這個方法接受模塊標識,以此引入一個模塊的API到當前上下文中。編程

2.模塊定義json

對應引入的功能,上下文提供了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;
};

// program.js
var math = require('./math');
console.log(math.add(1, 2));// 3

3.模塊標識瀏覽器

模塊標識其實就是傳遞給require()方法的參數,它必須是符合小駝峯命名的字符串,或者以.、..開頭的相對路徑,或者絕對路徑。能夠沒有文件名後綴.js。緩存

模塊的意義在於將類聚的方法和變量等限定在私有的做用域中,同時支持引入和導出功能以順暢地鏈接上下游依賴。bash

node的模塊實現

Node在實現中並不是徹底按照規範實現。

在node中引入模塊,須要經歷3個步驟。

  1. 路徑分析
  2. 文件定位
  3. 編譯執行

在node中,模塊分爲兩類:一類是node提供的模塊,稱爲核心模塊;另外一類是用戶編寫的模塊,稱爲文件模塊。

  1. 核心模塊部分在node源代碼的編譯過程當中,編譯進了二進制執行文件。在node進程啓動時,部分核心模塊就被直接加載進內存中,因此這部分核心模塊引入時,文件定位和編譯執行這兩個步驟能夠省略掉,而且在路徑分析中優先判斷,因此它的加載速度是最快的。
  2. 文件模塊則是在運行時動態加載,須要完整的路徑分析、文件定位、編譯執行過程,速度比核心模塊慢。

接下來,咱們展開詳細的模塊加載過程。

1.優先從緩存加載

node對引入過的模塊都會進行緩存,以減小二次引入時的開銷。與瀏覽器僅僅緩存文件不一樣,node緩存的是編譯和執行以後的對象。

不管是核心模塊仍是文件模塊,require()方法對相同模塊的二次加載都一概採用緩存優先的方式,這是第一優先級的,核心模塊的緩存檢查先於文件模塊的緩存檢查。

2.路徑分析和文件定位

2.1模塊標識符分析

require()方法接受一個標識符做爲參數,node正是基於這樣一個標識符進行模塊查找的。模塊標識符在node中主要分爲幾類。

  1. 核心模塊,如http、fs、path等
  2. 以.或..開始的相對路徑文件模塊
  3. 以/開始的絕對路徑文件模塊
  4. 非路徑形式的文件模塊,如自定義的connect模塊
  5. 核心模塊

核心模塊的優先級僅次於緩存加載,它在node的源代碼編譯過程當中已經編譯爲二進制文件代碼,其加載過程最快。

  1. 路徑形式的文件模塊

以.、..和/開始的標識符,這裏都被當作文件模塊來處理。在分析路徑模塊時,require()方法會將路徑轉爲真實路徑,並以真實路徑做爲索引,將編譯執行後的結果存放到緩存中,以使二次加載時更快。因爲文件模塊給node指明瞭確切的文件位置,因此在查找過程當中能夠節約大量時間,其加載速度慢於核心模塊。

  1. 自定義模塊

自定義模塊指的是非核心模塊,也不是路徑形式的標識符。它是一種特殊的文件模塊,多是一個文件或者包的形式。這類模塊的查找是最費時的,也是全部方式中最慢的一種。

咱們須要先介紹一下模塊路徑這個概念。模塊路徑是node在定位文件模塊的具體文件時制定的查找策略,具體表現爲一個路徑組成的數組。關於這個路徑的生成規則,咱們能夠手動嘗試一番。

(1)建立module_path.js文件,其內容爲console.log(module.paths);
(2)將其放在任意一個目錄中而後執行node module_path.js

在Linux下,你可能獲得的是這樣一個數組輸出:

['/home/jackson/research/node_modules',
'/home/jackson/node_modules',
'/home/node_modules',
'/node_modules']

在window下,也許是這樣:

['c:\\nodejs\\node_modules', 'c:\\node_modules']

能夠看出,模塊路徑的生成規則以下所示。
1.當前文件目錄下的node_modules目錄
2.父目錄下的node_modules目錄
3.父目錄的父目錄下的node_modules目錄
4.沿路徑向上逐級遞歸,直到根目錄下的node_modules目錄
在加載過程當中,node會逐個嘗試模塊路徑中的路徑,直到找到目標文件爲止。當前文件路徑越深,模塊查找耗時越多,這是自定義模塊的加載速度最慢的緣由。

2.1文件定位

從緩存加載的優化策略使得二次引入時不須要路徑分析、文件定位和編譯執行的過程,大大提升了再次加載模塊的效率。

但在文件定位過程當中,還有一些細節須要注意,這主要包括文件拓展名的分析、目錄和包的處理。
2.1.1文件擴展名分析
CommonJS模塊規範容許在標識符中不包含文件擴展名,這種狀況下node會按.js、.json、.node的次序補足擴展名,依次嘗試。在嘗試過程當中,須要調用fs模塊同步阻塞式判斷文件是否存在。由於node是單線程,這裏是一個會引發性能問題的地方。小訣竅是:標識符帶上擴展名,這樣會加快一點速度。另外一個訣竅是:同步配合緩存,也能夠大幅度緩解Node單線程阻塞式調用的缺陷。

2.1.2目錄分析和包

在分析標識符的過程當中,require()經過分析拓展名以後,可能沒有查找到對應文件,但卻獲得一個目錄,此時Node會將目錄當作一個包來處理。

Node會在當前目錄下查找package.json(包描述文件),經過JSON.parse()解析包描述對象,從中取出main屬性指定的文件名進行定位。若是文件名缺乏擴展名,將會進入擴展名分析的步驟。而若是main屬性指定的文件名錯誤,或者壓根沒有package.json文件,node會將index當作默認文件名,而後依次查找index.js、index.json、index.node。

若是沒有定位成功,則自定義模塊進入下一個模塊路徑進行查找。若是模塊路徑數組都被遍歷完畢,依舊沒有查找到,則會拋出查找失敗的異常。

模塊編譯

在Node中,每一個文件模塊都是一個對象,它的定義以下

function Module(id, parent) {
  this.id = id; // 模塊的識別符,一般是帶有絕對路徑的模塊文件名。
  this.exports = {}; // 表示模塊對外輸出的值。
  this.parent = parent; // 返回一個對象,表示調用該模塊的模塊。能夠判斷parent是否爲null判斷當前模塊是否爲入口腳本。
  if (parent && parent.children) {
    parent.children.push(this);
  }
  this.filename = null; // 模塊的文件名,帶有絕對路徑。
  this.loaded = false; // 返回一個布爾值,表示模塊是否已經完成加載。
  this.children = []; // 返回一個數組,表示該模塊要用到的其餘模塊。
}

定位到具體的文件後,Node會新建一個模塊對象,而後根據路徑載入並編譯。對於不一樣的文件擴展名,其載入方法也有所不一樣。

  1. .js文件 經過fs模塊同步讀取文件後編譯執行
  2. .node文件 這是C/C++編寫的擴展文件,經過dlopen()方法加載最後編譯生成的文件
  3. .json文件 經過fs模塊同步讀取文件後,用JSON.parse()解析返回結果
  4. 其他擴展名文件 都被當作.js文件載入

每個編譯成功的模塊都會將其文件路徑做爲索引緩存在Module._cache對象上,以提升二次引入的性能
.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);

結果以下:

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

若是想對自定義的擴展名進行特殊的加載,能夠經過相似require.extensions['.ext']的方式實現。早期的CoffeeScript文件就是經過添加require.extensions['.coffee']擴展的方式來實現加載的。可是從V0.10.6開始,官方不鼓勵經過這種方式進行加載,而是指望先將其餘語言或文件編譯成JavaScript文件後再加載,這樣作的好處在於不將煩瑣的編譯加載等過程引入Node的執行過程當中。

在肯定文件的擴展名以後,Node將調用具體的編譯方式來將文件執行後返回給調用者。
1.javaScript模塊的編譯
每一個模塊文件都有exports、require、、module、__filename、__dirname這些變量存在

{
  '0': {},
  '1':
   { [Function: require]
     resolve: { [Function: resolve] paths: [Function: paths] },
     main:
      Module {
        id: '.',
        exports: {},
        parent: null,
        filename: '/Users/xxx/Desktop/study/node-test/module_path.js',
        loaded: false,
        children: [],
        paths: [Array] },
     extensions:
      [Object: null prototype] { '.js': [Function], '.json': [Function], '.node': [Function] },
     cache:
      [Object: null prototype] {
        '/Users/xxx/Desktop/study/node-test/module_path.js': [Module] } },
  '2':
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/xxx/Desktop/study/node-test/module_path.js',
     loaded: false,
     children: [],
     paths:
      [ '/Users/xxx/Desktop/study/node-test/node_modules',
        '/Users/xxx/Desktop/study/node_modules',
        '/Users/xxx/Desktop/node_modules',
        '/Users/xxx/node_modules',
        '/Users/node_modules',
        '/node_modules' ] },
  '3': '/Users/xxx/Desktop/study/node-test/module_path.js',
  '4': '/Users/xxx/Desktop/study/node-test' }

在編譯的過程當中,Node對獲取的JavaScript文件內容進行了頭尾包裝。在頭部添加了:

(function (exports, require, module, __filename, __dirname) {
  var content = "content";
  exports.content = function () {
    console.log(content);
  };
})

這樣每一個模塊文件以前都進行了做用域隔離。包裝以後的代碼會經過vm原生模塊的runInThisContext()方法執行(相似eval,只是具備明確上下文,不污染全局),返回一個具體的function對象。最後,將當前模塊對象的exports屬性、require方法、module(模塊對象自身),以及在文件定位中獲得的完整文件路徑和文件目錄做爲參數傳遞給這個function()執行.

在執行以後,模塊的exports屬性被返回給了調用方。exports屬性上的任何方法和屬性均可以被外部調用到。但模塊中的其他變量或屬性則不可直接被調用。

那麼存在exports的狀況下,爲什麼存在module.exports。理想狀況下,只要賦值給exports便可

exports = function () {
  // My Class
};

可是會獲得一個失敗的結果。

CommonJS規範規定,每一個模塊內部,module變量表明當前模塊。這個變量是一個對象,它的exports屬性(即module.exports)表示當前模塊對外輸出的接口。其餘文件加載該模塊,實際上就是讀取module.exports變量。

爲了方便,Node爲每一個模塊提供一個exports變量,指向module.exports。
若是要達到require引入一個類的效果,賦值給exports會切斷exports與module.exports的聯繫,請賦值給module.exports對象。

// a.js
// exports = function () {};
module.exports = function () {};
// b.js
var a = require('./a.js');
console.log(a);// [Function]

AMD規範與CommonJS規範的兼容性

CommonJS規範加載模塊是同步的,也就是說,只有加載完成,才能執行後面的操做。AMD規範則是非同步加載模塊,容許指定回調函數。因爲Node.js主要用於服務器編程,模塊文件通常都已經存在於本地硬盤,因此加載起來比較快,不用考慮非同步加載的方式,因此CommonJS規範比較適用。可是,若是是瀏覽器環境,要從服務器端加載模塊,這時就必須採用非同步模式,所以瀏覽器端通常採用AMD規範。

AMD規範使用define方法定義模塊

define(['package/lib'], function(lib){
  function foo(){
    lib.log('hello world!');
  }

  return {
    foo: foo
  };
});

AMD規範容許輸出的模塊兼容CommonJS規範,這時define方法須要寫成下面這樣:

define(function (require, exports, module){
  var someModule = require("someModule");
  var anotherModule = require("anotherModule");

  someModule.doTehAwesome();
  anotherModule.doMoarAwesome();

  exports.asplode = function (){
    someModule.doTehAwesome();
    anotherModule.doMoarAwesome();
  };
});

模塊的循環加載

若是發生模塊的循環加載,即A加載B,B又加載A,則B將加載A的不完整版本。

// a.js
exports.x = 'a1';
console.log('a.js ', require('./b.js').x);
exports.x = 'a2';

// b.js
exports.x = 'b1';
console.log('b.js ', require('./a.js').x);
exports.x = 'b2';

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

上面代碼是三個JavaScript文件。其中,a.js加載了b.js,而b.js又加載a.js。這時,Node返回a.js的不完整版本,因此執行結果以下。

$ node main.js       
b.js  a1
a.js  b2
main.js  a2
main.js  b2 // 取的緩存

require.main

require方法有一個main屬性,能夠用來判斷模塊是直接執行,仍是被調用執行。

直接執行的時候(node module.js),require.main屬性指向模塊自己。

require.main === module
// true

調用執行的時候(經過require加載該腳本執行),上面的表達式返回false。

模塊的加載機制

CommonJS模塊的加載機制是,輸入的是被輸出的值的拷貝。也就是說,一旦輸出一個值,模塊內部的變化就影響不到這個值。
下面是一個模塊文件lib.js。

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代碼輸出內部變量counter和改寫這個變量的內部方法incCounter。
加載上面的模塊。

// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;

console.log(counter);  // 3
incCounter();
console.log(counter); // 3

上面代碼說明,counter輸出之後,lib.js模塊內部的變化就影響不到counter了。

require的內部處理流程

最後總結一下require。require命令是CommonJS規範之中,用來加載其餘模塊的命令。它其實不是一個全局命令,而是指向當前模塊的module.require命令,然後者又調用Node的內部命令Module._load。

Module._load = function(request, parent, isMain) {
  // 1. 檢查 Module._cache,是否緩存之中有指定模塊
  // 2. 若是緩存之中沒有,就建立一個新的Module實例
  // 3. 將它保存到緩存
  // 4. 使用 module.load() 加載指定的模塊文件,
  //    讀取文件內容以後,使用 module.compile() 執行文件代碼
  // 5. 若是加載/解析過程報錯,就從緩存刪除該模塊
  // 6. 返回該模塊的 module.exports
};

上面的第4步,採用module.compile()執行指定模塊的腳本,邏輯以下。

Module.prototype._compile = function(content, filename) {
  // 1. 生成一個require函數,指向module.require
  // 2. 加載其餘輔助方法到require
  // 3. 將文件內容放到一個函數之中,該函數可調用 require
  // 4. 執行該函數
};

上面的第1步和第2步,require函數及其輔助方法主要以下。

  1. require(): 加載外部模塊
  2. require.resolve():將模塊名解析到一個絕對路徑
  3. require.main:指向主模塊
  4. require.cache:指向全部緩存的模塊
  5. require.extensions:根據文件的後綴名,調用不一樣的執行函數

一旦require函數準備完畢,整個所要加載的腳本內容,就被放到一個新的函數之中,這樣能夠避免污染全局環境。該函數的參數包括require、module、exports,以及其餘一些參數。

(function (exports, require, module, __filename, __dirname) {
  // YOUR CODE INJECTED HERE!
});

Module._compile方法是同步執行的,因此Module._load要等它執行完成,纔會向用戶返回module.exports的值。

相關文章
相關標籤/搜索