絕對是講的最清楚的-NodeJS模組系統

雲中歌發表於2022-01-17

highlight: a11y-dark

theme: smartblue

NodeJS目前有兩個系統:一套是CommonJS(簡稱CJS),另一套是ECMAScript modules(簡稱ESM); 本篇內容主要三個話題:

  1. CommonJS的內部原理
  2. NodeJS平臺的ESM模組系統
  3. 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

    在上述例子中雖然myAppdepBdepC都依賴了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函式就能夠意識到其中的風險。下面通過一個例子來講解
    UML 圖.jpg
    有個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會得到以下結果

image.png
從結果可以看到,CommonJS在迴圈依賴所引發的風險。b模組匯入a模組的時候,內容並不是完整的,具體來說他只是反應了b.js模組請求a.js模組時,該模組所處的狀態,而無法反應a.js模組最終載入完畢的一個狀態
下面用一個示例圖來表示這個過程
UML 圖 (1).jpg
下面是具體的流程解釋

  1. 整個流程從main.js開始,這個模組一開始開始匯入a.js模組
  2. a.js首先要做的,是匯出一個名為loaded的值,並把該值設為false
  3. a.js模組要求匯入b.js模組
  4. 與a.js類似,b.js首先也是匯出loaded為false的變數
  5. b.js繼續執行,需要匯入a.js
  6. 由於系統已經開始處理a.js模組了,所以b.js會把a.js已經匯出的內容,立即複製到本模組中
  7. b.js會把自己匯出的loaded值改為false
  8. 由於b已經執行完成,控制權會回到a.js,他會把b.js模組的狀態拷貝一份
  9. a.js繼續執行,修改匯出值loaded為true
  10. 最後就執行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語句,那就會以深度優先的方式遞迴,直到所有的程式碼都解析完畢。
這個過程可細分為三個過程:

  1. 剖析: 找到所有的引入語句,並遞迴從相關檔案中載入每個模組的內容
  2. 例項化: 針對某個匯出的實體,在記憶體中保留一個帶名稱的引入,但暫且不給他賦值。此時還要根據import和export關鍵字建立依賴關係,此時不執行js程式碼
  3. 執行:到了這個階段,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方法,因為這裡使用了迴圈依賴
    image.png
    在上面執行結果中可以看到a.js和b.js都能夠完整的觀察到對方,不同與CommonJS,有模組拿到的狀態是不完整的狀態。

剖析

下面來解析一下其中的過程:
UML 圖 (2).jpg

已上圖為例:

  1. 從main.js開始剖析,首先發現了一條import語句,然後進入a.js
  2. 從a.js開始執行,發現了另外一條import語句,執行b.js
  3. 在b.js開始執行,發現了一條import語句,引入a.js,因為之前a.js已經被依賴過,我們不會再去執行這條路徑
  4. b.js繼續往下執行,發現沒有別的import語句。回到a.js之後,也發現沒有其他的import語句,然後直接回到main.js入口檔案。繼續往下執行,發現要求引入b.js,但是這個模組之前被訪問過了,因此這條路徑不會執行
    經過深度優先的方式,模組依賴關係圖已經形成一個樹狀圖,然後直譯器在通過這個依賴圖執行程式碼
    在這個階段,直譯器要從入口點開始,開始分析各模組之間的依賴關係。這個階段直譯器只關心繫統的import語句,並把這些語句想要引入的模組給載入進來,並以深度優先的方式探索依賴圖。按照這種方法遍歷依賴關係,得到一種樹狀的結構

    例項化

    在這一階段,直譯器會從樹狀結構的底部開始,逐漸向頂部走。沒走到一個模組,它就會尋找該模組所要匯出的所有屬性,並在記憶體中構建一張隱射表,以存放此模組所要匯出的屬性名稱與該屬性即將擁有的取值
    如下圖所示:

流程圖.jpg
從上圖可以看到,模組是按照什麼順序來例項化的

  1. 直譯器首先從b.js模組開始,它發現這個模組要匯出loaded和a
  2. 然後直譯器又分析a.js模組,他發現這個模組要匯出loaded和b
  3. 最後分析main.js模組,他發現這個模組不匯出任何功能
  4. 例項化階段所構造的這套exports隱射圖,只記錄匯出的名稱與該名稱即將擁有的值之間關係,至於這個值本身,既不在本階段初始化。
    走完上述流程後,解析器還需要在執行一遍,這次他會把各模組所匯出的名稱與引入這些的那些模組關聯起來,如下圖所示:

流程圖 (1).jpg
這次的步驟為:

  1. 模組b.js要與模組b.js所匯出的內容相連線,這條連結叫作aModule
  2. 模組a.js要與模組a.js所匯出的內容相連線,這條連結叫作bModule
  3. 最後模組main.js要與模組b.js所匯出的內容相連線
  4. 在這個階段,所有的值並沒有初始化,我們只是建立相應的連結,能夠讓這些連結指向相應的值,至於值本身,需要等到下一階段才能確定

    執行

    這這個階段,系統終於要執行每份檔案裡邊的程式碼。他按照後序的深度優先順序,由下而上的訪問最初那張依賴圖,並逐個執行訪問到的檔案。在本例中,main.js會放在最後執行。這種執行結果保證了,程式在執行主邏輯的時候,各模組所匯出的那些值,全部得到了初始化

UML 圖.jpg
以上圖具體步驟為:

  1. 從b.js開始執行。首先要執行的這行程式碼,會把該模組所匯出的loaded初始化為false
  2. 接下來往下執行,會把aModule複製給a,這個時候a拿到的是一個引用值,這個值就是a.js模組
  3. 然後設定loaded的值為true。這個時候b模組所有的值都全部確定了下來
  4. 現在執行a.js。首先初始化匯出值loaded為false
  5. 接下來將該模組匯出的b屬性值得到初始值,這個值是bModule的引用
  6. 最後把loaded的值改為true。到了這裡,我們就把a.js模組系統匯出的這些屬性所對應的值,最終確定了下來
    走完這些步驟後,系統就可以正式執行main.js檔案,這個時候,各模組所匯出的屬性全都已經求值完畢,由於系統是通過引用而不是複製來引入模組,所以就算模組之間有迴圈依賴關係,每個模組還是能夠完整看到對方的最終狀態

    CommonJS與ESM的區別與互動使用

    這裡講CommonJS和ESM之間幾個重要的區別,以及如何在必要的時候搭配使用這兩種模組

    ESM不支援CommonJS提供的某些引用

    CommonJS提供一些關鍵引用,不受ESM支援,這包括requireexportsmodule.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

相關文章