1、JavaScript 模塊發展史
1.1 Vanilla JS(1995~2009)
JavaScript 被開發出來的時候,是沒有模塊標準的,因為 JavaScript 的設計初衷就是作為一個 toy script,在瀏覽器中做一些簡單的交互。但是隨著互聯網的高速發展,人們已經不再滿足于簡單的交互,而代碼的復雜度也日益增長,維護難度也越來越高。
那么維護指的是維護什么呢?指的是維護變量。因為隨著項目不斷迭代,多人協同開發是不可避免的。在 JS 初期所有變量都寫在全局作用域上,那么很可能出現的問題是什么呢?變量的覆蓋、篡改和刪除,這是一個很頭疼的問題。很可能突然有一天你的功能報錯了,就是因為你的某個變量被另一位開發者所刪除了。
所以對于模塊的引入初衷是為了解對變量的控制。當然還有其他的好處,例如對代碼的封裝、復用等等。
那么初期在沒有模塊標準的支持下,開發者們是如何實現類似模塊的效果呢?有 2 種方式。
1.1.1 Object?Literal?Pattern(對象字面量)
使用 JS 內置的對象對變量進行控制:
function Person(name) {
? this.name = name;
}
Person.prototype.talk = function () {
? console.log("my name is", this.name);
};
const p = new Person("anson");
p.talk();
復制代碼
這樣就可以通過 new Person 的方式把變量都控制在對象內部。
1.1.2 IIFE(Immediately Invoked Function Expression)
我們知道在 JavaScript 中有作用域(Scope)的概念,在作用域內的變量,只在作用域內可見。在 ES6 之前,作用域只有 2 種,分別是:
全局作用域(Global Scope)
函數作用域(Function Scope)
上面提到了對變量的控制,那么肯定是把變量的作用范圍控制的越小越好,所以毫無疑問把變量寫在函數內是最好的辦法。但是,這又引發了另一個問題,函數中的變量要如何提供給外部使用呢?
這個問題在初期并沒有很好的解決方法,你必須把變量暴露到全局作用域中,例如經典的 jQuery。
而開發者們通常會使用 IIFE 去實現:
// lib.js
(function() {
? const base = 10;
? this.sumDOM = function(id) {
? ? // 依賴 jQuery
? ? return base + +$(id).text();
? }
})();
復制代碼
在 HTML 中引入 lib.js:
// index.html
<html>
? <head>
? ? <script src="/path/to/jquery.js"></script>
? ? <script src="/path/to/lib.js"></script>
? </head>
? <body>
? ? <script>
? ? ? window.sumDOM(20);
? ? </script>
? </body>
</html>
復制代碼
但是 IIFE 有幾個問題:
至少一個變量污染全局作用域;
模塊之間的依賴關系模糊,不明確(lib.js 不能直觀看出依賴 jquery.js);
加載順序無法保證,不好維護(必須確保 jquery.js 必須在 lib.js 前加載完成,否則會報錯)。
所以,JavaScript 非常需要一個模塊標準來解決上述問題。
1.2 Non-Native Module Format & Module Loader(2009~2015)
由于模塊能為我們解決上述問題,所以開發者嘗試著自己去設計一些非原生模塊標準如 CommonJS、AMD (Asynchronous?Module?Definition)、UMD (Universal Module Definition),然后搭配對應的 Module Loader 如 cjs-loader、RequireJS、SystemJS 可以實現模塊的效果,我們下面過一下幾個流行的非原生模塊標準。
1.2.1 CommonJS (CJS)
2009 年,來自 Mozilla 的工程師 Kevin 提出了為運行在瀏覽器以外的 JavaScript 建立一個模塊標準 CommonJS,主要應用在服務端如 Node.js。因為使用效果不錯,隨后也被用在瀏覽器的模塊開發中,但由于瀏覽器并不支持 CommonJS,所以代碼需要通過 Babel 等 transpiler 轉換為 ES5 才能在瀏覽器上運行。
CommonJS 的特征是使用 require 來導入依賴,exports 來導出接口。
// lib.js
module.exports.add = function add() {};
// main.js
const { add } = require("./lib.js");
add();
復制代碼
1.2.2 AMD
因為 CommonJS 設計初衷是應用在服務端的,所以模塊的加載執行也都是同步的(因為本地文件的 IO 很快)。但是同步的方式運用到瀏覽器就不友好了,因為在瀏覽器中模塊文件都是通過網絡加載的,單線程阻塞在模塊加載上,這是不可接受的。所以在 2011 年有人提出了 AMD,對 CommonJS 兼容的同時支持異步加載。
AMD 的特征是使用 define(deps, callback) 來異步加載模塊。
// Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {
? ? //Define the module value by returning a value.
? ? return function () {};
});
復制代碼
1.2.3 UMD
因為 CommonJS 和 AMD 的流行,隨后又有人提出了 UMD 的模塊標準,UMD 通過對不同的環境特性進行檢測,對 AMD、CommonJS 和 Global Variable 三種格式兼容。
// UMD
(function (root, factory) {
? if (typeof define === 'function' && define.amd) {
? ? // AMD
? ? define(['jquery', 'underscore'], factory);
? } else if (typeof exports === 'object') {
? ? // Node, CommonJS-like
? ? module.exports = factory(require('jquery'), require('underscore'));
? } else {
? ? // Browser globals (root is window)
? ? root.returnExports = factory(root.jQuery, root._);
? }
}(this, function ($, _) {
? //? ? methods
? function a(){};? ? //? ? private because it's not returned (see below)
? function b(){};? ? //? ? public because it's returned
? function c(){};? ? //? ? public because it's returned
? //? ? exposed public methods
? return {
? ? b: b,
? ? c: c
? }
}));
復制代碼
因為 UMD 的兼容性好,不少庫都會提供 UMD 的版本。
1.3 ESM(2015~now)
隨著 ECMAScript 的逐漸規范化、標準化,終于在 2015 年發布了 ES6(ES 2015),在這次版本更新中,制定了 JS 模塊標準即 ES Modules,ES Modules 使用 import 聲明依賴,export 聲明接口。
// lib.mjs
const lib = function() {};
export default lib;
// main.js
import lib from './lib.mjs';
復制代碼
截止到 2018 年,大部分主流瀏覽器都已經支持 ES Modules,在 HTML 中通過為 <script> 中添加 type="module" 屬性來聲明 ESM 類型。
在 HTML 中使用 ES Modules 有幾個注意點:
默認啟用嚴格模式即 "use strict";
默認 defer 加載執行;
默認啟用 CORS 跨域;
在同一個文檔中,相同的模塊只會加載、執行一次;
隨著 ES Modules 模塊標準的發布,JS 的周邊生態系統也在慢慢向 ES Modules 靠攏。Node.js 在 14.x 添加了對 ES Modules 的支持;Module Bundler 如 Rollup 均以 ES Modules 作為默認模塊標準;還有 Deno、TypeScript 等等。
現在仍然在使用的模塊標準主要就是 CJS 和 ESM,CJS 的存在主要是 Node 的歷史原因。下面我們對 ESM 工作原理進行介紹,并結合 CJS 進行對比。
2、ESM 工作原理
在介紹 ES Modules 工作原理前,先理解幾個概念。
Module Scope(模塊作用域)
我們都知道 ES6 引入了一個新的作用域:塊作用域(Block Scope),但是還有一個模塊作用域(Module Scope),用于管理模塊內部的變量。與函數作用域不同的是,在模塊作用域中,你需要顯式地指定導出的變量,這也叫作一個 export;同時你需要顯式地指定導入的變量,這也叫作一個 import。所以你不再需要污染全局作用域了。
正因為模塊之間的依賴關系是顯示的、明確的,所以你不用再擔心你的模塊是否會因為 jquery 沒有前置加載而報錯了,因為這在編譯階段就會提示你了。
Module Record(模塊記錄)
當我們使用模塊的時候,實則是在構建一個模塊依賴圖。你傳遞一個模塊文件作為入口(Entry Point),JS 引擎根據模塊中的 import 聲明語句遞歸查詢、下載、解析子模塊。
在這里 main.js 作為入口,然后依賴另外兩個子模塊 counter.js 和 display.js。
解析指的是把模塊文件解析為一種數據結構 Module Record,Module Record 記錄了模塊中的 import、export、code 等信息,用于后續的 Linking、Evaluation。
Module Environment Record(模塊環境記錄)
當 JS 引擎執行到一個作用域時,會創建一個 Environment Record(環境記錄)綁定到該作用域,用于存儲作用域內的變量和函數。Module Environment Record 除了保存著模塊內的 top-level 的變量聲明,還保存著模塊內的 import 綁定變量。
Environment Record 有一個很重要的字段 [[OuterEnv]] 用于指向外部的 Environment Record,這與原型鏈十分相似,末端為 null。
如上圖所示 lib-a.js 與 lib-b.js 是兩個獨立的模塊,環境記錄分別為 ModuleEnvironmentRecord-lib-a 與 ModuleEnvironmentRecord-lib-b,兩者的 [[OuterEnv]] 都指向 GlobalEnvironmentRecord,這樣做實現了模塊之間的變量分離。
ES Modules 工作過程主要可以劃分 3 個階段:
Construction- 查詢、下載、解析模塊為 Module Record;
Linking- 創建 Environment Record 并關聯模塊之間的 import、export 關系;
Evaluation- 執行 top-level 代碼并填充 Environment Record。
大家都說 ESM 是異步執行的,是因為這 3 個階段是獨立的、可分離的,但是這并不表示一定需要使用異步去實現,它也是可以通過同步去執行的,例如在 CJS 中就是同步去執行的。
因為在 ESM spec 里面只說到如何解析 Module Record;如何做模塊之間的 Linking;如何執行模塊的 Evaluation。但是并沒有提到如何獲取到模塊文件,這在不同的運行環境中由不同的 loader 去負責加載完成。對于瀏覽器而言,在 HTML spec 中使用的是異步加載的方式。
loader 不僅僅負責模塊的加載,同時它負責調用 ESM 的方法如 ParseModule、Module.Link、Module.Evaluate。loader 控制著這些方法的執行順序。
2.1 Construction
Construction 階段主要分為 3 個步驟:
找到模塊路徑,也叫 module resolution;
獲取模塊文件(從網絡下載或從文件系統加載);
解析模塊文件為 Module Record;
loader 負責對模塊進行尋址以及下載。首先我們需要一個入口文件,這在 HTML 中通常是一個 <script type="module"> 的標簽來表示一個模塊文件(在 Node 中通常使用 *.mjs 來表示一個模塊文件或修改 package.json 中的 "type": "module")
那模塊是怎么找到下一個子模塊的呢?這就需要通過 import 聲明語句了,在 import 聲明語句中有一部分被稱為 module specifier,這告訴 loader 要如何找到下一個子模塊的地址。
注意 module specifier 在不同環境(瀏覽器、Node)中有不同的解釋方法,解釋的過程也叫作 module resolution。例如在瀏覽器中只支持 URL 作為 module specifier;而 Node 除此以外還支持 bare module specifier,也就是我們平常寫的 import moment from "moment";。W3C 也在推進 import maps 特性來支持 bare module specifier。
你只有在解析完當前模塊為 Module Record 之后,才知道當前模塊依賴的是哪些子模塊,然后你需要 resolve 子模塊、fetch 子模塊、parse 子模塊,不斷的循環這套流程 resolving -> fetching -> parsing,如下圖所示:
如果整個過程,主線程都在等待每個模塊文件的下載,那么整個任務隊列都會掛起。因為你在瀏覽器中下載是很慢的,這也是為什么在 ESM spec 中把模塊加載拆分為 3 個階段的原因。
階段的拆分也是 CJS 與 ESM 主要的一個不同點,因為 CJS 加載的都是本地文件,自然不需要考慮 IO 的問題。這意味著 Node 會阻塞主線程去做這個模塊的加載動作,接著同步執行 Linking、Evaluation。
上圖代碼執行到 require,然后需要加載子模塊了,馬上切換到 fetch 子模塊,然后繼續執行 evaluate 子模塊,這一切都是同步發生的。這也是為什么在 Node 中,你可以在 module specifier 中使用變量。
但是對于 ESM 來說就不同了,因為 ESM 在執行 Evaluation 之前,就需要構建好整個模塊依賴圖,這包括所有模塊的resolving、fetching、parsing。所以 ESM 在 module specifier 中是無法使用變量的。
但是這也有一個好處,那就是 Rollup、Webpack 等 Module Bundler 可以在編譯時對 ESM 進行靜態分析,做 Tree Shaking 移除 dead code。
如果實在想在 ESM 中使用變量作為 module specifier,那么可以使用 dynamic import import(${path}/foo.js) 來導入新的模塊,新的模塊入口會自動創建一個新的模塊依賴圖。
雖然是新的模塊依賴圖,但是并不會創建新的 Module Record,loader 使用 Module Map 對全局的 Module Record 進行追蹤、緩存。這樣可以保證模塊文件只被 fetch 一次。每個全局作用域中會有一個獨立的 Module Map,也就說每個 iframe 會有獨立的 Module Map。
可以把 Module Map 想象為一個簡單的 key/value 映射對象。例如初次加載的模塊會標記狀態為 fetching,然后發起請求,接著繼續 fetch 下一個模塊文件。
我們可以通過查看下圖來理解 Document 與 Module Map 之間的關系:
Document 與 Module Map 是一對一的關系,main.js 有自己的 Module Map;底下的 iframe-a、iframe-b 也會有自己的 Module Map。所以盡管它們內部依賴模塊的地址是一樣的,仍然會重復去請求下載。
好,那么下載完文件后,JS 引擎會把模塊文件解析為 Module Record,保存著模塊中的 import、export、code 等信息。
Module Record 會放置到 Module Map 中緩存。
2.2 Linking
在所有 Module Record 被解析完后,接下來 JS 引擎需要把所有模塊進行鏈接。JS 引擎以入口文件 main.js 的 Module Record 作為起點,以深度優先的順序去遞歸鏈接模塊,為每個 Module Record 創建一個 Module Environment Record,用于管理 Module Record 中的變量。
具體是如何進行鏈接的呢?JS 引擎會對當前模塊 main.js 下的所有子模塊 counter.js、display.js 創建 Module Environment Record,對子模塊中的 export 變量進行綁定,為其分配內存空間。
然后控制權回到上一級,也就是當前模塊 main.js,對 main.js 中 import 的變量進行關聯,注意這里 main.js 中 import 指向的內存位置與 count.js、display.js 中 export 變量指向的內存位置是一致的,這樣就把父子模塊之間的關系鏈接起來了。
但是在 CJS 在這一點上面不同。在 CJS 里面,會對整個 module.exports 對象進行復制。
這意味著 exporting module 在后面修改變量值,importing module 并不會自動更新。
相反,ESM 用的一種技術叫作 live bindings。父子模塊指向相同的內存位置,所以 exporting module 修改變量值,importing module 會馬上得到更新。
需要注意的是,只有 exporting module 才可以對 export 變量值進行改變,importing module 是無法改變。可以說 exporting 模塊有讀寫權限,而 importing 模塊只有讀權限。
使用 live bindings 的一個原因是,它可以幫助把所有模塊關聯起來,而不需要跑任何代碼。這在當我們 Evaluation 遇到循環依賴(cyclic dependencies)的時候很有幫助。
下面我們要開始執行代碼,并填充上面的內存了。
2.3 Evaluation
在模塊彼此鏈接之后,JS 引擎通過執行模塊中的 top-level 代碼來實現,所以你的 import、export 語句是不能寫在函數里面的。
但是執行 top-level 代碼是可能會產生副作用,例如發送網絡請求,所以你肯定不希望同一個模塊執行多次。這也是為什么會使用 Module Map 來做全局緩存 Module Record 的原因,如果一個 Module Record 的狀態為 evaluated,那么下次執行會自動跳過,從而保證一個模塊只會執行一次。與 Linking 階段一樣的是,同樣是對 Module Record 執行深度優先遍歷的操作。
在 Linking 結尾提到的依賴循環問題,通常是錯綜復雜的依賴循環,這里以簡單的例子說明下:
main.js 與 counter.js 之間循環依賴彼此。 我們先來看看 CommonJS 中的依賴循環問題:
首先 main.js 執行到 require("./counter.js"),然后進入 counter.js 執行獲取 main.js 中的 message,而這時是 undefined 的,所以 counter.js 復制了 undefined。
在 counter.js 執行完成后(注意最后我們設置了一個 setTimeout 來查看 message 是否會自動更新),在控制權返回到 main.js 繼續執行代碼,最后對 message 賦值為 "Eval complete"。
但因為在 CommonJS 中 import 的變量值是對 export 變量值的復制,所以 counter.js 中的 message 并不會更新。
而 ES Modules 使用的是 live bindings,所以在 counter.js 中會自動更新 message 的值。
3、CJS 與 ESM 之間的混用
因為歷史原因,npm 上大多數的包都使用 CJS 編寫,但是隨著 ESM 的出現,開發者們開始使用 ESM 去編寫模塊。而為了最大程度復用 npm 上的包,在 ESM 中難免會需要導入 CJS。而因為模塊加載方式的差異性,CJS 無法導入 ESM,而 ESM 可以導入 CJS。
雖然 ESM 可以導入 CJS,但是使用上仍然有些限制。
3.1 ESM 只支持 default import CJS
ESM 支持 CJS 的 default import,但是不支持 named import,即:
import pkg from 'lib.cjs'; // work
import { fn1, fn2 } from 'lib.cjs'; // error
復制代碼
為什么呢?結合上面的 ESM 工作原理,ESM 是對模塊變量進行靜態分析的,而 CJS 的模塊變量是動態計算的。所以 ESM 還在還沒執行代碼的第一階段 Construction,又如何能計算出 CJS 的模塊變量呢?
但是在 Node 14.13.0 版本中,Node 添加了對 CJS named export 的支持,可以支持大部分的 CJS 模塊。
為什么說大部分呢?Node官網做出了說明:
The detection of named exports is based on common syntax patterns but does not always correctly detect named exports. In these cases, using the default import form described above can be a better option.
注意 detect 這一關鍵字,它是基于的 CJS 模塊語法對文本分析得到 named exports,所以并不能保證正確。在這種情況,使用 default import 是更好的選擇。
Node 使用了一個叫做的 cjs-module-lexer 的語法分析庫,對 CJS 模塊內容進行靜態語法分析,只支持簡單的 exports 寫法,如 exports.name = ... 或 module.exports = require('...'),這里舉個可被分析的例子:
// correct.cjs
exports.a = 1;
exports.b = 2;
if (Math.random() > 0.5) {
? exports.c = 3;
}
// main.mjs
import { a, b, c } from './correct.cjs';
// 執行 main.mjs 無異常
復制代碼
無法分析的例子:
// wrong.cjs
// 使用 tmp 來設置 exports
const tmp = exports;
tmp.a = 1;
tmp.b = 2;
if (Math.random() > 0.5) {
? tmp.c = 3;
}
// main.mjs
import { a, b, c } from './wrong.cjs';
// 執行 main.mjs 報錯
復制代碼
執行上面的例子會報以下錯誤:
file:///E:/javascript-modules/esm-app/dual/index.mjs:1
import { a, b, c } from "./lib.cjs";
? ? ? ? ^
SyntaxError: Named export 'a' not found. The requested module './lib.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from './lib.cjs';
const { a, b, c } = pkg;
復制代碼
你可能會想,誰會這么寫阿,不巧,蠻多有名的庫是這么寫的,例如 lodash、chalk。
對于無法分析 named exports 的模塊,Node 會在錯誤里面給我們提出建議使用 default import,然后再進行解構,也就多一行代碼:
CommonJS modules can always be imported via the default export, for example using:
import pkg from './lib.cjs'; const { a, b, c } = pkg;
3.2 使用 ESM Wrapper 為 CJS 實現 named exports
如果我們實在希望在 ESM 中使用 named exports CJS,那么我們可以為 CJS 提供一個 ESM Wrapper,其實就是根據 Node 的錯誤提示去封裝一層代碼,對 CJS 采用 default import,然后對里面指定的變量 re-export 一次:
// lib.cjs
const tmp = exports;
tmp.a = 1;
tmp.b = 2;
if (Math.random() > 0.5) {
? tmp.c = 3;
}
// lib-esm-wrapper.mjs
import lib from "./lib.cjs";
export const { a, b, c } = lib;
// main.mjs
import { a, b, c } from "./lib-esm-wrapper.mjs";
console.log(a);
console.log(b);
console.log(c);
復制代碼
所以當用戶需要 ESM 模塊,而當前只有 CJS 模塊時,可以考慮編寫一個簡單的 ESM Wrapper 進行包裝。
4、編寫支持多種模塊格式的庫
有時候我們在編寫庫的時候,希望我們的庫支持 CJS 和 ESM 兩種格式,大家可能對 package.json 的 module 字段比較熟悉,它是一個約定俗成的字段,主要用在 Module Bundler 如 Webpack、Rollup 對包是否支持 ESM 的檢查,然而 Node 并不會對該字段識別。
在 Node 12+ 我們可以使用 package.json 的 exports 字段來為包配置支持不同的模塊文件,Node 會根據你使用 import 還是 require 來加載,返回相應的模塊文件:
// package.json
{
? "exports": {
? "import": "./lib.mjs",
? ? "require": "./lib.cjs"
? }
}
// app.mjs
import { value } from "lib";
console.log("value from mjs", value);
// app.cjs
const value = require("lib").value;
console.log("value from cjs", value);