Nodejs 模組機制

艾倫先生發表於2017-12-14

nodejs 模組機制

簡單模組定義和使用

在Node.js中,定義一個模組十分方便。我們以計算圓形的面積和周長兩個方法為例,來表現Node.js中模組的定義方式。

var PI = Math.PI;
exports.area = function (r) {
    return PI * r * r;
};
exports.circumference = function (r) {
    return 2 * PI * r;
};
複製程式碼

將這個檔案存為circle.js,然後新建一個app.js檔案,並寫入以下程式碼:

var circle = require('./circle.js');
console.log( 'The area of a circle of radius 4 is ' + circle.area(4));
複製程式碼

可以看到模組呼叫也十分方便,只需要require需要呼叫的檔案即可。

在require了這個檔案之後,定義在exports物件上的方法便可以隨意呼叫。Node.js將模組的定義和呼叫都封裝得極其簡單方便,從API對使用者友好這一個角度來說,Node.js的模組機制是非常優秀的。

關於exports的內容,可以參考之前的文章 exports && module.exports

模組分類

核心模組

核心模組優先順序僅次於快取載入,因此無法載入一個和核心模組識別符號相同的自定義模組。

路徑形式的檔案模組

以"."、".."開頭和"/"開始的識別符號,這裡都被當作檔案模組來處理。require()方法會將路徑轉為真實路徑,並以真實路徑作為索引,並將編譯執行後的結果存放到快取中。

自定義模組(特殊的檔案模組)

自定義模組是指非核心模組,也不是路徑形式的識別符號。它是一種特殊的檔案模組,可能是一個檔案或者包的形式。 模組路徑是Node在定位檔案模組的具體檔案時制定的查詢策略,具體表現為一個路徑組成的陣列(module.paths)。這個路徑由當前目錄開始往上一直到根目錄,Node會逐個嘗試模組路徑中的路徑,直到找到目標檔案未知,若到達根目錄還是沒有找到目標檔案,則會丟擲查詢失敗的異常。當前檔案的目錄越深,模組查詢耗時越多。

模組載入策略

上文中說道,Node.js的模組分為兩類,一類為原生(核心)模組,一類為檔案模組。

原生模組在Node.js原始碼編譯的時候編譯進了二進位制執行檔案,載入的速度最快。另一類檔案模組是動態載入的,載入速度比原生模組慢。但是Node.js對原生模組和檔案模組都進行了快取,於是在第二次require時,是不會有重複開銷的。由於通過命令列載入啟動的檔案幾乎都為檔案模組。我們從Node.js如何載入檔案模組開始談起。

我們從命令列啟動上文的app.js檔案。

node app.js
複製程式碼

載入檔案模組的工作,主要由原生模組module來實現和完成,該原生模組在啟動時已經被載入,程式直接呼叫到runMain靜態方法。

// bootstrap main module.
Module.runMain = function () {
    // Load the main module--the command line argument.
    Module._load(process.argv[1], null, true);
};
複製程式碼

_load靜態方法在分析檔名之後執行

var module = new Module(id, parent);
複製程式碼

並根據檔案路徑快取當前模組物件,該模組例項物件則根據檔名載入。

module.load(filename);
複製程式碼

實際上在檔案模組中,又分為3類模組。這三類檔案模組以字尾來區分,Node.js會根據字尾名來決定載入方法。

  • .js。通過fs模組同步讀取js檔案並編譯執行。
  • .node。通過C/C++進行編寫的Addon。通過dlopen方法進行載入。
  • .json。讀取檔案,呼叫JSON.parse解析載入。

這裡我們將詳細描述js字尾的編譯過程。Node.js在編譯js檔案的過程中實際完成的步驟有對js檔案內容進行頭尾包裝。以app.js為例,包裝之後的app.js將會變成以下形式:

(function (exports, require, module, __filename, __dirname) {
    var circle = require('./circle.js');
    console.log('The area of a circle of radius 4 is ' + circle.area(4));
});
複製程式碼

這段程式碼會通過vm原生模組的runInThisContext方法執行(類似eval,只是具有明確上下文,不汙染全域性),返回為一個具體的function物件。最後傳入module物件的exportsrequire方法,module__filename(檔名),__dirname(目錄名)作為實參並執行。

這就是為什麼require並沒有定義在app.js檔案中,但是這個方法卻存在的原因。從Node.js的API文件中可以看到還有__filename__dirnamemoduleexports幾個沒有定義但是卻存在的變數。

