[譯]JS模組化簡史

江米小棗tonylua發表於2019-02-27

原文: https://ponyfoo.com/articles/brief-history-of-modularity


對於 JS 來說,模組化是個現代的概念。本文將快速回溯和總結模組化是如何推動了 JS 世界進化的。我們不會羅列各種全面的清單,而只是展示改變 JS 歷史的主要範例

Script 標籤和閉包

早年間,JS 還只是委身於 HTML <script> 標籤中的內聯程式碼;頂不濟也就是被封裝到專門的指令碼檔案中呼叫,也還是得與其他指令碼共享一個全域性作用域。

在這些檔案或內聯標籤裡面定義的任何變數都被全域性物件 window 收入囊中,由此可能帶來的所有不相關指令碼中的互相汙染,將導致衝突甚至破壞體驗;某個指令碼中的變數可能會在無意之間被全域性中或者其他指令碼中的變數覆蓋。

後來,隨著 web 應用開始變得越來越龐雜,作用域和全域性作用域的危害等概念變得顯而易見而深入人心。立即呼叫函式表示式 (IIFE: Immediately-invoking function expressions)被髮明出來併成為中流砥柱。一個 IIFE 就是把整個或部分 JS 檔案包裹進一個函式,並在對其求值後立即執行。因為 JS 中的每個函式都會建立一個新一級的作用域,所以用 var 宣告的變數就被繫結在所處的 IIFE 中了。歸功於 IIFE,儘管作用域中也有變數提升等效果,但不再會變成隱式宣告的全域性變數了,這避免了定義變數時的脆弱性。

下面的程式碼片段展示了各種形式的 IIFE。除非用window.foo = 'bar' 這種形式定義一個全域性上下文的變數,否則每個 IIFE 中的程式碼都是獨立的。

(function() {
  console.log('IIFE using parenthesis')
})()

~function() {
  console.log('IIFE using a bitwise operator')
}()

void function() {
  console.log('IIFE using the void operator')
}()複製程式碼

通過使用 IIFE 模式,庫就可以通過暴露一個繫結到 window 的變數並在之後對其重用的方式,來建立一個典型的模組了,這避免了全域性命名的空間汙染。以下程式碼片段展示瞭如何用這些 IIFE 中的一種形式來建立一個包含 sum 方法的 mathlib 庫。如果想對 mathlib 庫增加更多模組,就可以把每個模組置於一個 IIFE 中,並將其暴露的方法新增到 mathlib 這個公開介面中;而其他任何東西都留在了元件所定義在的私有函式作用域中了。

void function() {
  window.mathlib = window.mathlib || {}
  window.mathlib.sum = sum

  function sum(...values) {
    return values.reduce((a, b) => a + b, 0)
  }
}()

mathlib.sum(1, 2, 3)
// <- 6複製程式碼

這種模式也在無意之中誘發了 JS 工具的一個萌芽 -- 開發者首次能安全的將所有 IIFE 模組集合到一個檔案中,這減輕了網路負擔。

IIFE 這種實現方法的問題在於,並沒有一個明確的依賴樹。這意味著開發者不得不去特意維護元件的明確順序,以做到模組的依賴必須先於其被載入,還得考慮遞迴的情況。

RequireJS、AngularJS 以及依賴注入

隨著模組系統 Requirejs 以及 AngularJS 中依賴注入機制的出現,這兩者無疑都允許模組明確命名其依賴了。

接下來的例子展示了使用 Requirejs 的 define 函式定義 mathlib/sum.js ;define 是新增到全域性作用域中的,而隨後其回撥的返回值會成為模組的公開介面。

define(function() {
  return sum

  function sum(...values) {
    return values.reduce((a, b) => a + b, 0)
  }
})
複製程式碼

可以用一個 mathlib.js 檔案來彙集庫中的所有函式。在我們的用例中,暫時只有 mathlib/sum,但也可以用同樣的方法列出更多依賴。將這些依賴的檔案路徑列在 define 的第一個陣列引數中,並且將其各自的公開介面作為引數傳入 define 的第二個回撥函式中,注意保持順序一致。

define(['mathlib/sum'], function(sum) {
  return { sum }
})
複製程式碼

這樣就定義好了一個庫,並且能借由 require 函式呼叫了。注意下面片段中是如何處理依賴鏈的。

require(['mathlib'], function(mathlib) {
  mathlib.sum(1, 2, 3)
  // <- 6
})
複製程式碼

這就是 RequireJS 和其固有的依賴樹的優勢 -- 不管應用包含成百還是上千的模組,都不需要小心翼翼的維護一個依賴清單。考慮到我們明確列出了依賴在哪裡被需要,也就排除了為每個元件寫一份如何關聯其他元件的長長的清單的必要性,同時也避免了因此出錯。但排除如此之大的複雜度還僅僅是個副作用,而非其主要好處。

在模組層面描述依賴的明確性,使得元件如何關聯到應用中其他部分變得顯而易見。這種明確性反過來又培育出更大程度的模組化,這在以前是無法做到的,因為難以跟蹤依賴鏈。

RequireJS並非沒有問題。

首先,整個模式圍繞其非同步載入模組的能力執行,如果用在產品部署上,將影響效能。使用非同步載入機制,可能會在程式碼被執行前發起數百個網路請求。亟需對產品構建使用不同的工具實現優化。(譯註:可以手工打包或使用官方的 r.js 實現自動打包等避免這個問題)

其次,需要一個 RequireJS 函式、一個可能很冗長的依賴列表、一個可能有同樣冗長引數的回撥;所有這些只為實現“宣告一個有依賴的模組”一件事,這使得其應用複雜化了,其 API 也顯得不是很直觀。

