CommonJS規範和Nodejs模組機制
早在Netscape誕生不久後,JavaScript就一直在探索本地程式設計的路,Rhino是其代表產物。無奈那時服務端JavaScript走的路均是參考眾多伺服器端語言來實現的,在這樣的背景之下,一沒有特色,二沒有實用價值。但是隨著JavaScript在前端的應用越來越廣泛,以及服務端JavaScript的推動,JavaScript現有的規範十分薄弱,不利於JavaScript大規模的應用。那些以JavaScript為宿主語言的環境中,只有本身的基礎原生物件和型別,更多的物件和API都取決於宿主的提供,所以,我們可以看到JavaScript缺少這些功能:
•JavaScript沒有模組系統。沒有原生的支援密閉作用域或依賴管理。
•JavaScript沒有標準庫。除了一些核心庫外,沒有檔案系統的API,沒有IO流API等。
•JavaScript沒有標準介面。沒有如Web Server或者資料庫的統一介面。
•JavaScript沒有包管理系統。不能自動載入和安裝依賴。
於是便有了CommonJS(
CommonJS制定瞭解決這些問題的一些規範,而Node.js就是這些規範的一種實現。Node.js自身實現了require方法作為其引入模組的方法,同時NPM也基於CommonJS定義的包規範,實現了依賴管理和模組自動安裝等功能。這裡我們將深入一下Node.js的require機制和NPM基於包規範的應用。
簡單模組定義和使用
在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的模組機制是非常優秀的。
模組載入策略
Node.js的模組分為兩類,一類為原生(核心)模組,一類為檔案模組。原生模組在Node.js原始碼編譯的時候編譯進了二進位制執行檔案,載入的速度最快。另一類檔案模組是動態載入的,載入速度比原生模組慢。但是Node.js對原生模組和檔案模組都進行了快取,於是在第二次require時,是不會有重複開銷的。其中原生模組都被定義在lib這個目錄下面,檔案模組則不定性。
node app.js
由於通過命令列載入啟動的檔案幾乎都為檔案模組。我們從Node.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物件的exports,require方法,module,檔名,目錄名作為實參並執行。
這就是為什麼require並沒有定義在app.js 檔案中,但是這個方法卻存在的原因。從Node.js的API文件中可以看到還有__filename、__dirname、module、exports幾個沒有定義但是卻存在的變數。其中__filename和__dirname在查詢檔案路徑的過程中分析得到後傳入的。module變數是這個模組物件自身,exports是在module的建構函式中初始化的一個空物件({},而不是null)。
在這個主檔案中,可以通過require方法去引入其餘的模組。而其實這個require方法實際呼叫的就是load方法。
load方法在載入、編譯、快取了module後,返回module的exports物件。這就是circle.js檔案中只有定義在exports物件上的方法才能被外部呼叫的原因。
以上所描述的模組載入機制均定義在lib/module.js中。
require方法中的檔案查詢策略
由於Node.js中存在4類模組(原生模組和3種檔案模組),儘管require方法極其簡單,但是內部的載入卻是十分複雜的,其載入優先順序也各自不同。
2016-04-18補充:這裡糾正一個錯誤或者說是表義不明確的地方,require返回的始終是module.exports,不是exports。在module.js中有如下的引用
從檔案模組快取中載入
儘管原生模組與檔案模組的優先順序不同,但是都不會優先於從檔案模組的快取中載入已經存在的模組。
從原生模組載入
原生模組的優先順序僅次於檔案模組快取的優先順序。require方法在解析檔名之後,優先檢查模組是否在原生模組列表中。以http模組為例,儘管在目錄下存在一個http/http.js/http.node/http.json檔案,require(“http”)都不會從這些檔案中載入,而是從原生模組中載入。
原生模組也有一個快取區,同樣也是優先從快取區載入。如果快取區沒有被載入過,則呼叫原生模組的載入方式進行載入和執行。
從檔案載入
當檔案模組快取中不存在,而且不是原生模組的時候,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命令,將得到以下的輸出結果。
[ '/home/jackson/research/node_modules', '/home/jackson/node_modules', '/home/node_modules', '/node_modules' ]
Windows下:
[ 'c:\\nodejs\\node_modules', 'c:\\node_modules' ]
可以看出module path的生成規則為:從當前檔案目錄開始查詢node_modules目錄;然後依次進入父目錄,查詢父目錄下的node_modules目錄;依次迭代,直到根目錄下的node_modules目錄。
除此之外還有一個全域性module path,是當前node執行檔案的相對目錄(../../lib/node)。如果在環境變數中設定了HOME目錄和NODE_PATH目錄的話,整個路徑還包含NODE_PATH和HOME目錄下的.node_libraries與.node_modules。其最終值大致如下:
[NODE_PATH,HOME/.node_modules,HOME/.node_libraries,execPath/../../lib/node]
下圖是筆者從原始碼中整理出來的整個檔案查詢流程:
簡而言之,如果require絕對路徑的檔案,查詢時不會去遍歷每一個node_modules目錄,其速度最快。其餘流程如下:
- 從module path陣列中取出第一個目錄作為查詢基準。
- 直接從目錄中查詢該檔案,如果存在,則結束查詢。如果不存在,則進行下一條查詢。
- 嘗試新增.js、.json、.node字尾後查詢,如果存在檔案,則結束查詢。如果不存在,則進行下一條。
- 嘗試將require的引數作為一個包來進行查詢,讀取目錄下的package.json檔案,取得main引數指定的檔案。
- 嘗試查詢該檔案,如果存在,則結束查詢。如果不存在,則進行第3條查詢。
- 如果繼續失敗,則取出module path陣列中的下一個目錄作為基準查詢,迴圈第1至5個步驟。
- 如果繼續失敗,迴圈第1至6個步驟,直到module path中的最後一個值。
- 如果仍然失敗,則丟擲異常。
整個查詢過程十分類似原型鏈的查詢和作用域的查詢。所幸Node.js對路徑查詢實現了快取機制,否則由於每次判斷路徑都是同步阻塞式進行,會導致嚴重的效能消耗。
包結構
前面提到,JavaScript缺少包結構。CommonJS致力於改變這種現狀,於是定義了包的結構規範(http://wiki.commonjs.org/wiki/Packages/1.0 )。而NPM的出現則是為了在CommonJS規範的基礎上,實現解決包的安裝解除安裝,依賴管理,版本管理等問題。require的查詢機制明瞭之後,我們來看一下包的細節。
一個符合CommonJS規範的包應該是如下這種結構:
- 一個package.json檔案應該存在於包頂級目錄下
- 二進位制檔案應該包含在bin目錄下。
- JavaScript程式碼應該包含在lib目錄下。
- 文件應該在doc目錄下。
- 單元測試應該在test目錄下。
由上文的require的查詢過程可以知道,Node.js在沒有找到目標檔案時,會將當前目錄當作一個包來嘗試載入,所以在package.json檔案中最重要的一個欄位就是main。而實際上,這一處是Node.js的擴充套件,標準定義中並不包含此欄位,對於require,只需要main屬性即可。但是在除此之外包需要接受安裝、解除安裝、依賴管理,版本管理等流程,所以CommonJS為package.json檔案定義瞭如下一些必須的欄位:
- name。包名,需要在NPM上是唯一的。不能帶有空格。
- description。包簡介。通常會顯示在一些列表中。
- version。版本號。一個語義化的版本號(http://semver.org/ ),通常為x.y.z。該版本號十分重要,常常用於一些版本控制的場合。
- keywords。關鍵字陣列。用於NPM中的分類搜尋。
- maintainers。包維護者的陣列。陣列元素是一個包含name、email、web三個屬性的JSON物件。
- contributors。包貢獻者的陣列。第一個就是包的作者本人。在開源社群,如果提交的patch被merge進master分支的話,就應當加上這個貢獻patch的人。格式包含name和email。如:
"contributors": [{ "name": "Jackson Tian", "email": "mail @gmail.com" }, { "name": "fengmk2", "email": "[email protected]" }],
- bugs。一個可以提交bug的URL地址。可以是郵件地址(mailto:[email protected]),也可以是網頁地址(http://url)。
- licenses。包所使用的許可證。例如:
"licenses": [{ "type": "GPLv2", "url": "http://www.example.com/licenses/gpl.html", }]
- repositories。託管原始碼的地址陣列。
- dependencies。當前包需要的依賴。這個屬性十分重要,NPM會通過這個屬性,幫你自動載入依賴的包。
以下是Express框架的package.json檔案,值得參考。
{ "name": "express", "description": "Sinatra inspired web development framework", "version": "3.0.0alpha1-pre", "author": "TJ Holowaychuk
除了前面提到的幾個必選欄位外,我們還發現了一些額外的欄位,如bin、scripts、engines、devDependencies、author。這裡可以重點提及一下scripts欄位。包管理器(NPM)在對包進行安裝或者解除安裝的時候需要進行一些編譯或者清除的工作,scripts欄位的物件指明瞭在進行操作時執行哪個檔案,或者執行拿條命令。如下為一個較全面的scripts案例:
"scripts": { "install": "install.js", "uninstall": "uninstall.js", "build": "build.js", "doc": "make-doc.js", "test": "test.js", }
如果你完善了自己的JavaScript庫,使之實現了CommonJS的包規範,那麼你可以通過NPM來發布自己的包,為NPM上5000+的基礎上再加一個模組。
npm publish <folder>
命令十分簡單。但是在這之前你需要通過npm adduser命令在NPM上註冊一個帳戶,以便後續包的維護。NPM會分析該資料夾下的package.json檔案,然後上傳目錄到NPM的站點上。使用者在使用你的包時,也十分簡明:
npm install <package>
甚至對於NPM無法安裝的包(因為某些奇怪的網路原因),可以通過github手動下載其穩定版本,解壓之後通過以下命令進行安裝:
npm install <package.json folder>
只需將路徑指向package.json存在的目錄即可。然後在程式碼中require('package')即可使用。
Node.js模組與前端模組的異同
通常有一些模組可以同時適用於前後端,但是在瀏覽器端通過script標籤的載入JavaScript檔案的方式與Node.js不同。Node.js在載入到最終的執行中,進行了包裝,使得每個檔案中的變數天然的形成在一個閉包之中,不會汙染全域性變數。而瀏覽器端則通常是裸露的JavaScript程式碼片段。所以為了解決前後端一致性的問題,類庫開發者需要將類庫程式碼包裝在一個閉包內。以下程式碼片段抽取自著名類庫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);
首先,它通過function定義構建了一個閉包,將this作為上下文物件直接call呼叫,以避免內部變數汙染到全域性作用域。續而通過判斷exports是否存在來決定將區域性變數_繫結給exports,並且根據define變數是否存在,作為處理在實現了AMD規範環境(http://wiki.commonjs.org/wiki/Modules/AsynchronousDefinition)下的使用案例。僅只當處於瀏覽器的環境中的時候,this指向的是全域性物件(window物件),才將_變數賦在全域性物件上,作為一個全域性物件的方法匯出,以供外部呼叫。
所以在設計前後端通用的JavaScript類庫時,都有著以下類似的判斷:
if (typeof exports !== "undefined") { exports.EventProxy = EventProxy; } else { this.EventProxy = EventProxy; }
即,如果exports物件存在,則將區域性變數掛載在exports物件上,如果不存在,則掛載在全域性物件上。