node學習筆記第八節:模組化

weixin_34402408發表於2018-08-03

早在Netscape誕生不久後,JavaScript就一直在探索本地程式設計的路,Rhino是其代表產物。無奈那時服務端JavaScript走的路均是參考眾多伺服器端語言來實現的,在這樣的背景之下,一沒有特色,二沒有實用價值。但是隨著JavaScript在前端的應用越來越廣泛,以及服務端JavaScript的推動,JavaScript現有的規範十分薄弱,不利於JavaScript大規模的應用。那些以JavaScript為宿主語言的環境中,只有本身的基礎原生物件和型別,更多的物件和API都取決於宿主的提供,所以,我們可以看到JavaScript缺少這些功能:

JavaScript沒有模組系統。沒有原生的支援密閉作用域或依賴管理。
JavaScript沒有標準庫。除了一些核心庫外,沒有檔案系統的API,沒有IO流API等。
JavaScript沒有標準介面。沒有如Web Server或者資料庫的統一介面。
JavaScript沒有包管理系統。不能自動載入和安裝依賴。
於是便有了CommonJS(http://www.commonjs.org)規範的出現,其目標是為了構建JavaScript在包括Web伺服器,桌面,命令列工具,及瀏覽器方面的生態系統。
CommonJS制定瞭解決這些問題的一些規範,而Node.js就是這些規範的一種實現。Node.js自身實現了require方法作為其引入模組的方法,同時NPM也基於CommonJS定義的包規範,實現了依賴管理和模組自動安裝等功能。

一,CommonJS的模組規範

3706354-7798de4731abcf88.png
Node與瀏覽器以及 W3C組織、CommonJS組織、ECMAScript之間的關係

Node借鑑CommonJS的Modules規範實現了一套模組系統,所以先來看看CommonJS的模組規範。

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

1. 模組引用

模組引用的示例程式碼如下:

var math = require('math');

在CommonJS規範中,存在require()方法,這個方法接受模組標識,以此引入一個模組的API到當前上下文中。

2. 模組定義

在模組中,上下文提供require()方法來引入外部模組。對應引入的功能,上下文提供了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;

}; 

在另一個檔案中,我們通過require()方法引入模組後,就能呼叫定義的屬性或方法了:

// program.js

var math = require('math');

exports.increment = function (val) {  return math.add(val, 1);}; 

3.模組標識

模組標識其實就是傳遞給require()方法的引數,它必須是符合小駝峰命名的字串,或者以.、..開頭的相對路徑,或者絕對路徑。它可以沒有檔名字尾.js。模組的定義十分簡單,介面也十分簡潔。它的意義在於將類聚的方法和變數等限定在私有的作用域中,同時支援引入和匯出功能以順暢地連線上下游依賴。每個模組具有獨立的空間,它們互不干擾,在引用時也顯得乾淨利落。

二,Node的模組實現

3706354-ed485272ef1f3fd1.png
node載入模組的具體過程

Node在實現中並非完全按照規範實現,而是對模組規範進行了一定的取捨,同時也增加了少許自身需要的特性。儘管規範中exports、require和module聽起來十分簡單,但是Node在實現它們的過程中究竟經歷了什麼,這個過程需要知曉。
在Node中引入模組,需要經歷如下3個步驟。

1. 路徑分析

2. 檔案定位

3. 編譯執行

在Node中,模組分為兩類:一類是Node提供的模組,稱為核心模組;另一類是使用者編寫的模組,稱為檔案模組

• 核心模組部分在Node原始碼的編譯過程中,編譯進了二進位制執行檔案。在Node程式啟動時,部分核心模組就被直接載入進記憶體中,所以這部分核心模組引入時,檔案定位和編譯執行這兩個步驟可以省略掉,並且在路徑分析中優先判斷,所以它的載入速度是最快的。

• 檔案模組則是在執行時動態載入,需要完整的路徑分析、檔案定位、編譯執行過程,速度比核心模組慢。

1.優先從快取載入

與前端瀏覽器會快取靜態指令碼檔案以提高效能一樣,Node對引入過的模組都會進行快取,以減少二次引入時的開銷。不同的地方在於,瀏覽器僅僅快取檔案,而Node快取的是編譯和執行之後的物件。不論是核心模組還是檔案模組,require()方法對相同模組的二次載入都一律採用快取優先的方式,這是第一優先順序的。不同之處在於核心模組的快取檢查先於檔案模組的快取檢查。
模組載入的優先順序是:快取模組 > 核心模組 > 使用者自定義模組。

2.路徑分析和檔案定位

因為識別符號有幾種形式,對於不同的識別符號,模組的查詢和定位有不同程度上的差異。

a.模組識別符號分析

Node基於一個模組識別符號進行模組查詢。模組識別符號在Node中主要分為以下幾類。

核心模組,如http、fs、path等。
.或..開始的相對路徑檔案模組。
以/開始的絕對路徑檔案模組。
非路徑形式的檔案模組,如自定義的connect模組

• 核心模組

核心模組的優先順序僅次於快取載入,它在Node的原始碼編譯過程中已經編譯為二進位制程式碼,其載入過程最快。如果試圖載入一個與核心模組識別符號相同的自定義模組,那是不會成功的。如果自己編寫了一個http使用者模組,想要載入成功,必須選擇一個不同的識別符號或者換用路徑的方式。

• 路徑形式的檔案模組

以.、..和/開始的識別符號,這裡都被當做檔案模組來處理。在分析路徑模組時,require()方法會將路徑轉為真實路徑,並以真實路徑作為索引,將編譯執行後的結果存放到快取中,以使二次載入時更快。由於檔案模組給Node指明瞭確切的檔案位置,所以在查詢過程中可以節約大量時間,其載入速度慢於核心模組。

