[譯]JS 模組化歷史簡介

savokiss發表於2019-06-27

對於 JavaScript 來說,模組化是一個相對現代的概念,這篇文章會帶你在 JavaScript 的世界裡快速瀏覽模組化的歷史程式~

Script 標籤和閉包

在早些年間,JavaScript 就是直接寫在 HTML 的 <script> 標籤裡的,最多也就是放在獨立的檔案裡面,而它們也都共享一個全域性作用域。

任何 JS 檔案裡面宣告的變數都會被附加在全域性的 window 物件上,並且還有可能意外覆蓋掉第三方庫中的變數。

隨著 web 應用越來越複雜,共享全域性作用域這種方式的弊端開始顯現,於是 IIFE(立即呼叫函式表示式)就被髮明瞭出來,並且廣為使用。IIFE 就是將一整段程式碼包裹在一個函式中,然後立即執行這個函式。在 JavaScript 中,每個函式都有一個作用域,所以在函式中宣告的變數就只在這個函式中可見。即使有變數提升,變數也不會汙染到全域性作用域中。

下面讓我們看幾個 IIFE 的寫法,每個 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 上繫結一個物件作為名稱空間,這樣就避免了汙染全域性作用域。看下面的程式碼,假如我們要建立一個 mathlib 工具,它有一個 sum 方法。假如這個工具有多個模組,也可以建立多個檔案,每個檔案裡都是一個 IIFE,然後向 window.mathlib 物件中新增方法就可以了:

(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

IIFE 這種方式可以說是模組化的先河,它讓開發者可以將模組放在單獨的檔案中,並且不汙染全域性作用域。

當然 IIFE 也有缺點,它並沒有一個明確的依賴樹,這使得開發者只能自己確保 JS 檔案的載入順序。

RequireJS, AngularJS 和依賴注入

RequireJS 和 AngularJS 的出現,讓我們知道了依賴注入是什麼,即需要用哪個模組,就注入哪個模組。

下面的例子我們先用 RequireJS 的 define 方法定義一個沒有依賴的 mathlib/sum.js 模組:

define(function() {
  return sum

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

然後我們可以建立一個入口模組 mathlib.js 用來集合所有子模組。我們的例子中只有 mathlib/sum 一個子模組,但是你可以在 mathlib 資料夾中隨意擴充套件。下面我們宣告 mathlib 模組的依賴,並將依賴作為形參按順序傳入工廠方法,並返回 mathlib 模組物件:

define(['mathlib/sum'], function(sum) {
  return { sum }
})

好了我們已經定義了一個 mathlib 庫,下面就可以用 require 引入並使用它:

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

RequireJS 在內部維護了一個依賴樹,讓開發者不用關心依賴之間的順序,只需要在需要的地方宣告要載入的模組即可使用。

這種明確地宣告依賴的寫法讓各個模組間的依賴都非常清晰,並且反過來促進了模組化的發展。

但是 RequireJS 並不是沒有缺點。它的整個模式專注於解決非同步載入模組,卻忽略了在生產環境下,非同步載入多個模組造成的網路請求過多等效能影響。如果依賴過多,開發者也將面臨一個很長的依賴陣列和回撥裡面的形參列表。同時它的 API 也不夠直觀,就拿宣告一個含有依賴的模組來說,就有很多種不同的寫法。

AngularJS 的依賴注入系統也面臨同樣的問題。有一個方法可以根據形參名字來解析模組,讓開發者不用再寫那個依賴陣列,但是卻對程式碼壓縮工具不友好,因為壓縮後變數名就變短了,也就找不到相應的依賴。

直到 AngularJS v1 之後,可以通過一種構建任務,將以下程式碼:

module.factory('calculator', function(mathlib) { 
  // … 
})

轉換成可壓縮的帶依賴陣列的程式碼:

module.factory('calculator', ['mathlib', function(mathlib) { 
  // … 
}])

然鵝不得不提的是,用工程師思維新增了這麼一個構建步驟,解決了這個本不應該出現的問題,但是這本身價效比實在是不高,於是大部分開發者還是選擇自己手寫所有的依賴陣列(我當年就是這樣,哈哈)。

Node.js 和 CommonJS

CommonJS 模組系統是 Node.js 中眾多革新的一個,也叫 CJS。得力於 Node.js 可以直接訪問檔案系統,CommonJS 規範更貼近的是傳統的模組載入方式。在 CommonJS 中,每個檔案都是一個模組,並具有自己獨立的作用域。依賴的載入使用一個同步的 require 函式,這個函式可以在模組的任意地方呼叫:

const mathlib = require('./mathlib')

與 RequireJS 和 AngularJS 相似的是, CommonJS 依賴也是與檔案路徑相關聯。但是與它們最大的區別,就是 CommonJS 完全拋棄了包裝函式和依賴陣列,並且require 函式可以像 JS 表示式一樣,在模組的任何地方使用。

在 RequireJS 和 AngularJS 中,你可能有很多動態定義的模組,然而 CommonJS 中的檔案和模組是一一對應的。與此同時,RequireJS 眾多的模組定義方式,與 AngularJS 中的 factory、service、provider 都讓人頭大。與之相反的是,CommonJS 只有一種模組載入方式,一個 JS 檔案就是一個模組,載入依賴只需要用 require,匯出模組只需要將要匯出的值賦給 module.exports。這些優點都讓 CommonJS 模組系統更簡潔和易於使用。

終於,Browserify 作為橋樑,打通了 CommonJS 在 Node.js 和瀏覽器端的鴻溝。它可以將眾多模組打包成一個可在瀏覽器中執行的檔案。而 npm 源的出現,作為 CommonJS 的殺手級功能,基本上確立了模組載入生態中的事實標準。

誠然,npm 主要服務於 CommonJS 模組和 JavaScript 包,由於簡單的模組化語法和可複用性,大量 Node.js 和 web 瀏覽器的包出現在 npm 上,npm 也成為世界上最大的包管理器。

ES6, import, Babel, 和 Webpack

ES6 是在 2015 年被標準化,在此之前 Babel 一直承擔著將 ES6 轉換為 ES5 的角色,一場新的革命正在襲來。ES6 規範中包含了一個原生的模組化系統,一般稱之為 ECMAScript Modules(ESM)。

ESM 受到 CommonJS 和先烈們的影響,提供了一個靜態的宣告式的 API 和一個基於 Promise 的動態載入的 API:

import mathlib from './mathlib'
import('./mathlib').then(mathlib => {
  // …
})

在 ESM 中,每個檔案同樣是一個模組,並且具有自己獨立的作用域和執行環境。ESM 相對 CJS 來說有一個重要的優點:即 ESM 是靜態載入依賴的。靜態載入極大地提高了模組系統的自我檢查能力,使得模組系統可以基於抽象語法樹(AST)作靜態分析,同時 ESM 限制了載入語句必須置於模組頂部,也大大簡化了語法解析和語法檢查。

在 Node.js v8.5.0 中,ESM 已經可以通過一個 flag 開啟。大部分主流的瀏覽器也都可以支援 ESM。

Webpack 作為 Browserify 的繼任者,由於功能強大,基本上坐穩了通用模組打包器老大的位置。像 Babel 支援轉換 ES6 那樣,Webpack 很早就支援了 ESM 的 importexport 語法以及 import() 動態載入函式。並且在 ESM 的基礎上,新增了 code-splitting 功能,可以將應用程式程式碼分割成多個檔案來提升首屏載入體驗。

鑑於 ESM 是原生的模組載入規範,它一統江湖也指日可待了!


原文連結
英文原文連結


歡迎關注我的公眾號:碼力全開(codingonfire)
codingonfire.jpg

相關文章