AngularJS 中的依賴注入(DI - dependency injection)系統有著許多同樣的問題。作為當時一個優雅的解決方案,依靠巧妙的字串解析以避免依賴陣列,使用函式引數名來處理依賴。但是這個機制和程式碼壓縮工具不相容,將導致引數被重新命名成單字元,從而破壞了依賴的注入。

在之後的 AngularJS v1 版本中,引入了一個 build task 來轉換如下的程式碼:

module.factory('calculator', function(mathlib) {
  // …
})
複製程式碼

會轉換為下面這種格式的程式碼,因為包含了明確的依賴列表,就可以安全的使用壓縮工具了。

module.factory('calculator', ['mathlib', function(mathlib) {
  // …
}])
複製程式碼

不用說,之後引入的這個鮮為人知的構建工具,作為一個額外的構建步驟有過度設計之嫌,和帶來的小小好處相比,無論如何都妨礙了該模式的使用。開發者幾乎都會選擇繼續使用熟悉的類 RequireJS 風格來硬編碼依賴陣列。

Node.js 和 CommonJS 的降臨

在由 Node.js 催生的若干創新中,CommonJS 模組系統算得上一個,也被簡稱為 CJS。利用 Node.js 程式可以訪問檔案系統的優勢,CommonJS 標準更加貼近傳統的模組載入機制。在 CommonJS 中,每個檔案都是擁有自己的作用域和上下文的單獨模組。使用一個非同步的 require 函式來載入依賴項,並且可以在該模組生命週期中的任何時候動態呼叫,就像下面這樣:

const mathlib = require('./mathlib')
複製程式碼

和 RequireJS 以及 AngularJS 很像的是,CommonJS 中的依賴也是靠路徑名稱實現的。主要的區別在於,不再需要樣板函式和依賴陣列什麼的了,而是將模組的介面指派到一個繫結的變數中,或是在任何地方由 JS 表示式使用。

與前面提到的兩者不同的是,CommonJS 更加嚴格。在 RequireJS 和 AngularJS 中,每個檔案中可以包含若干個動態定義的模組,而 CommonJS 則限制了每個檔案只能一個模組。同時,RequireJS 有多種宣告模組的途徑,而 AngularJS 則有不同種類的 factories、services、providers 等等 -- 以及幕後和其依賴注入機制緊密耦合的框架本身。相比較而言,CommonJS 描述模組的方式則是唯一的。JS 檔案皆模組,呼叫 require 就載入依賴,並且其介面就是指定給 module.exports 的東西。這帶來了良好的工具化,以及更好的程式碼自省 -- 讓工具也能更容易的找出 CommonJS 模組系統中的層次。

最後,Browserify 被髮明出來,用於在本為 Node.js 伺服器而生的 CommonJS 模組和瀏覽器之間架起了橋樑。使用 browserify 命令列介面程式,並向其傳遞入口模組的路徑,就能將無論多少個模組打包成一個瀏覽器適用的單獨檔案。而 CommonJS 的殺手級特性:npm 包註冊器,為其接管模組載入生態系統起到了決定性作用。

的確,npm 沒有限制為只能有 CommonJS 的模組,甚至也沒規定只能是 JS 包,但 CommonJS 的 JS 包一直並仍將是其主流。指尖輕點之間,數以千計(現在已經有50多萬並仍穩定增長)的包就在你的應用中可用了,加上可以在系統的一大部分重用 Node.js 伺服器端和每種客戶端 web 瀏覽器中程式碼的能力,使其極大的保持了對其他模組系統的競爭優勢。

ES6、import、Babel 和 Webpack

當 ES6 在 2015 年中標準化,加之在此很久之前就已經可以用 Babel 將 ES6 轉換為 ES5 了,一場新的革命旋即展開。ES6 規範包括了一個 JS 原生的模組系統,一般被稱為 ECMAScript Modules (ESM)。

ESM 深受 CJS 及其前輩的影響,提供了一個靜態宣告式 API,以及一個基於 promise 的動態可程式設計 API。如下所示:

import mathlib from './mathlib'
import('./mathlib').then(mathlib => {
  // …
})
複製程式碼

在 ESM 中,和 CJS 一樣,每個檔案都是擁有自己的作用域和上下文的單獨模組。EMS 相比 CJS 的一個主要的優勢是:其具備並被鼓勵使用的靜態匯入依賴的方式。靜態匯入極大改善了模組系統的自省能力,使其可以被靜態化的分析;並有了從系統中每個模組的抽象語法樹(AST - abstract syntax tree)中的詞法層面抽取的能力。

在 Node.js v8.5.0 中,引入了 ESM 模組支援。大部分現代瀏覽器也已經支援。

作為 Browserify 的接班人,Webpack 主要接管了通用模組打包器的角色,這歸功於其具備的大量新特性。正如 Babel 之於 ES6,Webpack 也一直支援著 ESM -- 及包括其 import 和 export宣告語句,也包括動態 import() 函式。Webpack 採用 ESM 並取得了特別豐富的成果,不僅是其引入的“程式碼分割(code-splitting)”機制,更是憑藉能將應用分為不同部分打包的能力提升了首次載入時的使用體驗。

相比於 CJS,考慮到 ESM 作為 JS 這門語言的天然性 -- 在幾年後,有理由期待其全面接管模組生態系統。





-------------------------------------

長按二維碼或搜尋 fewelife 關注我們哦

[譯]JS模組化簡史


相關文章