• 自定義模組

自定義模組指的是非核心模組,也不是路徑形式的識別符號。它是一種特殊的檔案模組,可能是一個檔案或者包的形式。這類模組的查詢是最費時的,也是所有方式中最慢的一種。

b.檔案定位

從快取載入的優化策略使得二次引入時不需要路徑分析、檔案定位和編譯執行的過程,大大提高了再次載入模組時的效率。但在檔案的定位過程中,還有一些細節需要注意,這主要包括副檔名的分析、目錄和包的處理。

• 副檔名分析

CommonJS模組規範也允許在識別符號中不包含副檔名,這種情況下,Node會按.js、.json、.node的次序補足副檔名,依次嘗試。在嘗試的過程中,需要呼叫fs模組同步阻塞式地判斷檔案是否存在。因為Node是單執行緒的,所以這裡是一個會引起效能問題的地方。小訣竅是:如果是.node和.json檔案,在傳遞給require()的識別符號中帶上副檔名,會加快一點速度。
require載入無檔案型別的優先順序:.js > .json > .node

• 目錄分析和包

在分析識別符號的過程中,require()通過分析副檔名之後,可能沒有查詢到對應檔案,但卻得到一個目錄,此時Node會將目錄當做一個包來處理。

在這個過程中,Node對CommonJS包規範進行了一定程度的支援。首先,Node在當前目錄下查詢package.json(CommonJS包規範定義的包描述檔案),通過JSON.parse()解析出包描述物件,從中取出main屬性指定的檔名進行定位。如果檔名缺少副檔名,將會進入副檔名分析的步驟。而如果main屬性指定的檔名錯誤,或者壓根沒有package.json檔案,Node會將index當做預設檔名,然後依次查詢index.js、index.node、index.json。

如果在目錄分析的過程中沒有定位成功任何檔案,則自定義模組進入下一個模組路徑進行查詢。如果模組路徑陣列都被遍歷完畢,依然沒有查詢到目標檔案,則會丟擲查詢失敗的異常。

c.模組編譯

在Node中,每個檔案模組都是一個物件,它的定義如下:


function Module(id, parent) {  
    this.id = id;  
    this.exports = {};  
    this.parent = parent; 
     if (parent && parent.children) {   
     parent.children.push(this);  
    }  
    this.filename = null; 
     this.loaded = false;  
    this.children = [];

編譯和執行是引入檔案模組的最後一個階段。定位到具體的檔案後,Node會新建一個模組物件,然後根據路徑載入並編譯。對於不同的副檔名,其載入方法也有所不同,具體如下所示。
• .js檔案。

通過fs模組同步讀取檔案後編譯執行。

• .node檔案。

這是用C/C++編寫的擴充套件檔案,通過dlopen()方法載入最後編譯生成的檔案。

• .json檔案。

通過fs模組同步讀取檔案後,用JSON.parse()解析返回結果。

• 其餘副檔名檔案。

它們都被當做.js檔案載入。

每一個編譯成功的模組都會將其檔案路徑作為索引快取在Module._cache物件上,以提高二次引入的效能。

JavaScript模組的編譯

回到CommonJS模組規範,我們知道每個模組檔案中存在著require、exports、module這3個變數,但是它們在模組檔案中並沒有定義,那麼從何而來呢?甚至在Node的API文件中,我們知道每個模組中還有__filename、__dirname這兩個變數的存在,它們又是從何而來的呢?如果我們把直接定義模組的過程放諸在瀏覽器端,會存在汙染全域性變數的情況。

事實上,在編譯的過程中,Node對獲取的JavaScript檔案內容進行了頭尾包裝。在頭部新增了(function (exports, require, module, __filename, __dirname) {\n,在尾部新增了\n});。一個正常的JavaScript檔案會被包裝成如下的樣子:

(function (exports, require, module, __filename, __dirname) {
  var math = require('math');
  exports.area = function (radius) {
    return Math.PI * radius * radius;
  };
});

這樣每個模組檔案之間都進行了作用域隔離。包裝之後的程式碼會通過vm原生模組的runInThisContext()方法執行(類似eval,只是具有明確上下文,不汙染全域性),返回一個具體的function物件。最後,將當前模組物件的exports屬性、require()方法、module(模組物件自身),以及在檔案定位中得到的完整檔案路徑和檔案目錄作為引數傳遞給這個function()執行。

三,包和NPM

在模組之外,包和NPM則是將模組聯絡起來的一種機制。


3706354-4d9a7184019471b5.png
image.png

CommonJS的包規範的定義其實也十分簡單,它由包結構和包描述檔案兩個部分組成,前者用於組織包中的各種檔案,後者則用於描述包的相關資訊,以供外部讀取分析。

包結構

包實際上是一個存檔檔案,即一個目錄直接打包為.zip或tar.gz格式的檔案,安裝後解壓還原為目錄。完全符合CommonJS規範的包目錄應該包含如下這些檔案。

package.json:包描述檔案。
bin:用於存放可執行二進位制檔案的目錄。
lib:用於存放JavaScript程式碼的目錄。
doc:用於存放文件的目錄。
test:用於存放單元測試用例的程式碼。

包描述檔案

包描述檔案用於表達非程式碼相關的資訊,它是一個JSON格式的檔案——package.json,位於包的根目錄下,是包的重要組成部分。而NPM的所有行為都與包描述檔案的欄位息息相關。

這個可以看看NPM官網對package.json的定義規範。

可以通過npm adduser, npm publish把自己的package上傳到npm倉庫。

本文內容源自https://blog.csdn.net/u012422829/article/details/52760981https://blog.csdn.net/qbian/article/details/79367500

相關文章