(三)深入Node.js的模塊機(jī)制

2018-02-24 16:10 更新

專(zhuān)欄的第三篇文章《深入Node.js的模塊機(jī)制》。之前介紹了Node.js安裝的基礎(chǔ)知識(shí),本文將深入Node.js的模塊機(jī)制。

Node.js模塊的實(shí)現(xiàn)

之前在網(wǎng)上查閱了許多介紹Node.js的文章,可惜對(duì)于Node.js的模塊機(jī)制大都著墨不多。在后續(xù)介紹模塊的使用之前,我認(rèn)為有必要深入一下Node.js的模塊機(jī)制。

CommonJS規(guī)范

早在Netscape誕生不久后,JavaScript就一直在探索本地編程的路,Rhino是其代表產(chǎn)物。無(wú)奈那時(shí)服務(wù)端JavaScript走的路均是參考眾多服務(wù)器端語(yǔ)言來(lái)實(shí)現(xiàn)的,在這樣的背景之下,一沒(méi)有特色,二沒(méi)有實(shí)用價(jià)值。但是隨著JavaScript在前端的應(yīng)用越來(lái)越廣泛,以及服務(wù)端JavaScript的推動(dòng),JavaScript現(xiàn)有的規(guī)范十分薄弱,不利于JavaScript大規(guī)模的應(yīng)用。那些以JavaScript為宿主語(yǔ)言的環(huán)境中,只有本身的基礎(chǔ)原生對(duì)象和類(lèi)型,更多的對(duì)象和API都取決于宿主的提供,所以,我們可以看到JavaScript缺少這些功能:

  • JavaScript沒(méi)有模塊系統(tǒng)。沒(méi)有原生的支持密閉作用域或依賴(lài)管理。
  • JavaScript沒(méi)有標(biāo)準(zhǔn)庫(kù)。除了一些核心庫(kù)外,沒(méi)有文件系統(tǒng)的API,沒(méi)有IO流API等。
  • JavaScript沒(méi)有標(biāo)準(zhǔn)接口。沒(méi)有如Web Server或者數(shù)據(jù)庫(kù)的統(tǒng)一接口。
  • JavaScript沒(méi)有包管理系統(tǒng)。不能自動(dòng)加載和安裝依賴(lài)。

