highlight: a11y-dark
theme: smartblue
NodeJS目前有兩個系統:一套是CommonJS(簡稱CJS),另一套是ECMAScript modules(簡稱ESM); 本篇內容主要三個話題:
- CommonJS的內部原理
- NodeJS平臺的ESM模組系統
- CommonJS與ESM的區別;如何在兩套系統進行轉換
首先講講為什麼要有模組系統
為什麼要有模組系統
一門好的語言一定要有模組系統,因為它能為我們解決工程中遇到的基本需求
- 把功能進行模組拆分,能夠讓程式碼更具有條理,更容易理解,能夠讓我們單獨開發並測試各個子模組的功能
- 能夠對功能進行封裝,然後再其他模組能夠直接引入使用,提高複用性
- 實現封裝:只需要對外提供簡單的輸入輸出文件,內部實現能夠對外遮蔽,減少理解成本
- 管理依賴關係:好的模組系統能夠讓開發者根據現有的第三方模組,輕鬆的構建其他模組。另外模組系統能夠讓使用者簡單引入自己想要的模組,並且把依賴鏈上的模組進行引入
剛開始的時候,JavaScript並沒有好的模組系統,頁面主要是通過多個script標籤引入不同的資源。但是隨著系統的逐漸複雜化,傳統的script標籤模式不能滿足業務需求,所以才開始計劃定義一套模組系統,有AMD,UMD等等
NodeJS是執行在後臺的一門服務端語言,相對於瀏覽器的html,缺乏script標籤來引入檔案,完全依賴本地檔案系統的js檔案。於是NodeJS按照CommonJS規範實現了一套模組系統
2015年ES2015規範釋出,到了這個時候,JS才對模組系統有了正式標準,按照這種標準打造的模組系統叫作ESM系統,他讓瀏覽器和服務端在模組的管理方式上更加一致
CommonJS模組
CommonJS規劃中有兩個基本理念:
- 使用者可以通過requeire函式,引入本地檔案系統中的某個模組
通過exports和module.exports兩個特殊變數,對外發布能力
模組載入器
下面來簡單實現一個簡單的模組載入器
首先是載入模組內容的函式,我們把這個函式放在私有作用域裡邊避免汙染全域性環境,然後eval執行該函式function loadModule(filname, module, require) { const wrappedSrc = ` (function (module, exports, require) { ${fs.readFileSync(filename, 'utf-8')} })(module, module.exports, require) ` eval(wrappedSrc) }
在程式碼中我們通過同步方法
readFileSync
來讀取了模組內容。一般來說,在呼叫檔案系統API時,不應該使用同步版本,但是此處確實是使用了這個方式,Commonjs通過同步操作,來保證多個模組能夠安裝正常的依賴順序得到引入
現在在實現require
函式function require(moduleName) { const id = require.resolve(moduleName); if (require.cache[id]) { return require.cache[id].exports } // 模組的後設資料 const module = { exports: {}, id, } require.cache[id] = module; loadModule(id, module, require); // 返回匯出的變數 return module.exports } require.cache = {}; require.resolve = (moduleName) => { // 根據ModuleName解析完整的模組ID }
上面實現了一個簡單的
require
函式,這個自制的模組系統有幾個不走需要解釋- 輸入模組的ModuleName以後,首先要解析出模組的完整路徑(如何解析後面會講到),然後把這個結果儲存在id的變數之中
- 如果該模組已經被載入過了,會立刻返回快取中的結果
- 如果該模板沒有被載入過,那麼就配置一套環境。具體來說,先建立一個
module
變數,讓他包含一個exports的屬性。這個物件的內容,將由模組在匯出API時所使用的的那些程式碼來填充 - 將module物件快取起來
- 執行
loadModule
函式,傳入剛建立的module物件,通過函式將另外一個模組的內容進行掛載 返回另外模組的匯出內容
模組解析演算法
在前面提到解析模組的完整路徑,我們通過傳入模組名,模組解析函式能夠返回模組的對應的完整路徑,接下來通過路徑來載入對應模組的程式碼,並用這個路徑來標識模組的身份。
resolve
函式所用的解析函式主要是處理以下三種情況- 要載入的是不是檔案模組? 如果moduleName以/開頭,那就視為一條絕對路徑,載入時只需要安裝該路徑原樣返回即可。如果moduleName以
./
開頭,那麼就當成一條相對路徑,這樣相對路徑是從請求載入該模組的這個目錄算起的 - 要載入的是不是核心模組 如果
moduleName
不是以/
或者./
開頭,那麼演算法會首先嚐試在NodeJS
的核心模組去尋找 要載入的是不是包模組 如果沒有找到
moduleName
匹配的核心模組,那就從發出載入請求的這個模組開始,逐層向上搜尋名為node_modules
的陌路,看看裡邊有沒有能夠與moduleName
匹配的模組,如果有就載入該模組。如果還沒有,就沿著目錄繼續線上走,並在相應的node_modules
目錄中搜尋,一直到檔案系統的根目錄
通過這種方式就能實現兩個模組依賴不同版本的包,但是仍然能夠正常載入
例如以下目錄結構:myApp - index.js - node_modules - depA - index.js - depB - index.js - node_modules - depA - depC - index.js - node_modules - depA
在上述例子中雖然
myApp
、depB
、depC
都依賴了depA
但是載入進來的確實不同的模組。比如:- 在
/myApp/index.js
中,載入的來源是/myApp/node_modules/depA
- 在
/myApp/node_modules/depB/index.js
, 載入的是/myApp/node_modules/depB/node_modules/depA
在
/myApp/node_modules/depC/index.js
, 載入的是/myApp/node_modules/depC/node_modules/depA
NodeJs之所以能夠把依賴關係管理好,就因為它背後有模組解析演算法這樣一個核心的部分,能夠管理上千個包,而不會發生衝突或出現版本不相容的問題迴圈依賴
很多人覺得迴圈依賴是理論上的設計問題,但是這種問題很可能出現在實際專案中,所以應該知道CommonJS如何處理這種情況的。是看之前實現的require函式就能夠意識到其中的風險。下面通過一個例子來講解
有個mian.js的模組,需要依賴了a.js和b.js兩個模組,同時a.js需要依賴b.js,但是b.js又反過來依賴了a.js,這就造成了迴圈依賴,下面是原始碼:// a.js exports.loaded = false; const b = require('./b'); module.exports = { b, loaded: true } // b.js exports.loaded = false; const a = require('./a') module.exports = { a, loaded: false } // main.js const a = require('./a'); const b = require('./b'); console.log('A ->', JSON.stringify(a)) console.log('B ->', JSON.stringify(b))
執行
main.js
會得到以下結果
從結果可以看到,CommonJS在迴圈依賴所引發的風險。b模組匯入a模組的時候,內容並不是完整的,具體來說他只是反應了b.js模組請求a.js
模組時,該模組所處的狀態,而無法反應a.js
模組最終載入完畢的一個狀態
下面用一個示例圖來表示這個過程
下面是具體的流程解釋
- 整個流程從main.js開始,這個模組一開始開始匯入a.js模組
- a.js首先要做的,是匯出一個名為loaded的值,並把該值設為false
- a.js模組要求匯入b.js模組
- 與a.js類似,b.js首先也是匯出loaded為false的變數
- b.js繼續執行,需要匯入a.js
- 由於系統已經開始處理a.js模組了,所以b.js會把a.js已經匯出的內容,立即複製到本模組中
- b.js會把自己匯出的loaded值改為false
- 由於b已經執行完成,控制權會回到a.js,他會把b.js模組的狀態拷貝一份
- a.js繼續執行,修改匯出值loaded為true
- 最後就執行main.js
上面可以看到由於是同步執行,導致b.js匯入的a.js模組並不是完整的,無法反應b.js的最終應有的狀態。
在上面例子中可以看到,迴圈依賴所產生的的結果,這對大型專案來說,更加嚴重。
使用方法就比較簡單了,篇幅有限就不在這篇文章中進行講解了
ESM
ESM是ECMAScript 2015規範的一部分,這份規範給Javascript制定了統一的模組系統,以適應各種執行環境。ESM和CommonJS的一項重要區別,在於在ES模組是靜態的,也就是說引入模組的語句必須要寫在最頂層。另外受引用的模組只能使用常量字串,不能依賴需要執行期動態求值的表示式。
比如我們不能通過下面方式來引入ES模組
if (condition) {
import module1 from 'module1'
} else {
import module2 from 'module2'
}
而CommonJS能夠根據條件匯入不同的模組
let module = null
if (condition) {
module = require("module1")
} else {
module = require("module2")
}
看起來相對CommonJS更嚴格了一些,但是正是因為這種靜態引入機制,我們能夠對依賴關係進行靜態分析,去除不會執行的邏輯,這個就叫tree-shaking
模組載入過程
要想理解ESM系統的運作原理,以及它處理迴圈依賴的關係,我們需要明白系統是如何解析並執行Javascript程式碼
載入模組的各個階段
直譯器的目標是構建一張圖來描述所要載入的模組之間的依賴關係,這種圖也叫做依賴圖。
直譯器正是通過這種依賴圖,來判斷模組的依賴關係,並決定自己應該按照什麼順序去執行程式碼。例如我們需要執行某個js檔案,那麼直譯器會從入口開始,尋找所有的import語句,如果在尋找過程中又遇到了import語句,那就會以深度優先的方式遞迴,直到所有的程式碼都解析完畢。
這個過程可細分為三個過程:
- 剖析: 找到所有的引入語句,並遞迴從相關檔案中載入每個模組的內容
- 例項化: 針對某個匯出的實體,在記憶體中保留一個帶名稱的引入,但暫且不給他賦值。此時還要根據import和export關鍵字建立依賴關係,此時不執行js程式碼
執行:到了這個階段,NodeJS開始執行程式碼,這能夠讓實際匯出的實體,能夠獲得實際的取值
在CommonJS中,是邊解析依賴,一邊執行檔案。所以當看到require的時候,就代表前面的程式碼已經執行完成。因為require操作不一定要在檔案開頭,而是可以出現在任務地方
但是ESM系統不同,這三個階段是分開的,它必須先把依賴圖完整的構造出來,然後才開始執行程式碼迴圈依賴
在之前提到的CommonJS迴圈依賴的例子,使用ESM的方式進行改造
// a.js import * as bModule from './b.js'; export let loaded = false; export const b = bModule; loaded = true; // b.js import * as aModule from './b.js'; export let loaded = false; export const a = aModule; loaded = true; // main.js import * as a from './a.js'; import * as b from './b.js'; console.log("A =>", a) console.log("B =>", b)
需要注意的是這裡不能是用
JSON.strinfy
方法,因為這裡使用了迴圈依賴
在上面執行結果中可以看到a.js和b.js都能夠完整的觀察到對方,不同與CommonJS,有模組拿到的狀態是不完整的狀態。
剖析
下面來解析一下其中的過程:
已上圖為例:
- 從main.js開始剖析,首先發現了一條import語句,然後進入a.js
- 從a.js開始執行,發現了另外一條import語句,執行b.js
- 在b.js開始執行,發現了一條import語句,引入a.js,因為之前a.js已經被依賴過,我們不會再去執行這條路徑
b.js繼續往下執行,發現沒有別的import語句。回到a.js之後,也發現沒有其他的import語句,然後直接回到main.js入口檔案。繼續往下執行,發現要求引入b.js,但是這個模組之前被訪問過了,因此這條路徑不會執行
經過深度優先的方式,模組依賴關係圖已經形成一個樹狀圖,然後直譯器在通過這個依賴圖執行程式碼
在這個階段,直譯器要從入口點開始,開始分析各模組之間的依賴關係。這個階段直譯器只關心繫統的import語句,並把這些語句想要引入的模組給載入進來,並以深度優先的方式探索依賴圖。按照這種方法遍歷依賴關係,得到一種樹狀的結構例項化
在這一階段,直譯器會從樹狀結構的底部開始,逐漸向頂部走。沒走到一個模組,它就會尋找該模組所要匯出的所有屬性,並在記憶體中構建一張隱射表,以存放此模組所要匯出的屬性名稱與該屬性即將擁有的取值
如下圖所示:
從上圖可以看到,模組是按照什麼順序來例項化的
- 直譯器首先從b.js模組開始,它發現這個模組要匯出loaded和a
- 然後直譯器又分析a.js模組,他發現這個模組要匯出loaded和b
- 最後分析main.js模組,他發現這個模組不匯出任何功能
- 例項化階段所構造的這套exports隱射圖,只記錄匯出的名稱與該名稱即將擁有的值之間關係,至於這個值本身,既不在本階段初始化。
走完上述流程後,解析器還需要在執行一遍,這次他會把各模組所匯出的名稱與引入這些的那些模組關聯起來,如下圖所示:
這次的步驟為:
- 模組b.js要與模組b.js所匯出的內容相連線,這條連結叫作aModule
- 模組a.js要與模組a.js所匯出的內容相連線,這條連結叫作bModule
- 最後模組main.js要與模組b.js所匯出的內容相連線
在這個階段,所有的值並沒有初始化,我們只是建立相應的連結,能夠讓這些連結指向相應的值,至於值本身,需要等到下一階段才能確定
執行
這這個階段,系統終於要執行每份檔案裡邊的程式碼。他按照後序的深度優先順序,由下而上的訪問最初那張依賴圖,並逐個執行訪問到的檔案。在本例中,main.js會放在最後執行。這種執行結果保證了,程式在執行主邏輯的時候,各模組所匯出的那些值,全部得到了初始化
以上圖具體步驟為:
- 從b.js開始執行。首先要執行的這行程式碼,會把該模組所匯出的loaded初始化為false
- 接下來往下執行,會把aModule複製給a,這個時候a拿到的是一個引用值,這個值就是a.js模組
- 然後設定loaded的值為true。這個時候b模組所有的值都全部確定了下來
- 現在執行a.js。首先初始化匯出值loaded為false
- 接下來將該模組匯出的b屬性值得到初始值,這個值是bModule的引用
最後把loaded的值改為true。到了這裡,我們就把a.js模組系統匯出的這些屬性所對應的值,最終確定了下來
走完這些步驟後,系統就可以正式執行main.js檔案,這個時候,各模組所匯出的屬性全都已經求值完畢,由於系統是通過引用而不是複製來引入模組,所以就算模組之間有迴圈依賴關係,每個模組還是能夠完整看到對方的最終狀態CommonJS與ESM的區別與互動使用
這裡講CommonJS和ESM之間幾個重要的區別,以及如何在必要的時候搭配使用這兩種模組
ESM不支援CommonJS提供的某些引用
CommonJS提供一些關鍵引用,不受ESM支援,這包括
require
、exports
、module.exports
、__filename
、__diranme
。如果在ES模組中使用這些,會到程式發生引用錯誤的問題。
在ESM系統中,我們可以通過import.meta這個特殊物件來獲取一個引用,這個引用指的是當前檔案的URL。具體來說,就是通過import.meta.url這種寫法,來獲取當前模組的檔案路徑,這個路徑類似於file: ///path/to/current_module.js
。我們可以根據這條路徑,構造出__filename
和__dirname
所表示的那兩條絕對路徑:import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __dirname = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
CommonJS的require函式,也可以通過用下面這種方法,在ESM模組裡邊進行實現:
import { createRequire } from 'module'; const require = createRequire(import.meta.url)
現在,就可以在ES模組系統的環境下,用這個
require()
函式來載入Commonjs
模組在其中一個模組系統中使用另外一個模組
在上面提到,在ESM模組中使用
module.createRequire
函式來載入commonJS
模組。除了這個方法,其實還可以通過import語言引入CommonJS模組。不過這種方式只會匯出預設匯出的內容;import pkg from 'commonJS-module' import { method1 } from 'commonJS-module' // 會報錯
但是反過來沒辦法,我們沒辦法在
commonJS
中引入ESM
模組
此外ESM不支援把json檔案當成模組進行引入,這在commonjs卻可以輕鬆實現
下面這種import語句,就會報錯import json from 'data.json'
如果需要引入json檔案,還需要藉助
createRequire
函式:import { createRequire } from 'module'; const require = createRequire(import.meta.url); const data = require("./data.json"); console.log(data)
總結
本文主要講解了NodeJS中兩種模組系統是如何工作的,通過了解這些原因能夠幫忙我們編寫避免一些難以排查的問題的bug