1. 程式人生 > 實用技巧 >js 模組化之 commonjs

js 模組化之 commonjs

在最初 js 被設計用來做一些表單校驗的簡單功能,當初的 js 只是用來作為頁面展示的一個補充。後來隨著 web 的發展,相當一部分業務邏輯前置到了前端進行處理,js 的地位越來越重要,檔案也越來越龐大,為了將大的功能模組進行拆分成一個一個小的組成部分,但是拆分成小的 js 檔案又帶來了新的挑戰,由於 js 的載入和執行順序在引入的時候就已經決定了,這樣就需要充分考慮到各變數的作用範圍以及各變數之間的依賴關係。

<html>
  <body>
    <script src="a.js"></script>
    <script src="b.js"></script>
    <script src="c.js"></script>
  </body>
</html>

就像上面這樣,a.js 會最先被執行,這樣如果在 b.js 中存在著與 a.js 同名的變數,就會發生覆蓋。同時如果在 c.js 中有使用到 a.js 宣告的變數就決定了 a.js 必須在 c.js 上面被引入。這樣就存在這一種耦合,為了解決這一類問題 js 的模組化應運而生。

commonjs

commonjs 隨著 nodejs 的誕生而面世,主要是用來解決服務端模組化的問題,commonjs 對模組做了如下規定

  • 一個 js 檔案就是一個模組,裡面定義的變數都是私有的,對其他檔案不可見
  • 模組內的需要被匯出的變數可以通過 exports 物件上的屬性進行匯出
  • 使用 require 方法匯入其他模組的變數
  • 所有的模組載入都是同步的
  • 同一個模組可以被多次載入,但是隻有第一次被載入時會執行模組內容,然後會快取模組

node 中的 commonjs 模組

node 中一個檔案就是一個模組,各模組之間的變數是無法互相訪問到的。

// a.js
const a = 1;
// b.js
const b = 2;

console.log(a); // ReferenceError: a is not defined

b.js 中無法訪問到變數 a,如果需要使用 a 需要先匯入模組

// b.js
const a = require('./a.js');

console.log(a) // {}

這裡還是無法訪問到 a 變數是因為模組 a 中沒有匯出對應的變數

// a.js

const a = 1

exports.a = a

// b.js

const a = require('./a.js');

console.log(a); // 1

node 模組中的 module 物件

modulenode 中的一個內建物件,是 Module 類的一個例項, module 物件上有幾個重要屬性

  • module.id 模組的識別符號。 通常是完全解析後的檔名

  • module.loaded 模組是否已經載入完成,或正在載入中

    exports.x = 1;
    console.log(module.loaded) // false 還沒有載入完成
    
    setTimeout(() => {
      console.log(module.loaded) // true
    }, 0)
    
  • module.exports 當前模組對外輸出的介面,其他檔案匯入當前模組實際上就是在讀取當前模組的 module.exports 變數

    除了 module.exports 之外,node 中還提供了一個內建變數 exports,它是 module.exports 的一個引用(可以理解成是一個快捷方式),看一下 exportsmodule.exports 的關係

    /**
     * 實際的引用關係
     * module.exports = {}
     * exports = module.exports
     */
    
    /**
     * 一
     * 這樣做的實際結果就是讓 
     * module.exports = {x: 1}
     */
    exports.x = 1; // {x: 1}
    
    /**
     * 二
     * 同上
     */
    module.exports.x = 1; // {x: 1}
    
    /**
     * 三
     * 雖然最終匯出的內容與上面兩種做法是
     * 一樣的,但是這種做法改變了 
     * module.exports 的原始引用,導
     * 致了 exports 與 module.exports 的
     * 聯絡斷掉了,如果再使用 exports.y = 2
     * 是沒有效果的
     */
    module.exports = { x: 1 }; // {x: 1}
    exports.y = 2; // 無效
    
    /**
     * 四
     * 與上面類似,改變了 exports 的引用
     */
    exports = {x: 1}; // 無效
    module.exports.y = 2; // 2
    

node 模組中的 require 方法

requirenode 模組中的內建方法,該方法用於匯入模組,其函式簽名如下:

interface require {
  /**
   * id  模組的名稱或路徑
   */
  (id: string): any
}

require 方法上有幾個比較重要的屬性和方法

  • require.mainModule 的一個例項,表示當前 node 程序啟動的入口模組

  • require.resolve 是一個方法,用來查詢指定的模組的路徑,如果存在會返回模組的路徑(如果是原生模組,則只會返回原生模組的名稱,例如 http),不存在則會報出錯誤,與 require 不同的是這個方法只會查詢對應的模組路徑,不會執行模組中的程式碼,其函式簽名如下

    interface RequireResolve {
      /**
       * request 指定要查詢的模組路徑
       * options.paths 從 paths 指定的路徑中進行查詢
       */
      (request: string, options: {paths: string[]}): string
    }
    
    // /home/user/a.js
    
    console.log(require.resolve('.b')); // /home/user/b.js
    
    console.log(require.resolve('http')); // http
    
    console.log(require.resolve('./index', {
      paths: ['/home/local/']
    })); // /home/local/index.js
    

    require.resolve 方法與 require 解析檔案路徑的方式是一樣的(後面會做介紹具體的解析過程),會優先檢視是否是原生模組、然後會檢視是否具有快取、然後才是更具不同的副檔名進行查詢

  • require.cache 是一個物件,被引入的模組將被快取在這個物件中,可以手動進行刪除

