探索 JS 中的模組化

Yanjiie發表於2019-02-27

原文連結:http://yanjiie.me

偶然的一個週末複習了一下 JS 的模組標準,重新整理了一下對 JS 模組化的理解。

JS Module

從開始 Coding 以來,總會週期性地突發奇想進行 Code Review。既是對一段時期的程式碼進行總結,也是對那一段時光的懷念。

距離上一次 Review 已經過去近兩個月,這次竟然把兩年前在源續寫的程式碼翻了出來,程式碼雜亂無章的程度就像那時更加浮躁的自己,讓人感慨時光流逝之快。

話不多說,直接上碼。

當時在做的是一個境外電商專案(越南天寶商城),作為非 CS 的新手程式設計師,接觸 Coding 時間不長和工程化觀念不強,在當時的專案中出現了這樣的程式碼:

import.js:

import.js

這段程式碼看起來就是不斷地從 DOM 中插進 CSS 和 JS,雖然寫得很爛,但是很能反映以前的 Web 開發方式。

在 Web 開發中,有一個原則叫“關注點分離(separation of concerns)“,意思是各種技術只負責自己的領域,不互相耦合混合在一起,所以催生出了 HTML、CSS 和 JavaScript。

其中,在 Web 中負責邏輯和互動 的 JavaScript,是一門只用 10 天設計出來的語言,雖然借鑑了許多優秀靜態和動態語言的優點,但卻一直沒有模組 ( module ) 體系。這導致了它將一個大程式拆分成互相依賴的小檔案,再用簡單的方法拼裝起來。其他語言都有這項功能,比如 RubyrequirePythonimport,甚至就連 CSS 都有 @import,但是 JavaScript 任何這方面的支援都沒有。而且 JS 是一種載入即執行的技術,在頁面中插入指令碼時還需要考慮庫的依賴,JS 在這方面的缺陷,對開發大型的、複雜的專案形成了巨大障礙。

發展過程

雖然 JS 本身並不支援模組化,但是這並不能阻擋 JS 走向模組化的道路。既然本身不支援,那麼就從程式碼層面解決問題。活躍的社群開始制定了一些模組方案,其中最主要的是 CommonJS 和 AMD,ES6 規範出臺之後,以一種更簡單的形式制定了 JS 的模組標準 (ES Module),並融合了 CommonJS 和 AMD 的優點。

大致的發展過程:

CommonJS(服務端) => AMD (瀏覽器端) => CMD / UMD => ES Module

CommonJS 規範

2009年,Node.js 橫空出世,JS 得以脫離瀏覽器執行,我們可以使用 JS 來編寫服務端的程式碼了。對於服務端的 JS,沒有模組化簡直是不能忍。

CommonJs (前 ServerJS) 在這個階段應運而生,制定了 Module/1.0 規範,定義了第一版模組標準。

標準內容:

  1. 模組通過變數 exports 來向外暴露 API,exports 只能是一個物件,暴露的 API 須作為此物件的屬性。
  2. 定義全域性函式 require,通過傳入模組標識來引入其他模組,執行的結果即為別的模組暴露出來的 API。
  3. 如果被 require 函式引入的模組中也包含依賴,那麼依次載入這些依賴。

特點:

  1. 模組可以多次載入,首次載入的結果將會被快取,想讓模組重新執行需要清除快取。
  2. 模組的載入是一項阻塞操作,也就是同步載入。

它的語法看起來是這樣的:

// a.js
module.exports = {
  moduleFunc: function() {
    return true;
  };
}
// 或
exports.moduleFunc = function() {
  return true;
};

// 在 b.js 中引用
var moduleA = require('a.js');
// 或
var moduleFunc = require('a.js').moduleFunc;

console.log(moduleA.moduleFunc());
console.log(moduleFunc())
複製程式碼

AMD 規範(Asynchromous Module Definition)

CommonJS 規範出現後,在 Node 開發中產生了非常良好的效果,開發者希望借鑑這個經驗來解決瀏覽器端 JS 的模組化。

但大部分人認為瀏覽器和伺服器環境差別太大,畢竟瀏覽器端 JS 是通過網路動態依次載入的,而不是像服務端 JS 存在本地磁碟中。因此,瀏覽器需要實現的是非同步模組,模組在定義的時候就必須先指明它所需要依賴的模組,然後把本模組的程式碼寫在回撥函式中去執行,最終衍生出了 AMD 規範

