avatar
threadsinstagram

JavaScript 模組化

Table of Contents

前言

昨天簡單複習了一些 bundler 的基礎知識,今天差不多要進入正題中的「網頁前端工具的前世今生 」。而這個主題的靈感之一,來自於前陣子在社群上看到上面這張由 yoavbls 這個開發者整理的網頁前端工具大亂鬥的關係圖,覺得其中有許多值得探索的地方,決定來梳理一下脈絡。

原本是打算直接介紹各款 bundler、build tool,一直猶豫有沒有需要提到 JavaScript 模組化歷史,因為網路上已經有許多介紹模組化的優質文章,會放在最下方的延伸閱讀先提供讀者參考。

但想想畢竟主題是「前世今生」,要聊這些工具的發展史似乎就離不開模組化歷史,因此會試著盡可能追本溯源,將這些資訊理解消化後來做個筆記。

何謂模組化?

如果你有寫過一些程式碼,不管是什麼語言,你可能會看過諸如 importincluderequire 等語法,這就是所謂模組化的概念,我們能夠將複雜的程式碼拆分成許多邏輯獨立的小塊讓他們各司其職,在需要打造應用時只需要將這些模組們「引入」整合後就能完成需求。

白話地說明的話覺得 Huli 在這篇文章的例子已經夠清楚,可以想像一台手機中含有電池、螢幕、相機等模組,當你今天某部分故障要替換或是做客製化,都只要抽換該模組即可達到需求,而不需要整台手機都打掉重練。

JavaScript 的模組化

雖然說在近代 bundler 的世界觀中,任何檔案都能被視為廣義的模組,但如果要追根究柢,我們就要先聊回「JavaScript 的模組化歷史」。

你可能會想問「為什麼 JavaScript 需要有模組化?」

簡單說的話就是因為「網頁應用越來越複雜」,不可能什麼功能都自己從頭打造,可能會需要引用別人開發好的框架、套件等。就算你可以全部自己重造輪子,那全部都寫在同一支 JavaScript 檔案裡,未來要規模化與維護都會變得困難。

而 JavaScript 這個在當時 1995 年時由 Brendan Eich 在 10 天內開發出來的語言,在一直到 2015 年的 ES6 之前,都沒有所謂模組的標準語法。順帶一提,其他語言如 Python、Java 的 import、C 的 include、就連 CSS 的 @import 都早在 1996 年的 CSS1 中就有初始版本的概念與支援。

因此在這中間的 20 年間的 JavaScript 開發者遇到應用逐漸複雜的狀況後,衍伸出了各種設計模式與各家提出的模組化標準。

上古時代 - Module patterns

這邊參考黃玄在這個簡報中的資料來試著理解過往的模組化歷史。

當我們尚未有工具與設計模式前,就是粗暴地將所有變數、函式寫在一起放在全域:

...
function add(a, b){
    return a + b;
}
function sub(a, b){
    return a - b;
}
...

上面有個顯而易見的缺點是當今天要擴展出不同邏輯的 add 與 sub 時,可能會有命名衝突,因此可以改成用命名空間 (namespace) 模式來封裝:

var FOO = {
  add: function (a, b) {
    return a + b;
  },
  sub: function (a, b) {
    return a - b;
  }
};
 
var BAR = {
  add: function (a, b) {
    return a + b + 5566;
  },
  sub: function (a, b) {
    return a - b - 5566;
  }
};
 
FOO.add(1, 2);
BAR.add(3, 4);

但上面這方式有兩個問題:

  1. 物件屬性容易被改寫:本質上是用一個物件去做包裝,這些內部的屬性及函式都沒有受到保護,很容易就能在其他地方用像是 FOO.add = function(a, b) { return a - b; }; 這樣的方式去改寫掉,此時如果誤用 FOO.add(5, 5) 就會得到 0 而不是預期的 10
  2. global namespace 污染:FOOBAR 仍是全域變數,當今天有個第三方程式中定義了同名的 FOO 那原本的 FOO.add 就會變成 undefined 而出錯:
var FOO = {
  add: function (a, b) {
    return a + b;
  },
  sub: function (a, b) {
    return a - b;
  }
};
 
// 引入第三方程式
var FOO = { foo: 'bar' };
 
console.log(FOO.add); // undefined

為了解決上面提到的第一個問題 —— 物件屬性容易被改寫,有一種解法是可以改寫成 IIFE (立即執行函式) 的方式:

var FOO = (function () {
  var _privateTotal = 0;
 
  var add = function (val) {
    _privateTotal += val;
  };
 
  var getTotal = function () {
    return _privateTotal;
  };
 
  return {
    add: add,
    getTotal: getTotal
  };
})();
 
FOO.add(5);
FOO.add(10);
console.log(FOO.getTotal()); // 15
console.log(FOO._privateTotal); // undefined

上面這種模式透過 IIFE 閉包的特性,讓私有變數 _privateTotal 只能在函式內部的作用域被操作與讀取,因此得到相當程度的保護。

順帶一提,上面這段程式中的寫法,其實有個專有名詞叫 Revealing Module Pattern,其中與一般的 Module Pattern 的差異只在於前者是一種可讀性更高的寫法,先將所有變數與函式定義好,最後 return 的地方才決定要揭露 (reveal) 哪些屬性,也就是如果上面這段改寫成 Module Pattern 會像是這個樣子:

var FOO = (function() {
    var _privateTotal = 0;
 
    return {
        add: function(val) {
            _privateTotal += val;
        },
        getTotal: function() {
            return _privateTotal;
        }
    };
})();

上述這個方式已經解決了模組化中屬性容易被改寫的問題,但仍沒有解決 global namespace 污染的問題,另外如果今天想要讓這些模組互相引用仍不夠方便。明天將會繼續聊聊怎麼解決。

小結

今天簡單地為此系列做了個破題,決定由 JavaScript 的模組化歷史切入,但越深掘歷史後發現總有幾個時間點與邏輯尚未對起來,加上仍在猶豫哪些該講、哪些其實沒那麼重要,像是 script loader、module loader 等這些在 CommonJS 之前的歷史,要把這故事講得好比我想像複雜得多。但有個小心得是現代網頁開發工具的方便,很多時候只是會用,但沒有真的理解為什麼現在長成這個樣子,實際嘗試追本溯源後才開始一窺前人的智慧與努力。

而有些知識點我還沒有完全參透,為了不要誤人子弟,待我繼續研讀與消化後其他故事靜待下回分曉:

  • Script loader、Module loader
  • Node.js / CommonJS 的 server side 模組化開端
  • AMD / CMD / UMD client side 模組化的百家爭鳴
  • Browserify / Webpack 橫空出世解救眾生
  • ESM 的一統天下
  • 早期 bundler 的百家爭鳴:Webpack、Rollup、Parcel
  • 開發體驗的革命:Snowpack、Vite、esbuild
  • 難道只能使出那一招了嗎:Rust-based bundlers

延伸閱讀