require 本身的用法

require 可以通過傳入 string 型別的 id 作為入參,id 可以是一個檔案路徑或者是一個模組名稱,路徑可以是一個相對路徑(以 ./ 或者 ../ 開頭)或者是一個絕對路徑(以 / 開頭)。相對路徑的方式比較簡單,會以當前檔案的 __dirname 作為基礎路徑計算出絕對路徑,無論是相對路徑還是絕對路徑都可以是檔案或者資料夾。

i. 檔案載入規則

LOAD_AS_FILE(X)

LOAD_AS_FILE
1. 是否存在 X 檔案,是則優先載入 X
2. 否則會載入 X.js 
3. 否則會載入 X.json
4. 否則會載入 X.node

ii. 資料夾載入規則

LOAD_AS_DIRECTORY(X)

LOAD_AS_DIRECTORY
1. 是否存在 `X/package.json`,是則繼續
    a. `package.json` 是否有 `main` 欄位,無則執行 2,是則執行 b
    b. 載入 `(X + main)` 檔案,規則: `LOAD_AS_FILE(X + main)` ,無則繼續執行 c
    c. 載入 `(X + main)/index`,規則: `LOAD_AS_FILE((X + main)/index)`,無則丟擲錯誤
2. 否則會執行去查詢 `X/index`,規則: `LOAD_AS_FILE(X/index)`

iii. 模組名稱載入規則

id 作為模組名稱會遵守如下優先順序規則進行模組查詢:

  1. 載入內建模組
  2. 載入當前目錄下 node_modules 資料夾中的模組
  3. 載入父級目錄下 node_modules 資料夾中的模組,一直到最頂層

模組快取

模組在第一次被載入之後會快取,多次呼叫同一個模組只會讓模組執行一次。

// a.js
module.exports = {
  name: '張三'
}

// b.js
require('./a.js') // {name: '張三'}
require('./a.js').age = 18
require('./a.js') // {name: '張三', age: 18}

最後一個 require('./a.js') 會輸出 {name: '張三', age: 18} 則說明 a.js 模組只執行了一次,返回的還是最早被快取的物件。如果要強制重新執行被引用的模組程式碼,可以通過刪除快取的方式

// a.js
module.exports = {
  name: '張三'
}

// b.js
require('./a.js') // {name: '張三'}
require('./a.js').age = 18
require('./a.js') // {name: '張三', age: 18}
delete require.cache[require.resolve('./a')]
require('./a.js') // {name: '張三'}

上面的例子還能說明模組的快取是基於檔案路徑進行的,只要在被載入時路徑不一致同一個模組也會執行兩次

迴圈依賴

要說弄清楚這個問題需要先了解 node 中模組載入機制,在 commonjs 模組體系中 require 載入的是一個物件的副本,實際也就是 module.exports 所指向的變數,所以除非是存在引用型別的變數否則模組內部的變化是影響不到外部的。舉個例子說明這個:

// b.js
let count = 1

let countObj = {
  count: 10
}

module.exports = {
  count,
  countObj,
  setCount(newVal) {
    count = newVal
  },
  setCountObj(newVal) {
    countObj.count = newVal
  }
}

// a.js
const moduleB = require('./b.js')

console.log(moduleB.count) // 1
moduleB.setCount(2)
console.log(moduleB.count) // 1

console.log(moduleB.countObj.count) // 10
moduleB.setCountObj(20)
console.log(moduleB.countObj.count) // 20

上面的例子說明了 require 的結果實際是 module.exports 的一個副本,按照這樣的思路迴圈載入的情況下,也就會讀取已經存在 module.exports 上的屬性,如果還存在部分屬性未掛在到 module.exports 上則會讀取不到。

// a.js
console.log('a 開始');
exports.done = false;
const b = require('./b.js');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 結束');

// b.js
console.log('b 開始');
exports.done = false;
const a = require('./a.js');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 結束');

// main.js
console.log('main 開始');
const a = require('./a.js');
const b = require('./b.js');
console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);

main.js 載入 a.js 時, a.js 又載入 b.js。 此時, b.js 會嘗試去載入 a.js。 為了防止無限的迴圈,會返回一個 a.jsexports 物件的 未完成的副本 給 b.js 模組。 然後 b.js 完成載入,並將 exports 物件提供給 a.js 模組。

當 main.js 載入這兩個模組時,它們都已經完成載入。 因此,該程式的輸出會是:

main 開始
a 開始
b 開始
在 b 中,a.done = false
b 結束
在 a 中,b.done = true
a 結束
在 main 中,a.done=true,b.done=true