__filename``和__dirname在查詢檔案路徑的過程中分析得到後傳入的。module變數是這個模組物件自身,exports是在module的建構函式中初始化的一個空物件({},而不是null)。

在這個主檔案中,可以通過require方法去引入其餘的模組。而其實這個require方法實際呼叫的就是load方法

load方法在載入、編譯、快取了module後,返回moduleexports物件。這就是circle.js檔案中只有定義在exports物件上的方法才能被外部呼叫的原因。

以上所描述的模組載入機制均定義在lib/module.js中。

require 方法中的檔案查詢策略

儘管require方法極其簡單,但是內部的載入卻是十分複雜的,其載入優先順序也各自不同。

image1.jpg-29.2kB

從檔案載入

當檔案模組快取中不存在,而且不是原生模組的時候,Node.js會解析require方法傳入的引數,並從檔案系統中載入實際的檔案,載入過程中的包裝和編譯細節在前一節中已經介紹過,這裡我們將詳細描述查詢檔案模組的過程,其中,也有一些細節值得知曉。

require方法接受以下幾種引數的傳遞:

  • http、fs、path等,原生模組。
  • ./mod或../mod,相對路徑的檔案模組。
  • /pathtomodule/mod,絕對路徑的檔案模組。
  • mod,非原生模組的檔案模組。

在進入路徑查詢之前有必要描述一下module path這個Node.js中的概念。對於每一個被載入的檔案模組,建立這個模組物件的時候,這個模組便會有一個paths屬性,其值根據當前檔案的路徑計算得到。我們建立modulepath.js這樣一個檔案,其內容為:

console.log(module.paths);
複製程式碼

我們將其放到任意一個目錄中執行node modulepath.js命令,將得到以下的輸出結果(mac的演示結果)。

[ '/Users/beifeng/Desktop/test_node/node_modules',
  '/Users/beifeng/Desktop/node_modules',
  '/Users/beifeng/node_modules',
  '/Users/node_modules',
  '/node_modules' ]
複製程式碼

可以看出module path的生成規則為:從當前檔案目錄開始查詢node_modules目錄;然後依次進入父目錄,查詢父目錄下的node_modules目錄;依次迭代,直到根目錄下的node_modules目錄。

檔案模組查詢流程

image2.jpg-69.9kB

簡而言之,如果require絕對路徑的檔案,查詢時不會去遍歷每一個node_modules目錄,其速度最快。其餘流程如下:

1.從module paths陣列中取出第一個目錄作為查詢基準。 2.直接從目錄中查詢該檔案,如果存在,則結束查詢。如果不存在,則進行下一條查詢 3.嘗試新增.js.json.node字尾後查詢,如果存在檔案,則結束查詢。如果不存在,則進行下一條。 4.嘗試將require的引數作為一個包來進行查詢,讀取目錄下的package.json檔案,取得main引數指定的檔案。 5.嘗試查詢該檔案,如果存在,則結束查詢。如果不存在,則進行第3條查詢。 6.如果繼續失敗,則取出module path陣列中的下一個目錄作為基準查詢,迴圈第1至5個步驟。 7.如果繼續失敗,迴圈第1至6個步驟,直到module paths中的最後一個值。 8.如果仍然失敗,則丟擲異常。

整個查詢過程十分類似原型鏈的查詢和作用域的查詢。所幸Node.js對路徑查詢實現了快取機制,否則由於每次判斷路徑都是同步阻塞式進行,會導致嚴重的效能消耗。

CommonJS規範

JavaScript缺少包結構。CommonJS致力於改變這種現狀,於是定義了包的結構規範(http://wiki.commonjs.org/wiki/Packages/1.0 )。

CommonJS(http://www.commonjs.org)規範的出現,其目標是為了構建JavaScript在包括Web伺服器,桌面,命令列工具,及瀏覽器方面的生態系統。

一個符合CommonJS規範的包應該是如下這種結構:

  • 一個package.json檔案應該存在於包頂級目錄下
  • 二進位制檔案應該包含在bin目錄下。
  • JavaScript程式碼應該包含在lib目錄下。
  • 文件應該在doc目錄下。
  • 單元測試應該在test目錄下。

由上文的require的查詢過程可以知道,Node.js在沒有找到目標檔案時,會將當前目錄當作一個包來嘗試載入,所以在package.json檔案中最重要的一個欄位就是main。而實際上,這一處是Node.js的擴充套件,標準定義中並不包含此欄位,對於require,只需要main屬性即可。

引用文章

相關文章