AMD 的主要思想是非同步模組,主邏輯在回撥函式中執行,這和瀏覽器前端所習慣的開發方式不謀而合,RequireJS 應運而生。

標準內容:

  1. 用全域性函式 define 來定義模組,用法為:define(id?, dependencies?, factory);
  2. id 為模組標識,遵從 CommonJS Module Identifiers 規範
  3. dependencies 為依賴的模組陣列,在 factory 中需傳入形參與之一一對應,如果 dependencies 省略不寫,則預設為 ["require", "exports", "module"] ,factory 中也會預設傳入 require, exports, module,與 ComminJS 中的實現保持一致
  4. 如果 factory 為函式,模組對外暴露 API 的方法有三種:return 任意型別的資料、exports.xxx = xxx 或 module.exports = xxx
  5. 如果 factory 為物件,則該物件即為模組的返回值

特點:

  1. 前置依賴,非同步載入
  2. 便於管理模組之間的依賴性,有利於程式碼的編寫和維護。

它的用法看起來是這樣的:

// a.js
define(function (require, exports, module) {
  console.log('a.js');
  exports.name = 'Jack';
});

// b.js
define(function (require, exports, module) {
  console.log('b.js');
  exports.desc = 'Hello World';
});

// main.js
require(['a', 'b'], function (moduleA, moduleB) {
  console.log('main.js');
  console.log(moduleA.name + ', ' + moduleB.desc);
});

// 執行順序:
// a.js
// b.js
// main.js
複製程式碼

人無完人,AMD/RequireJS 也存在飽受詬病的缺點。按照 AMD 的規範,在定義模組的時候需要把所有依賴模組都羅列一遍(前置依賴),而且在使用時還需要在 factory 中作為形參傳進去。

define(['a', 'b', 'c', 'd', 'e', 'f', 'g'], function(a, b, c, d, e, f, g){ ..... });
複製程式碼

看起來略微不爽 ...

RequireJS 模組化的順序是這樣的:模組預載入 => 全部模組預執行 => 主邏輯中呼叫模組,所以實質是依賴載入完成後還會預先一一將模組執行一遍,這種方式會使得程式效率有點低。

所以 RequireJS 也提供了就近依賴,會在執行至 require 方法才會去進行依賴載入和執行,但這種方式的使用者體驗不是很好,使用者的操作會有明顯的延遲(下載依賴過程),雖然可以通過各種 loading 去解決。

// 就近依賴
define(function () {
  setTimeout(function () {
    require(['a'], function (moduleA) {
      console.log(moduleA.name);
    });
  }, 1000);
});
複製程式碼

CMD 規範(Common Module Definition)

AMD/RequireJS 的 JS 模組實現上有很多不優雅的地方,長期以來在開發者中廣受詬病,原因主要是不能以一種更好的管理模組的依賴載入和執行,雖然有不足的地方,但它提出的思想在當時是非常先進的。

既然優缺點那麼必然有人出來完善它,SeaJS 在這個時候出現。

SeaJS 遵循的是 CMD 規範,CMD 是在 AMD 基礎上改進的一種規範,解決了 AMD 對依賴模組的執行時機處理問題。

SeaJS 模組化的順序是這樣的:模組預載入 => 主邏輯呼叫模組前才執行模組中的程式碼,通過依賴的延遲執行,很好解決了 RequireJS 被詬病的缺點。

SeaJS 用法和 AMD 基本相同,並且融合了 CommonJS 的寫法:

// a.js
define(function (require, exports, module) {
  console.log('a.js');
  exports.name = 'Jack';
});

// main.js
define(function (require, exports, module) {
  console.log('main.js');
  var moduleA = require('a');
  console.log(moduleA.name);
});

// 執行順序
// main.js
// a.js
複製程式碼

除此之外,SeaJS 還提供了 async API,實現依賴的延遲載入。

// main.js
define(function (require, exports, module) {
  var moduleA = require.async('a');
  console.log(moduleA.name);
});
複製程式碼

SeaJS 的出現,貌似以一種比較完美的形式解決了 JS 模組化的問題,是 CommonJS 在瀏覽器端的踐行者,並吸收了 RequestJS 的優點。

ES Module

ES Module 是目前 web 開發中使用率最高的模組化標準。

隨著 JS 模組化開發的呼聲越來越高,作為 JS 語言規範的官方組織 ECMA 也開始將 JS 模組化納入 TC39 提案中,並在 ECMAScript 6.0 中得到實踐。

