【Node】詳解模組的實現過程

Jenny_Tong發表於2019-03-08

CommonJS 定義了 module、exports 和 require 模組規範,Node.js 為了實現這個簡單的標準,從底層 C/C++ 內建模組到 JavaScript 核心模組,從路徑分析、檔案定位到編譯執行,經歷了一系列複雜的過程。簡單的瞭解 Node 模組的原理,有利於我們重新認識基於 Node 搭建的框架。

一、CommonJS 模組規範

CommonJS 規範或標準簡單來說是一種理論,它期望 JavaScript 可以具備跨宿主環境執行的能力,不僅可以開發客戶端應用,還可以開發服務端應用、命令列工具、桌面圖形介面應用等。

CommonJS 規範對模組的定義分為三個部分:

  • 模組定義

    在模組中存在module物件代表模組本身,模組上下文提供exports屬性 ,將方法掛載在exports物件上即可以定義匯出方式,例如:

    	// math.js
      exports.add = function(){ //...}
    複製程式碼
  • 模組引用

    module提供require()方法引入外部模組的 API 到當前的上下文中:

      var math = require('math')
    複製程式碼
  • 模組標識

    模組標識實際就是傳遞給require()方法中的引數,可以是按小駝峰(camelCase)命名的字串,也可以是檔案路徑。

Node.js 借鑑了 CommonJS 規範的設計,特別是 CommonJS 的 Modules 規範,實現了一套模組系統,同時 NPM 實現了 CommonJS 的 Packages 規範,模組和包組成了 Node 應用開發的基礎。

二、Node 模組載入原理

上述模組規範看起來十分簡單,只有moduleexportsrequire,但 Node 是如何實現的呢?

需要經歷路徑分析(模組的完整路徑)、檔案定位(副檔名或目錄)、編譯執行三個步驟。

2.1 路徑分析

回顧require()接收 模組標識 作為引數來引入模組,Node 就是基於這個識別符號進行路徑分析。不同的識別符號采用的分析方式是不同的,主要分為一下幾類:

  • Node 提供的核心模組,如 http、fs、path

    核心模組在 Node 原始碼編譯時存為二進位制執行檔案,在 Node 啟動時直接載入到記憶體中,路徑分析中優先判斷,所以載入速度很快,而且也不用後續的檔案定位和編譯執行。

    如果想載入與核心模組同名的自定義模組,如自定義 http 模組,那必須選用不同標誌符或改用路徑方式。

  • 路徑形式的檔案模組.、..相對路徑模組和/絕對路徑模組

    .、..或/開始的識別符號都會當成檔案模組處理,Node 會將require()中的路徑轉為真實路徑作為索引,然後編譯執行。

    由於檔案模組明確了檔案位置,所以縮短了路徑分析時間,載入速度僅慢與核心模組。

  • 自定義模組,即非路徑形式的檔案模組

    即不是核心模組,也不是路徑形式的檔案模組,自定義檔案是特殊的檔案模組,在路徑查詢時 Node 會逐級查詢該模組路徑中的路徑。
    模組路徑查詢策略示例如下:

    // paths.js
    console.log(module.paths)
    
    // Terminal
    $ node paths.js
    [ '/Users/tong/WebstormProjects/testNode/node_modules',
    '/Users/tong/WebstormProjects/node_modules',
    '/Users/tong/node_modules',
    '/Users/node_modules',
    '/node_modules' ]
    
    複製程式碼

從上述示例輸出的模組路徑陣列可以看出,模組的查詢時沿當前路徑向上逐級查詢node_modules目錄,直到目標路徑為止,類似 JS 原型鏈或作用域鏈。路徑越深速度越慢,所以自定義模組載入速度最慢。

快取優先機制:Node 會對引入過的模組進行快取以提高效能,不同於瀏覽器快取的是檔案,Node 快取的是編譯和執行後的物件,所以require()對相同模組的二次載入採用快取優先的方式。這個快取優先是第一優先順序的,比核心模組的優先順序要高!

2.2 檔案定位

模組路徑分析完成後是檔案定位,主要包括副檔名的分析、目錄和包的處理。為了表達的更清晰,將檔案定位分為四個步驟:

  • step1: 補充副檔名

    通常require()中的識別符號是不包含副檔名的,這種情況下,Node會按照 .js、.json、.node 的順序嘗試補充副檔名

    在嘗試補充副檔名時,需要呼叫 fs 模組同步阻塞式判斷檔案是否存在,所以這裡提升效能的小技巧,就是 .json 和 .node 檔案傳遞給require()時帶上副檔名會加快一些速度。

  • step2: 目錄處理查詢 pakage.json

    如果補充副檔名後沒有找到對應檔案,但是得到了一個目錄,此時 Node會將目錄當做一個包處理。依據 CommonJS 包規範的實現,Node 會在目錄下查詢pakage.json(包描述檔案),通過JSON.parse()解析成包描述物件,從中main屬性指定的檔名定位

  • step3: 繼續預設查詢 index 檔案

    如果沒有pakage.json或者main屬性指定的檔名錯誤,那 Node 會將 index 當做預設檔名,依次查詢 index.js、index.json、index.node

  • step4: 進入下一個模組路徑

    在上述目錄分析過程中沒有成功定位時,自定義模組按路徑查詢策略進入上一層node_modules目錄,當整個模組路徑陣列遍歷完畢後沒有定位到檔案,則會丟擲查詢失敗異常。

快取載入的優化策略使得二次引入不需要路徑分析、檔案定位、編譯執行這些過程,而且核心模組也不需要檔案定位的過程,這大大提高了再次載入模組時的效率

2.3 編譯執行

Node 中每個模組都是一個物件,在具體定位到檔案後,Node 會新建該模組物件,然後根據路徑載入並編譯。不同的副檔名載入方法為:

  • .js 檔案: 通過 fs 模組同步讀取後編譯執行
  • .json 檔案: 通過 fs 模組同步讀取後,用JSON.parse()解析並返回結果
  • .node 檔案: 這是用 C/C++ 寫的擴充套件檔案,通過process.dlopen()方法載入最後編譯生成的
  • 其他副檔名: 都被當做 js 檔案載入

載入成功後 Node 會呼叫具體的編譯方式將檔案執行後返回給呼叫者。對於 .json 檔案的編譯最簡單,JSON.parse()解析得到物件後直接賦值給模組物件的exports,而 .node 檔案是C/C++編譯生成的,Node 直接呼叫process.dlopen()載入執行就可以,下面重點介紹 .js 檔案的編譯


在 CommonJS 模組規範中有moduleexportsrequire 這3個變數,在 Node API 文件中每個模組還有 __filename__dirname這兩個變數,但是在模組中沒有定義這些變數,那它們是怎麼產生的呢?

事實上在編譯過程中,Node 對每個 JS 檔案都被進行了封裝,例如一個 JS 檔案會被封裝成如下:

(function (exports, require, module, __filename, __dirname) {
	var math = require('math')
	export.add = function(){ //... }
})
複製程式碼

首先每個模組檔案之間都進行了作用域隔離,通過vm原生模組的runInThisContext()方法(類似 eval)返回一個具體的 function 物件,最後將當前模組物件的exports屬性、require()方法、模組物件本身module、檔案定位時得到的完整路徑__filename檔案目錄__dirname作為引數傳遞給這個 function 執行。模組的exports屬性上的任何方法和屬性都可以被外部呼叫,其餘的則不可被呼叫。

至此,moduleexportsrequire的流程就介紹完了。


曾經困惑過,每個模組都可以使用exports的情況下,為什麼還必須用module.exports

因為exports只是module.exports的一個地址引用,如module.exports 已經具備一些屬性和方法,Node 會忽略exports只匯出 module.exports。所以直接賦值給module.exports會更準確。

編譯成功的模組會將檔案路徑作為索引快取在 Module._cache 物件上,路徑分析時優先查詢快取,提高二次引入的效能。

三、Node 核心模組

總結來說 Node 模組分為Node提供的核心模組和使用者編寫的檔案模組。檔案模組是在執行時動態載入,包括了上述完整的路徑分析、檔案定位、編譯執行這些過程,核心模組在Node原始碼編譯成可執行檔案時存為二進位制檔案,直接載入在記憶體中,所以不用檔案定位和編譯執行。

核心模組分為 C/C++ 編寫的和 JavaScript 編寫的兩部分,在編譯所有 C/C++ 檔案之前,編譯程式需要將所有的 JavaScript 核心模組編譯為 C/C++ 可執行程式碼,編譯成功的則放在 NativeModule._cache物件上,顯然和檔案模組 Module._cache的快取位置不同。

在核心模組中,有些模組由純 C/C++ 編寫的內建模組,主要提供 API 給 JavaScript 核心模組,通常不能被使用者直接呼叫,而有些模組由 C/C++ 完成核心部分,而 JavaScript 實現封裝和向外匯出,如 buffer、fs、os 等。

所以在Node的模組型別中存在依賴層級關係:內建模組(C/C++)—> 核心模組(JavaScript)—> 檔案模組。

使用require()十分的方便,但從 JavaScript 到 C/C++ 的過程十分複雜,總結來說需要經歷 C/C++ 層面內建模組的定義、(JavaScript)核心模組的定義和引入以及(JavaScript)檔案模組的引入。

四、前端模組規範

對比前後端的 JavaScript,瀏覽器端的 JavaScript 需要經歷從同一個伺服器端分發到多個客戶端執行,通過網路載入程式碼,瓶頸在於寬頻;而伺服器端 JavaScript 相同程式碼需要多次執行,通過磁碟載入,瓶頸在於 CPU 和記憶體,所以前後端的 JavaScript 在 Http 兩端的職責完全不用。

Node 模組的引入幾乎是同步的,而前端模組如果同步引入,那指令碼載入需要太長的時間,所以 CommonJS 為後端 JavaScript 制定的規範不適合前端。而後出現 AMD 和 CMD 用於前端應用場景。

4.1 AMD 規範

AMD 即非同步模組定義(Asynchronous Module Definition),模組定義為:

define(id?, dependencies?, factory);
複製程式碼

AMD 模組需要用define明確定義一個模組,其中模組id與依賴dependencies是可選的,factory的內容就是實際程式碼的內容。例如指定一些依賴到模組中:

define(['dep1', 'dep2'], function(){
	// module code
});
複製程式碼

require.js 實現 AMD 規範的模組化,感興趣的可以檢視 require.js 的文件。

4.2 CMD 規範

CMD 模組的定義更加簡單:

 define(factory);
複製程式碼

定義的模組同 Node 模組一樣是隱式包裝,在依賴部分支援動態引入,例如:

 define(function(require, exports, module){
 	// module code
 });
複製程式碼

requireexportsmodule通過形參傳遞給模組,需要依賴模組時直接使用require()引入。

sea.js 實現 AMD 規範的模組化,感興趣的可以檢視 sea.js 的文件。

【Node】詳解模組的實現過程


推薦兩本 Node 的書籍:《Node.js 實戰》主要是使用示例,《深入淺出 Node.js》偏實現原理。

當然我的部落格也會繼續總結更新,下一篇內容會是關於 CommonJS 包規範和 NPM 包管理的內容。

相關文章