于是便有了CommonJS(http://www.commonjs.org)規(guī)范的出現(xiàn),其目標(biāo)是為了構(gòu)建JavaScript在包括Web服務(wù)器,桌面,命令行工具,及瀏覽器方面的生態(tài)系統(tǒng)。

CommonJS制定了解決這些問(wèn)題的一些規(guī)范,而Node.js就是這些規(guī)范的一種實(shí)現(xiàn)。Node.js自身實(shí)現(xiàn)了require方法作為其引入模塊的方法,同時(shí)NPM也基于CommonJS定義的包規(guī)范,實(shí)現(xiàn)了依賴(lài)管理和模塊自動(dòng)安裝等功能。這里我們將深入一下Node.js的require機(jī)制和NPM基于包規(guī)范的應(yīng)用。

簡(jiǎn)單模塊定義和使用

在Node.js中,定義一個(gè)模塊十分方便。我們以計(jì)算圓形的面積和周長(zhǎng)兩個(gè)方法為例,來(lái)表現(xiàn)Node.js中模塊的定義方式。

var PI = Math.PI;
exports.area = function (r) {
    return PI * r * r;
};
exports.circumference = function (r) {
    return 2 * PI * r;
};

將這個(gè)文件存為circle.js,并新建一個(gè)app.js文件,并寫(xiě)入以下代碼:

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

可以看到模塊調(diào)用也十分方便,只需要require需要調(diào)用的文件即可。

在require了這個(gè)文件之后,定義在exports對(duì)象上的方法便可以隨意調(diào)用。Node.js將模塊的定義和調(diào)用都封裝得極其簡(jiǎn)單方便,從API對(duì)用戶(hù)友好這一個(gè)角度來(lái)說(shuō),Node.js的模塊機(jī)制是非常優(yōu)秀的。

模塊載入策略

Node.js的模塊分為兩類(lèi),一類(lèi)為原生(核心)模塊,一類(lèi)為文件模塊。原生模塊在Node.js源代碼編譯的時(shí)候編譯進(jìn)了二進(jìn)制執(zhí)行文件,加載的速度最快。另一類(lèi)文件模塊是動(dòng)態(tài)加載的,加載速度比原生模塊慢。但是Node.js對(duì)原生模塊和文件模塊都進(jìn)行了緩存,于是在第二次require時(shí),是不會(huì)有重復(fù)開(kāi)銷(xiāo)的。其中原生模塊都被定義在lib這個(gè)目錄下面,文件模塊則不定性。

node app.js

由于通過(guò)命令行加載啟動(dòng)的文件幾乎都為文件模塊。我們從Node.js如何加載文件模塊開(kāi)始談起。加載文件模塊的工作,主要由原生模塊module來(lái)實(shí)現(xiàn)和完成,該原生模塊在啟動(dòng)時(shí)已經(jīng)被加載,進(jìn)程直接調(diào)用到runMain靜態(tài)方法。

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

_load靜態(tài)方法在分析文件名之后執(zhí)行

var module = new Module(id, parent);

并根據(jù)文件路徑緩存當(dāng)前模塊對(duì)象,該模塊實(shí)例對(duì)象則根據(jù)文件名加載。

module.load(filename);

實(shí)際上在文件模塊中,又分為3類(lèi)模塊。這三類(lèi)文件模塊以后綴來(lái)區(qū)分,Node.js會(huì)根據(jù)后綴名來(lái)決定加載方法。

  • .js。通過(guò)fs模塊同步讀取js文件并編譯執(zhí)行。
  • .node。通過(guò)C/C++進(jìn)行編寫(xiě)的Addon。通過(guò)dlopen方法進(jìn)行加載。
  • .json。讀取文件,調(diào)用JSON.parse解析加載。

這里我們將詳細(xì)描述js后綴的編譯過(guò)程。Node.js在編譯js文件的過(guò)程中實(shí)際完成的步驟有對(duì)js文件內(nèi)容進(jìn)行頭尾包裝。以app.js為例,包裝之后的app.js將會(huì)變成以下形式:

(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));
});

這段代碼會(huì)通過(guò)vm原生模塊的runInThisContext方法執(zhí)行(類(lèi)似eval,只是具有明確上下文,不污染全局),返回為一個(gè)具體的function對(duì)象。最后傳入module對(duì)象的exports,require方法,module,文件名,目錄名作為實(shí)參并執(zhí)行。

這就是為什么require并沒(méi)有定義在app.js 文件中,但是這個(gè)方法卻存在的原因。從Node.js的API文檔中可以看到還有filename、dirname、module、exports幾個(gè)沒(méi)有定義但是卻存在的變量。其中filename和dirname在查找文件路徑的過(guò)程中分析得到后傳入的。module變量是這個(gè)模塊對(duì)象自身,exports是在module的構(gòu)造函數(shù)中初始化的一個(gè)空對(duì)象({},而不是null)。

在這個(gè)主文件中,可以通過(guò)require方法去引入其余的模塊。而其實(shí)這個(gè)require方法實(shí)際調(diào)用的就是load方法。

load方法在載入、編譯、緩存了module后,返回module的exports對(duì)象。這就是circle.js文件中只有定義在exports對(duì)象上的方法才能被外部調(diào)用的原因。

以上所描述的模塊載入機(jī)制均定義在lib/module.js中。

require方法中的文件查找策略

由于Node.js中存在4類(lèi)模塊(原生模塊和3種文件模塊),盡管require方法極其簡(jiǎn)單,但是內(nèi)部的加載卻是十分復(fù)雜的,其加載優(yōu)先級(jí)也各自不同。

從文件模塊緩存中加載

盡管原生模塊與文件模塊的優(yōu)先級(jí)不同,但是都不會(huì)優(yōu)先于從文件模塊的緩存中加載已經(jīng)存在的模塊。

從原生模塊加載

原生模塊的優(yōu)先級(jí)僅次于文件模塊緩存的優(yōu)先級(jí)。require方法在解析文件名之后,優(yōu)先檢查模塊是否在原生模塊列表中。以http模塊為例,盡管在目錄下存在一個(gè)http/http.js/http.node/http.json文件,require("http")都不會(huì)從這些文件中加載,而是從原生模塊中加載。

