《程式設計時間簡史系列》JavaScript 模組化的歷史程式

然後去遠足發表於2020-06-24

引言

昨天在思否上閒逛,發現了一個有意思的問題(點此傳送)。

因為這個問題,我產生了寫一個系列文章的想法,試圖從站在歷史的角度上來看待程式設計世界中林林總總的問題和解決方案。

目前中文網路上充斥著大量互相“轉載”的內容,基本是某一個技術問題的解決方案(what? how?),卻不涉及為什麼這麼做和歷史緣由(why? when?)。比如你要搜 “JavaScript 有哪些模組化方案?它們有什麼區別?”,能得到一萬個有用的結果;但要想知道 “為什麼 JavaScript 有這麼多模組化方案?它們是誰建立的?”,卻幾乎不可能。

因此,這一系列文章內會盡可能的不涉及具體程式碼,只談歷史故事。但會在文末提供包含部分程式碼的參考連結,以供感興趣的朋友自行閱讀。

這個系列暫定為十篇文章,內容會涉及前端、後端、程式語言、開發工具、作業系統等等。也給自己立個 Flag,在今年年底之前把整個系列寫完。如果沒完成目標……就當我沒說過這句話(逃

全系列索引:

  1. 《程式設計時間簡史系列》JavaScript 模組化的歷史程式
  2. 《程式設計時間簡史系列》Web Server 編年史

正文

模組化,是前端繞不過去的話題。

隨著 Node.js 和三大框架的流行,越來越多的前端開發者們腦海中都會時常浮現一個問題:

為什麼 JavaScript 有這麼多模組化方案?

自從 1995 年 5 月,Brendan Eich 寫下了第一行 JavaScript 程式碼起,JavaScript 已經誕生了 25 年。

但這門語言早期僅僅作為輕量級的指令碼語言,用於在 Web 上與使用者進行少量的互動,並沒有依賴管理的概念。

隨著 AJAX 技術得以廣泛使用,Web 2.0 時代迅猛發展,瀏覽器承載了愈來愈多的內容與邏輯,JavaScript 程式碼越來越複雜,全域性變數衝突、依賴管理混亂等問題始終縈繞在前端開發者們的心頭。此時,JavaScript 亟需一種在其他語言中早已得到良好應用的功能 —— 模組化。

其實,JavaScript 本身的標準化版本 ECMAScript 6.0 (ES6/ES2015) 中,已經提供了模組化方案,即 ES Module。但目前在 Node.js 體系下,最常見的方案其實是 CommonJS。再加上大家耳熟能詳的 AMDCMDUMD,模組化的事實標準如此之多。

那麼為什麼有如此之多的模組化方案?它們又是在怎樣的背景下誕生的?為什麼沒有一個方案 “千秋萬代,一統江湖”?

接下來,我會按照時間順序講述模組化的發展歷程,順帶也就回答了上述幾個問題。

萌芽初現:從 YUI Library 和 jQuery 說起

時間回到 2006 年 1 月,當時還是國際網際網路巨頭的 Yahoo(雅虎),開源了其內部使用已久的元件庫 YUI Library

YUI Library 採用了類似於 Java 名稱空間的方式,來隔離各個模組之間的變數,避免全域性變數造成的衝突。其寫法類似於:

YUI.util.module.doSomthing();

這種寫法無論是封裝還是呼叫時都十分繁瑣,而且當時的 IDE 對於 JavaScript 來說智慧感知非常弱,開發者很難知道他需要的某個方法存在於哪個名稱空間下,經常需要頻繁地查閱開發手冊,導致開發體驗十分不友好。

在 YUI 釋出之後不久,John Resig 釋出了 jQuery。當時年僅 23 歲的他,不會知道自己這一時興起在 BarCamp 會議上寫下的程式碼,將佔據未來十幾年的 Web 領域。

jQuery 使用了一種新的組織方式,它利用了 JavaScript 的 IIFE(立即執行函式表示式)和閉包的特性,將所依賴的外部變數傳給一個包裝了自身程式碼的匿名函式,在函式內部就可以使用這些依賴,最後在函式的結尾把自身暴露給 window。這種寫法被很多後來的框架所模仿,其寫法類似於:

(function(root){
    // balabala
    root.jQuery = root.$ = jQuery;
})(window);

這種寫法雖然靈活性大大提升,可以很方便地新增擴充套件,但它並未解決根本問題:所需依賴還是得外部提前提供,還是會增加全域性變數。

從以上的嘗試中,可以歸納出 JavaScript 模組化需要解決哪些問題:

  1. 如何給模組一個唯一標識?
  2. 如何在模組中使用依賴的外部模組?
  3. 如何安全地(不汙染模組外程式碼)包裝一個模組?
  4. 如何優雅地(不增加全域性變數)把模組暴漏出去?

圍繞著這些問題,JavaScript 模組化開始了一段曲折的探索之路。

探索之路:CommonJS 與 Node.js 的誕生

讓我們來到 2009 年 1 月,此時距離 ES6 釋出尚有 5 年的時間,但前端領域已經迫切地需要一套真正意義上的模組化方案,以解決全域性變數汙染和依賴管理混亂等問題。

Mozilla 旗下的工程師 Kevin Dangoor,在工作之餘,與同事們一起制訂了一套 JavaScript 模組化的標準規範,並取名為 ServerJS

ServerJS 最早用於服務端 JavaScript,旨在為配合自動化測試等工作而提供模組匯入功能。

這裡插一句題外話,其實早期 1995 年,Netsacpe(網景)公司就提供了有在服務端執行 JavaScript 能力的產品,名為 Netscape Enterprise Server。但此時服務端能做的 JavaScript 還是基於瀏覽器來實現的,本身沒有脫離其自帶的 API 範圍。直到 2009 年 5 月,Node.js 誕生,賦予了其檔案系統、I/O 流、網路通訊等能力,才真正意義上的成為了一門服務端程式語言。

2009 年年初,Ryan Dahl 產生了創造一個跨平臺程式設計框架的想法,想要基於 Google(谷歌)的 Chromium V8 引擎來實現。經過幾個月緊張的開發工作,在 5 月中旬,Node.js 首個預覽版本的開發工作已全部結束。同年 8 月,歐洲 JSConf 開發者大會上,Node.js 驚豔亮相。

但在此刻,Node.js 還沒有一款包管理工具,外部依賴依然要手動下載到專案目錄內再引用。歐洲 JSConf 大會結束後,Isaac Z. Schlueter 注意到了 Node.js,兩人一拍即合,決定開發一款包管理工具,也就是後來大名鼎鼎的 Node Package Manager(即 npm)。

在開發之初,擺在二人面前的第一個問題就是,採用何種模組化方案?。二人江目光鎖定在了幾個月前(2009 年 4 月)在華盛頓特區舉辦的美國 JSConf 大會上公佈的 ServerJS。此時的 ServerJS 已經更名為 CommonJS,並重新制訂了標準規範,即Modules/1.0,展現了更大的野心,企圖一統所有程式語言的模組化方案。

具體來說,Modules/1.0標準規範包含以下內容:

  1. 模組的標識應遵循一定的書寫規則。
  2. 定義全域性函式 require(dependency),通過傳入模組標識來引入其他依賴模組,執行的結果即為別的模組暴漏出來的 API。
  3. 如果被 require 函式引入的模組中也包含外部依賴,則依次載入這些依賴。
  4. 如果引入模組失敗,那麼 require 函式應該丟擲一個異常。
  5. 模組通過變數 exports 來向外暴露 API,exports 只能是一個 object 物件,暴漏的 API 須作為該物件的屬性。

由於這個規範簡單而直接,Node.jsnpm 很快就決定採用這種模組化的方案。至此,第一個 JavaScript 模組化方案正式登上了歷史舞臺,成為前端開發中必不可少的一環。

需要注意的是,CommonJS 是一系列標準規範的統稱,它包含了多個版本,從最早 ServerJS 時的 Modules/0.1,到更名為 CommonJS 後的 Modules/1.0,再到現在成為主流的 Modules/1.1。這些規範有很多具體的實現,且不只侷限於 JavaScript 這一種語言,只要遵循了這一規範,都可以稱之為 CommonJS。其中,Node.js 的實現叫做 Common Node ModulesCommonJS 的其他實現,感興趣的朋友可以閱讀本文最下方的參考連結。

值得一提的是,CommonJS 雖然沒有進入 ECMAScript 標準範圍內,但 CommonJS 專案組的很多成員,也都是 TC39(即制訂 ECMAScript 標準的委員會組織)的成員。這也為日後 ES6 引入模組化特性打下了堅實的基礎。

分道揚鑣:CommonJS 歷史路口上的抉擇

在推出 Modules/1.0 規範後,CommonJSNode.js 等環境下取得了很不錯的實踐。

但此時的 CommonJS 有兩個重要問題沒能得到解決,所以遲遲不能推廣到瀏覽器上:

  1. 由於外層沒有 function 包裹,被匯出的變數會暴露在全域性中。
  2. 在服務端 require 一個模組,只會有磁碟 I/O,所以同步載入機制沒什麼問題;但如果是瀏覽器載入,一是會產生開銷更大的網路 I/O,二是天然非同步,就會產生時序上的錯誤。

因此,社群意識到,要想在瀏覽器環境中也能順利使用 CommonJS,勢必重新制訂新的標準規範。但新的規範怎麼制訂,成為了激烈爭論的焦點,分歧和衝突由此誕生,逐步形成了三大流派:

  • Modules/1.x 派:這派的觀點是,既然 Modules/1.0 已經在伺服器端有了很好的實踐經驗,那麼只需要將它移植到瀏覽器端就好。在瀏覽器載入模組之前,先通過工具將模組轉換成瀏覽器能執行的程式碼了。我們可以理解為他們是“保守派”。
  • Modules/Async 派:這派認為,既然瀏覽器環境於伺服器環境差異過大,那麼就不應該繼續在 Modules/1.0 的基礎上小修小補,應該遵循瀏覽器本身的特點,放棄 require 方式改為回撥,將同步載入模組變為非同步載入模組,這樣就可以通過 ”下載 -> 回撥“ 的方式,避免時序問題。我們可以理解為他們是“激進派”。
  • Modules/2.0 派:這派同樣也認為不應該沿用 Modules/1.0,但也不向激進派一樣過於激進,認為 require 等規範還是有可取之處,不應該隨隨便便放棄,而是要儘可能的保持一致;但激進派的優點也應該吸收,比如 exports 也可以匯出其他型別、而不僅侷限於 object 物件。我們可以理解為他們是“中間派”。

其中保守派的思路跟今天通過 babel 等工具,將 JavaScript 高版本程式碼轉譯為低版本程式碼如出一轍,主要目的就是為了相容。有了這種想法,這派人馬提出了 Modules/Transport 規範,用於規定模組如何轉譯。browserify 就是這一觀點下的產物。

激進派也提出了自己的規範 Modules/AsynchronousDefinition,奈何這一派的觀點並沒有得到 CommonJS 社群的主流認可。

中間派同樣也有自己的規範 Modules/Wrappings,但這派人馬最後也不了了之,沒能掀起什麼風浪。

激進派、中間派與保守派的理念不和,最終為 CommonJS 社群分裂埋下伏筆。

百家爭鳴:激進派 —— AMD 的崛起

激進派的 James Burke 在 2009 年 9 月開發出了 RequireJS 這一模組載入器,以實踐證明自己的觀點。

但激進派的想法始終得不到 CommonJS 社群主流認可。雙方的分歧點主要在於執行時機問題,Modules/1.0 是延遲載入、且同一模組只執行一次,而 Modules/AsynchronousDefinition 卻是提前載入,加之破壞了就近宣告(就近依賴)原則,還引入了 define 等新的全域性函式,雙方的分歧越來越大。

最終,在 James BurkeKarl Westin 等人的帶領下,激進派於同年年底宣佈離開 CommonJS 社群,自立門戶。

激進派在離開社群後,起初專注於 RequireJS 的開發工作,並沒有過多的涉足社群工作,也沒有此草新的標準規範。

2011 年 2 月,在 RequireJS 的擁躉們的共同努力下,由 Kris Zyp 起草的 Async Module Definition(簡稱 AMD)標準規範正式釋出,並在 RequireJS 社群的基礎上建立了 AMD 社群。

AMD 標準規範主要包含了以下幾個內容:

  1. 模組的標識遵循 CommonJS Module Identifiers
  2. 定義全域性函式 define(id, dependencies, factory),用於定義模組。dependencies 為依賴的模組陣列,在 factory 中需傳入形參與之一一對應。
  3. 如果 dependencies 的值中有 requireexportsmodule,則與 CommonJS 中的實現保持一致。
  4. 如果 dependencies 省略不寫,則預設為 ['require', 'exports', 'module']factory 中也會預設傳入三者。
  5. 如果 factory 為函式,模組可以通過以下三種方式對外暴漏 API:return 任意型別;exports.XModule = XModulemodule.exports = XModule
  6. 如果 factory 為物件,則該物件即為模組的匯出值。

其中第三、四兩點,即所謂的 Modules/Wrappings,是因為 AMD 社群對於要寫一堆回撥這種做法頗有微辭,最後 RequireJS 團隊妥協,搞出這麼個部分相容支援。

因為 AMD 符合在瀏覽器端開發的習慣方式,也是第一個支援瀏覽器端的 JavaScript 模組化解決方案,RequireJS 迅速被廣大開發者所接受。

但有 CommonJS 珠玉在前,很多開發者對於要寫很多回撥的方式頗有微詞。在呼籲高漲聲中,RequireJS 團隊最終妥協,搞出個 Simplified CommonJS wrapping(簡稱 CJS)的相容方式,即上文的第三、四兩點。但由於背後實際還是 AMD,所以只是寫法上做了相容,實際上並沒有真正做到 CommonJS 的延遲載入。

CommonJS 規範有眾多實現不同的是,AMD 只專注於 JavaScript 語言,且實現並不多,目前只有 RequireJSDojo Toolkit,其中後者已經停止維護。

一波三折:中間派 —— CMD 的衰落

由於 AMD 的提前載入的問題,被很多開發者擔心會有效能問題而吐槽。

例如,如果一個模組依賴了十個其他模組,那麼在本模組的程式碼執行之前,要先把其他十個模組的程式碼都執行一遍,不管這些模組是不是馬上會被用到。這個效能消耗是不容忽視的。

為了避免這個問題,上文提到,中間派試圖保留 CommonJS 書寫方式和延遲載入、就近宣告(就近依賴)等特性,並引入非同步載入機制,以適配瀏覽器特性。

其中一位中間派的大佬 Wes Garland,本身是 CommonJS 的主要貢獻者之一,在社群中很受尊重。他在 CommonJS 的基礎之上,起草了 Modules/2.0,並給出了一個名為 BravoJS 的實現。

另一位中間派大佬 @khs4473 提出了 Modules/Wrappings,並給出了一個名為 FlyScript 的實現。

Wes Garland 本人是學院派,理論功底十分紮實,但寫出的作品卻既不優雅也不實用。而實戰派的 @khs4473 則在與 James Burke 發生了一些爭論,最後刪除了自己的 GitHub 倉庫並停掉了 FlyScript 官網。

到此為止,中間一派基本已全軍覆滅,空有理論,沒有實踐。

讓我們前進到 2011 年 4 月,國內阿里巴巴集團的前端大佬玉伯(本名王保平),在給 RequireJS 不斷提出建議卻被拒絕之後,萌生了自己寫一個模組載入器的想法。

在借鑑了 CommonJSAMD 等模組化方案後,玉伯寫出了 SeaJS,不過這一實現並沒有嚴格遵守 Modules/Wrappings 的規範,所以嚴格來說並不能稱之為 Modules/2.0。在此基礎上,玉伯提出了 Common Module Definition(簡稱 CMD)這一標準規範。

CMD 規範的主要內容與 AMD 大致相同,不過保留了 CommonJS 中最重要的延遲載入、就近宣告(就近依賴)特性。

隨著國內網際網路公司之間的技術交流,SeaJS 在國內得到了廣泛使用。不過在國外,也許是因為語言障礙等原因,並沒有得到非常大範圍的推廣。

相容並濟:UMD 的統一

2014 年 9 月,美籍華裔 Homa Wong 提交了 UMD 第一個版本的程式碼。

UMDUniversal Module Definition 的縮寫,它本質上並不是一個真正的模組化方案,而是將 CommonJSAMD 相結合。

UMD 作出瞭如下內容的規定:

  1. 優先判斷是否存在 exports 方法,如果存在,則採用 CommonJS 方式載入模組;
  2. 其次判斷是否存在 define 方法,如果存在,則採用 AMD 方式載入模組;
  3. 最後判斷 global 物件上是否定義了所需依賴,如果存在,則直接使用;反之,則丟擲異常。

這樣一來,模組開發者就可以使自己的模組同時支援 CommonJSAMD 的匯出方式,而模組使用者也無需關注自己依賴的模組使用的是哪種方案。

姍姍來遲:欽定的 ES6/ES2015

時間前進到 2016 年 5 月,經過了兩年的討論,ECMAScript 6.0 終於正式通過決議,成為了國際標準。

在這一標準中,首次引入了 importexport 兩個 JavaScript 關鍵字,並提供了被稱為 ES Module 的模組化方案。

在 JavaScript 出生的第 21 個年頭裡,JavaScript 終於迎來了屬於自己的模組化方案。

但由於歷史上的先行者已經佔據了優勢地位,所以 ES Module 遲遲沒有完全替換上文提到的幾種方案,甚至連瀏覽器本身都沒有立即作出支援。

2017 年 9 月上旬,Chrome 61.0 版本釋出,首次在瀏覽器端原生支援了 ES Module

2017 年 9 月中旬,Node.js 迅速跟隨,釋出了 8.5.0,以支援原生模組化,這一特性被稱之為 ECMAScript Modules(簡稱 MJS)。不過到目前為止,這一特性還處於試驗性階段。

不過隨著 babelWebpackTypeScript 等工具的興起,前端開發者們已經不再關心以上幾種方式的相容問題,習慣寫哪種就寫哪種,最後由工具統一轉譯成瀏覽器所支援的方式。

因此,預計在今後很長的一段時間裡,幾種模組化方案都會在前端開發中共存。


尾聲

本文以時間線為基準,從作者、社群、理念等幾個維度談到了 JavaScript 模組化的幾大方案。

其實模組化方案遠不止提到的這些,但其他的都沒有這些流行,這裡也就不費筆墨。

文中並沒有提及各個模組化方案是如何實現的,也沒有給出相關的程式碼示例,感興趣的朋友可以自行閱讀下方的參考閱讀連結。

下面我們再總結梳理一下時間線:

時間 事件
1995.05 Brendan Eich 開發 JavaScript。
2006.01 Yahoo 開源 YUI Library,採用名稱空間方式管理模組。
2006.01 John Resig 開發 jQuery,採用 IIFE + 閉包管理模組。
2009.01 Kevin Dangoor 起草 ServerJS,並公佈第一個版本 Modules/0.1
2009.04 Kevin Dangoor 在美國 JSConf 公佈 CommonJS
2009.05 Ryan Dahl 開發 Node.js
2009.08 Ryan Dahl 在歐洲 JSConf 公佈 Node.js
2009.08 Kevin DangoorServerJS 改名為 CommonJS,並起草第二個版本 Modules/1.0
2009.09 James Burke 開發 RequireJS
2010.01 Isaac Z. Schlueter 開發 npm,實現了基於 CommonJS 模組化方案的 Common Node Modules
2010.02 Kris Zyp 起草 AMDAMD/RequireJS 社群成立。
2011.01 玉伯開發 SeaJS,起草 CMDCMD/SeaJS 社群成立。
2014.08 Homa Wong 開發 UMD
2015.05 ES6 釋出,新增特性 ES Module
2017.09 ChromeNode.js 開始原生支援 ES Module

注:文章中的所有人物、事件、時間、地點,均來自於網際網路公開內容,由本人進行蒐集整理,其中如有謬誤之處,還請多多指教。


參考閱讀


首發於 Segmentfault.com,歡迎轉載,轉載請註明來源和作者。

RHQYZ, Write at 2020.06.24.

相關文章