ES Module 吸收了其他方案的優點並以更優雅的形式實現模組化,它的思想是儘量的靜態化,即在編譯時就確定所有模組的依賴關係,以及輸入和輸出的變數,和 CommonJS 和 AMD/CMD 這些標準不同的是,它們都是在執行時才能確定需要依賴哪一些模組並且執行它。ES Module 使得靜態分析成為可能。有了它,就能進一步拓寬 JavaScript 的語法,實現一些只能靠靜態分析實現的功能(比如引入巨集(macro)和型別檢驗(type system)。

標準內容:

  1. 模組功能主要由兩個命令構成:exportimportexport 命令用於規定模組的對外介面,import 命令用於輸入其他模組提供的功能。
  2. 通過 export 命令定義了模組的對外介面,其他 JS 檔案就可以通過 import 命令載入這個模組。

ES Module 可以有多種用法:

模組的定義:

/**
 * export 只支援物件形式匯出,不支援值的匯出,export default 命令用於指定模組的預設輸出,
 * 只支援值匯出,但是隻能指定一個,本質上它就是輸出一個叫做 default 的變數或方法
 */
// 寫法 1
export var m = 1;
// 寫法 2
var m = 1;
export { m };
// 寫法 3
var n = 1;
export { n as m };
// 寫法 4
var n = 1;
export default n;
複製程式碼

模組的引入:

// 解構引入
import { firstName, lastName, year } from 'a-module';
// 為輸入的變數重新命名
import { lastName as surname } from 'a-module';
// 引出模組物件(引入所有)
import * as ModuleA from 'a-module';
複製程式碼

在使用 ES Module 值得注意的是:importexport 命令只能在模組的頂層,在程式碼塊中將會報錯,這是因為 ES Module 需要在編譯時期進行模組靜態優化,importexport 命令會被 JavaScript 引擎靜態分析,先於模組內的其他語句執行,這種設計有利於編譯器提高效率,但也導致無法在執行時載入模組(動態載入)。

對於這個缺點,TC39 有了一個新的提案 -- Dynamic Import,提案的內容是建議引入 import() 方法,實現模組動態載入。

// specifier: 指定所要載入的模組的位置
import(specifier)
複製程式碼

import() 方法返回的是一個 Promise 物件。

import('b-module')
  .then(module => {
    module.helloWorld();
  })
  .catch(err => {
    console.log(err.message);
  });
複製程式碼

import() 函式可以用在任何地方,不僅僅是模組,非模組的指令碼也可以使用。它是執行時執行,也就是說,什麼時候執行到這一句,就會載入指定的模組。另外,import() 函式與所載入的模組沒有靜態連線關係,這點也是與 import 語句不相同。import() 類似於 Node 的 require 方法,區別主要是前者是非同步載入,後者是同步載入。

通過 importexport 命令以及 import() 方法,ES Module 幾乎實現了 CommonJS/AMD/CMD 方案的所有功能,更重要的是它是作為 ECMAScript 標準出現的,帶有正統基因,這也是它在現在 Web 開發中廣泛應用的原因之一。

但 ES Module 是在 ECMAScript 6.0 標準中的,而目前絕大多數的瀏覽器並直接支援 ES6 語法,ES Module 並不能直接使用在瀏覽器上,所以需要 Babel 先進行轉碼,將 import 和 export 命令轉譯成 ES2015 語法才能被瀏覽器解析。

總結

JS 模組化的出現使得前端工程化程度越來越高,讓使用 JS 開發大型應用成為觸手可及的現實(VScode)。縱觀 JS 模組化的發展,其中很多思想都借鑑了其他優秀的動態語言(Python),然後結合 JS 執行環境的特點,衍生出符合自身的標準。但其實在本質上,瀏覽器端的 JS 仍沒有真正意義上的支援模組化,只能通過工具庫(RequireJS、SeaJS)或者語法糖(ES Module)去 Hack 實現模組化。隨著 Node 前端工程化工具的繁榮發展(Grunt/Gulp/webpack),使我們可以不關注模組化的實現過程,直接享受 JS 模組化程式設計的快感。

在複習 JS 模組化的過程中,對 Webpack 等工具的模組化語法糖轉碼產生了新的興趣,希望有時間可以去分析一下模組化的打包機制和轉譯程式碼,然後整理出來加深一下自己對模組化實現原理的認識和理解。

期待下一篇。

參考文章:

相關文章