原生模塊也有一個(gè)緩存區(qū),同樣也是優(yōu)先從緩存區(qū)加載。如果緩存區(qū)沒(méi)有被加載過(guò),則調(diào)用原生模塊的加載方式進(jìn)行加載和執(zhí)行。

從文件加載

當(dāng)文件模塊緩存中不存在,而且不是原生模塊的時(shí)候,Node.js會(huì)解析require方法傳入的參數(shù),并從文件系統(tǒng)中加載實(shí)際的文件,加載過(guò)程中的包裝和編譯細(xì)節(jié)在前一節(jié)中已經(jīng)介紹過(guò),這里我們將詳細(xì)描述查找文件模塊的過(guò)程,其中,也有一些細(xì)節(jié)值得知曉。

require方法接受以下幾種參數(shù)的傳遞:

  • http、fs、path等,原生模塊。
  • ./mod或../mod,相對(duì)路徑的文件模塊。
  • /pathtomodule/mod,絕對(duì)路徑的文件模塊。
  • mod,非原生模塊的文件模塊。

在進(jìn)入路徑查找之前有必要描述一下module path這個(gè)Node.js中的概念。對(duì)于每一個(gè)被加載的文件模塊,創(chuàng)建這個(gè)模塊對(duì)象的時(shí)候,這個(gè)模塊便會(huì)有一個(gè)paths屬性,其值根據(jù)當(dāng)前文件的路徑計(jì)算得到。我們創(chuàng)建modulepath.js這樣一個(gè)文件,其內(nèi)容為:

console.log(module.paths);

我們將其放到任意一個(gè)目錄中執(zhí)行node modulepath.js命令,將得到以下的輸出結(jié)果。

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

Windows下:

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

可以看出module path的生成規(guī)則為:從當(dāng)前文件目錄開(kāi)始查找node_modules目錄;然后依次進(jìn)入父目錄,查找父目錄下的node_modules目錄;依次迭代,直到根目錄下的node_modules目錄。

除此之外還有一個(gè)全局module path,是當(dāng)前node執(zhí)行文件的相對(duì)目錄(../../lib/node)。如果在環(huán)境變量中設(shè)置了HOME目錄和NODE_PATH目錄的話(huà),整個(gè)路徑還包含NODE_PATH和HOME目錄下的.node_libraries與.node_modules。其最終值大致如下:

[NODE_PATH,HOME/.node_modules,HOME/.node_libraries,execPath/../../lib/node]

下圖是筆者從源代碼中整理出來(lái)的整個(gè)文件查找流程:

簡(jiǎn)而言之,如果require絕對(duì)路徑的文件,查找時(shí)不會(huì)去遍歷每一個(gè)node_modules目錄,其速度最快。其余流程如下:

  1. 從module path數(shù)組中取出第一個(gè)目錄作為查找基準(zhǔn)。
  2. 直接從目錄中查找該文件,如果存在,則結(jié)束查找。如果不存在,則進(jìn)行下一條查找。
  3. 嘗試添加.js、.json、.node后綴后查找,如果存在文件,則結(jié)束查找。如果不存在,則進(jìn)行下一條。
  4. 嘗試將require的參數(shù)作為一個(gè)包來(lái)進(jìn)行查找,讀取目錄下的package.json文件,取得main參數(shù)指定的文件。
  5. 嘗試查找該文件,如果存在,則結(jié)束查找。如果不存在,則進(jìn)行第3條查找。
  6. 如果繼續(xù)失敗,則取出module path數(shù)組中的下一個(gè)目錄作為基準(zhǔn)查找,循環(huán)第1至5個(gè)步驟。
  7. 如果繼續(xù)失敗,循環(huán)第1至6個(gè)步驟,直到module path中的最后一個(gè)值。
  8. 如果仍然失敗,則拋出異常。

整個(gè)查找過(guò)程十分類(lèi)似原型鏈的查找和作用域的查找。所幸Node.js對(duì)路徑查找實(shí)現(xiàn)了緩存機(jī)制,否則由于每次判斷路徑都是同步阻塞式進(jìn)行,會(huì)導(dǎo)致嚴(yán)重的性能消耗。

包結(jié)構(gòu)

