iT邦幫忙

2024 iThome 鐵人賽

DAY 26
1
JavaScript

Don't make JavaScript Just Surpise系列 第 26

模組(Module)的前世今生 - IIFE、CommonJS、AMD、UMD、CMD

  • 分享至 

  • xImage
  •  

站在巨人的肩膀上,能讓我們走得更遠,不管是引用別人寫好的程式碼,或是在多個檔案間相互引用自己寫好的程式碼,我們都得來好好瞭解一下 JS 中的模組的演進歷史與運作。

JS 中的模組 Module 概念,指的就是將程式碼片段視為可獨立執行的一部份,透過這種方式組合程式碼,讓程式碼更易於維護、提高可重用性、易讀性等等。
可以想像,時至今日 JS 已被用於一些大型專案或系統中,如果只是一個單獨的檔案,光是變數名稱衝突、執行順序相依等等問題就能搞的開發者焦頭爛額。
也因此,模組化的概念自然而然被導入。

IIFE

早期,儘管 JS 大多情況相對沒有那麼大規模的運用,但仍有可能希望拆分模組的需求。
在那個時候,由於 var 本身的作用域是函式作用域,最開始是使用 IIFE 加上物件概念的方式來避免全域污染。

var CustomModule = (function() {
  // 存於模組內的變數和函式
  var privateVar = 'bar';

  function privateMethod() {
    return privateVar;
  }

  return {
    foo: privateMethod
  };
})();

console.log(CustomModule.foo());  // "bar"
console.log(typeof privateVar);

這種方式能夠封閉變數於 IIFE 中,透過閉包的概念,僅提供外部固定可操作的方式,其他內部的變數與函式也不會污染外部空間。

其他語言有的命名空間(namespace)概念,也可以透過多層物件包裹的方式來實現,雖然相對粗糙,但確實是那時候的解法。
其中一個缺點是假設一個方法是在一個長串聯的命名空間尾端,則每次要呼叫的時候都要記得那麼長的命名空間鏈,是相當不方便的。

Common.(CJS)

下一個階段算是 2009 年的時候,隨著 Node.js 的推出與發展,被提出用於伺服器端的模組運用標準 Common.js(瀏覽器不支援)。

過往瀏覽器載入多個 JS 檔案時往往使用 <script> 標籤來載入,但後端不像前端這樣有 html 文件和腳本標籤能使用,那該怎麼引入其他 JS 的依賴呢?這就有了 Common.js

最開始叫做 Server.js,因為本來主要目標為伺服器程式開發,後來希望展示與推廣他的泛用性才改名為 Common.js

實際上並沒有一個函式庫叫 Common.js,如上所說,他是一個規範,而 Node.js 於其程式中便依該規範實作了模組的引入與導出。

//這個例子於瀏覽器環境不會跑,要測試請使用 node 環境
//第一個檔案:CustomModule.js,撰寫模組內容
var CustomModule = {
  foo: function() {
    return 'bar';
  }
};

module.exports = CustomModule;

// 第二個檔案,引入模組並使用
var CustomModule = require('./CustomModule');

console.log(CustomModule.foo());  // "bar"

透過 export 關鍵字來決定檔案要匯出的內容,通過 require 來引入檔案為一個物件,引入時也可以直接針對引入物件的函式以同樣名稱進行引入。

特色是對模組的同步引入,必須引入完成後才會往下執行,適用於伺服器端的開發流程,且明確以 require 申明引入對象,能夠較好理解與管理依賴。
(註:Node.js 於 2013 年 5 月由 npm (Node.js 的模組管理器)的作者宣佈廢棄其中 Common.js 的使用)

Browserify

特別提一下由於基於當時 Common.js 規範開發的 Node.js 的模組是無法直接被瀏覽器使用的(引入需要 require 語法,但瀏覽器並不支援),但又有很多好用的函式庫在上面,所以對應的專案 Browserify 應運而生。

本質上 Browserify 是一個模組的打包工具,將本來僅能用於後端的模組,打包生成為一個瀏覽器端可以使用的模組。

打包時必須使用 npm 環境,因為 Browserify 本身也是一個透過 npm 安裝的模組,對模組進行打包後會生成一個瀏覽器可用的檔案,步驟如下:

  1. 持有符合 Common.js 規範的模組
  2. 使用 Browserify 的打包該模組,會有一個適用於瀏覽器的新的檔案(會處理包含相依的程式碼,生成一個新的單一檔案)
  3. 在瀏覽器的使用中引入該打包後的新的檔案

但有些僅用於後端的模組仍無法透過這個方式被瀏覽器使用,因本質上 Node.js 環境和瀏覽器環境就是有所不同,使用時仍須清楚該模組的作用範圍與限制。

AMD(Asynchronous Module Definition)

基於 Common.js 的規範,為了讓 Common.js 規範能更廣泛地被使用,後來對模組該如何被使用出現了分歧,其中一派就是 AMD(Asynchronous Module Definition)

如其名中的「異步」,主要是用於瀏覽器的解決方案。
因為瀏覽器中如果使用同步載入,會造成其他操作的阻塞,所以發展出這個方式來異步引入需要的模組。

最知名的實踐是 require.js

require.js 的使用需要載入 reuqire.js 本身的函式庫,如透過 script tag 載入如下連結