前面提到,JavaScript缺少包結(jié)構(gòu)。CommonJS致力于改變這種現(xiàn)狀,于是定義了包的結(jié)構(gòu)規(guī)范(http://wiki.commonjs.org/wiki/Packages/1.0?)。而NPM的出現(xiàn)則是為了在CommonJS規(guī)范的基礎(chǔ)上,實(shí)現(xiàn)解決包的安裝卸載,依賴(lài)管理,版本管理等問(wèn)題。require的查找機(jī)制明了之后,我們來(lái)看一下包的細(xì)節(jié)。

一個(gè)符合CommonJS規(guī)范的包應(yīng)該是如下這種結(jié)構(gòu):

  • 一個(gè)package.json文件應(yīng)該存在于包頂級(jí)目錄下
  • 二進(jìn)制文件應(yīng)該包含在bin目錄下。
  • JavaScript代碼應(yīng)該包含在lib目錄下。
  • 文檔應(yīng)該在doc目錄下。
  • 單元測(cè)試應(yīng)該在test目錄下。

由上文的require的查找過(guò)程可以知道,Node.js在沒(méi)有找到目標(biāo)文件時(shí),會(huì)將當(dāng)前目錄當(dāng)作一個(gè)包來(lái)嘗試加載,所以在package.json文件中最重要的一個(gè)字段就是main。而實(shí)際上,這一處是Node.js的擴(kuò)展,標(biāo)準(zhǔn)定義中并不包含此字段,對(duì)于require,只需要main屬性即可。但是在除此之外包需要接受安裝、卸載、依賴(lài)管理,版本管理等流程,所以CommonJS為package.json文件定義了如下一些必須的字段:

  • name。包名,需要在NPM上是唯一的。不能帶有空格。
  • description。包簡(jiǎn)介。通常會(huì)顯示在一些列表中。
  • version。版本號(hào)。一個(gè)語(yǔ)義化的版本號(hào)(http://semver.org/?),通常為x.y.z。該版本號(hào)十分重要,常常用于一些版本控制的場(chǎng)合。
  • keywords。關(guān)鍵字?jǐn)?shù)組。用于NPM中的分類(lèi)搜索。
  • maintainers。包維護(hù)者的數(shù)組。數(shù)組元素是一個(gè)包含name、email、web三個(gè)屬性的JSON對(duì)象。
  • contributors。包貢獻(xiàn)者的數(shù)組。第一個(gè)就是包的作者本人。在開(kāi)源社區(qū),如果提交的patch被merge進(jìn)master分支的話(huà),就應(yīng)當(dāng)加上這個(gè)貢獻(xiàn)patch的人。格式包含name和email。如:
"contributors": [{
    "name": "Jackson Tian",
    "email": "mail @gmail.com"
    }, {
    "name": "fengmk2",
    "email": "mail2@gmail.com"
}],
  • bugs。一個(gè)可以提交bug的URL地址。可以是郵件地址(mailto:mailxx@domain),也可以是網(wǎng)頁(yè)地址(http://url)。
  • licenses。包所使用的許可證。例如:
"licenses": [{
    "type": "GPLv2",
    "url": "http://www.example.com/licenses/gpl.html",
}]
  • repositories。托管源代碼的地址數(shù)組。
  • dependencies。當(dāng)前包需要的依賴(lài)。這個(gè)屬性十分重要,NPM會(huì)通過(guò)這個(gè)屬性,幫你自動(dòng)加載依賴(lài)的包。

以下是Express框架的package.json文件,值得參考。

{
    "name": "express",
    "description": "Sinatra inspired web development framework",
    "version": "3.0.0alpha1-pre",
    "author": "TJ Holowaychuk 

除了前面提到的幾個(gè)必選字段外,我們還發(fā)現(xiàn)了一些額外的字段,如bin、scripts、engines、devDependencies、author。這里可以重點(diǎn)提及一下scripts字段。包管理器(NPM)在對(duì)包進(jìn)行安裝或者卸載的時(shí)候需要進(jìn)行一些編譯或者清除的工作,scripts字段的對(duì)象指明了在進(jìn)行操作時(shí)運(yùn)行哪個(gè)文件,或者執(zhí)行拿條命令。如下為一個(gè)較全面的scripts案例:

"scripts": {
    "install": "install.js",
    "uninstall": "uninstall.js",
    "build": "build.js",
    "doc": "make-doc.js",
    "test": "test.js",
}

如果你完善了自己的JavaScript庫(kù),使之實(shí)現(xiàn)了CommonJS的包規(guī)范,那么你可以通過(guò)NPM來(lái)發(fā)布自己的包,為NPM上5000+的基礎(chǔ)上再加一個(gè)模塊。

npm publish 

命令十分簡(jiǎn)單。但是在這之前你需要通過(guò)npm adduser命令在NPM上注冊(cè)一個(gè)帳戶(hù),以便后續(xù)包的維護(hù)。NPM會(huì)分析該文件夾下的package.json文件,然后上傳目錄到NPM的站點(diǎn)上。用戶(hù)在使用你的包時(shí),也十分簡(jiǎn)明:

npm install 

甚至對(duì)于NPM無(wú)法安裝的包(因?yàn)槟承┢婀值木W(wǎng)絡(luò)原因),可以通過(guò)github手動(dòng)下載其穩(wěn)定版本,解壓之后通過(guò)以下命令進(jìn)行安裝:

npm install 

只需將路徑指向package.json存在的目錄即可。然后在代碼中require('package')即可使用。

Node.js中的require內(nèi)部流程之復(fù)雜,而方法調(diào)用之簡(jiǎn)單,實(shí)在值得嘆為觀(guān)止。更多NPM使用技巧可以參見(jiàn)http://www.infoq.com/cn/articles/msh-using-npm-manage-node.js-dependence。

Node.js模塊與前端模塊的異同

通常有一些模塊可以同時(shí)適用于前后端,但是在瀏覽器端通過(guò)script標(biāo)簽的載入JavaScript文件的方式與Node.js不同。Node.js在載入到最終的執(zhí)行中,進(jìn)行了包裝,使得每個(gè)文件中的變量天然的形成在一個(gè)閉包之中,不會(huì)污染全局變量。而瀏覽器端則通常是裸露的JavaScript代碼片段。所以為了解決前后端一致性的問(wèn)題,類(lèi)庫(kù)開(kāi)發(fā)者需要將類(lèi)庫(kù)代碼包裝在一個(gè)閉包內(nèi)。以下代碼片段抽取自著名類(lèi)庫(kù)underscore的定義方式。

(function () {
    // Establish the root object, `window` in the browser, or `global` on the server.
    var root = this;
    var _ = function (obj) {
            return new wrapper(obj);
        };
    if (typeof exports !== 'undefined') {
        if (typeof module !== 'undefined' && module.exports) {
            exports = module.exports = _;
        }
        exports._ = _;
    } else if (typeof define === 'function' && define.amd) {
        // Register as a named module with AMD.
        define('underscore', function () {
            return _;
        });
    } else {
        root['_'] = _;
    }
}).call(this);

首先,它通過(guò)function定義構(gòu)建了一個(gè)閉包,將this作為上下文對(duì)象直接call調(diào)用,以避免內(nèi)部變量污染到全局作用域。續(xù)而通過(guò)判斷exports是否存在來(lái)決定將局部變量_綁定給exports,并且根據(jù)define變量是否存在,作為處理在實(shí)現(xiàn)了AMD規(guī)范環(huán)境(http://wiki.commonjs.org/wiki/Modules/AsynchronousDefinition)下的使用案例。僅只當(dāng)處于瀏覽器的環(huán)境中的時(shí)候,this指向的是全局對(duì)象(window對(duì)象),才將_變量賦在全局對(duì)象上,作為一個(gè)全局對(duì)象的方法導(dǎo)出,以供外部調(diào)用。

所以在設(shè)計(jì)前后端通用的JavaScript類(lèi)庫(kù)時(shí),都有著以下類(lèi)似的判斷:

if (typeof exports !== "undefined") {
    exports.EventProxy = EventProxy;
} else {
    this.EventProxy = EventProxy;
}

即,如果exports對(duì)象存在,則將局部變量掛載在exports對(duì)象上,如果不存在,則掛載在全局對(duì)象上。

對(duì)于更多前端的模塊實(shí)現(xiàn)可以參考國(guó)內(nèi)淘寶玉伯的seajs(http://seajs.com/),或者思科杜歡的oye(http://www.w3cgroup.com/oye/)。

參考文獻(xiàn)

以上內(nèi)容是否對(duì)您有幫助:
在線(xiàn)筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)