https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js

然後便可以使用這樣的語法

//檔案 1:定義 module A,提供 greet 方法
define('moduleA', [], function() {
    return {
        greet: function() {
            return 'Hello from Module A';
        }
    };
});

//檔案 2:定義 module B,,引入 module A,提供 greet 方法
define('moduleB', ['moduleA'], function(moduleA) {
    return {
        greet: function() {
            return moduleA.greet() + ' and Hello from Module B';
        }
    };
});

//檔案 3:引入 module B,呼叫 B 的 greet 方法
require(['moduleB'], function(moduleB) {
    console.log(moduleB.greet());
    //"Hello from Module A and Hello from Module B"
});

其中 define 用於宣告一個模組, return 中寫明對外公開的用法,require 則用於引入被 define 宣告的模組。
requiredefine 後面的函式執行會等待前面定義的[]模組陣列模組皆載入完畢後再執行。

引入模組的部分,有另一個語法叫做 require.config,能聲明需要使用的模組相對路徑,用起來會像這樣:

require.config({
    paths: {
        'libName': 'libSource Url / Path'
    },
  shim: {
    'libName': {
      exports: 'the function/variable you like to export'
    },
   }
});
require(['libName'], function(libName) {
    //Then you can call libName. things you exported
});

透過這樣的宣告,便能直接在 define 時直接指名 libName,他會辨識到是連結到後面的路徑/網址,另外如果 libName 並不是一個 AMD 模組,而是被預期為同步載入的對象,當我們要以 AMD 的方式載入,需要額外加上 shim 語法,來指名針對該載入對象我們關注的引入對象($)。

通常 AMD 模組會使用 define 來聲明依賴關係,所以要判斷時簡單一點可以這樣判斷,如果真的要嚴格檢查,就是對照上面提供的 AMD 標準,如不符合,就不是 AMD 模組。

AMD 模組提供了一個異步載入模組的方式,讓瀏覽器使用模組不會因同步載入被阻塞,require.js曾是風行一時的瀏覽器模組載入方式。

CMD(Common Module Definition)

基於 Common.jsAMD 的規範,CMD 規範接著推出。比起「異步」載入,CMD 是使用「延遲」載入,僅在需要時才載入。

CMD 的語法也是使用 define 配上 require,但用法與 AMD 略有不同。

define(function(require, exports) {
   var a = require('a')
   if (false) {
      var b = require('b')// will not load b
   }
})

AMD 會在最開始的時候聲明所有要載入的模組,且定義後立刻進行載入。
CMD 則是在內部使用到 require 時才同步載入,若沒有執行該行 require,則不會進行載入,這便是所謂「延遲」載入的行為。

使用 CMD 的範例之一便是 sea.js,一個用於瀏覽器載入模組的載入器。該 repo 的作者有一篇關於發展歷史的文章也很值得一讀,提到他的觀點來看 CJS、AMD、CMD(這個 comment 也值得一看), 與 sea.js 誕生的原因。

UMD(Universal Module Definition)

同樣被 CJS 與 AMD 啟發,UMD 的誕生是為了提供更好的環境相容性。
這個規範提供一種定義模組的方式,使模組能夠被無論是 CJS 或 AMD 方式載入。
對於一個模組的作者而言,無疑是更加方便的一種做法。

實踐上總歸來說我們的模組會回傳一個要被導出的物件/函式,可是 CJS,AMD,CMD 他們都要求一個不一樣的導出方法,那該怎麼辦呢?

這時候就輪到設計模式中的工廠模式(Factory Pattern)出場。
簡單的說,工廠模式用於創建物件,目的是集中了物件創造方式的邏輯,根據不同的條件回傳不同的物件。
UMD 的宣告就用了這種模式。

((global, factory) => {
    if (typeof define === "function" && define.amd) {
        // AMD
        define(["yourModule"], factory);
    } else if (typeof exports === "object") {
        // CommonJS
        module.exports = factory(require("yourModule"));
    } else {
        // Browser global
        root.yourModule = factory(global.yourModule);
    }
})(this, (yourModule) => {
    // Module implementation
    return {
    };
});

可以看到上面的方式對前述的各種模組標準都有對應偵測方式,並依據偵測到是哪種模組載入環境,來使用對應的導出方式。透過這個方式,這個模組便能夠被各種載入環境使用。

轉換

除了相依於環境的特定語法(如瀏覽器或後端特有操作),實際上無論是哪種寫法的模組,背後都依然是 JavaScript,當然,他們是能夠被轉換成相容於其他方式的寫法的。
手動改寫當然是一個辦法,但數量一多可能嫌麻煩,又剛好沒有其他適合的解決方案,網路上其實有一些相互轉換的方式能夠使用,以下提供三種透過 npm 安裝後能使用的方案:


儘管現時點已經是 2024 了,我們有了更好更通用的解決方案:ES 6 所引入的 module 概念,我們會於下篇介紹,但程式的世界永遠不缺 legacy code,那些流傳下來的專案、仍然活著的古董都是我們開發中有可能遇到的對象,甚至我們還需要親自修改。
懂的過往的脈絡,能讓我們在處理這些過往工程時,更得心應手。


上一篇
日期物件(Date)
下一篇
模組(Module)的前世今生 - ES 6 Module
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言