目錄

從 Node 誕生開始 CommonJS(CJS)就被廣泛使用來進行開發,透過 require() 引入它們,當實作了 script,提供給他人使用,可以定義 exports,使用 named exports module.exports.foo = 'bar' 或是 default exports module.exports = 'baz' 的方式導出 script。

ESM 和 CJS 是完全不同的。儘管從表面上來看,ESM 看起來非常相似。

使用 命名導出 的 CJS 範例

1
2
3
4
5
// @filename: utils.CJS
module.exports.sum = (x, y) => x + y;
// @filename: main.CJS
const { sun } = require("./utils.CJS");
console.log(sum(2, 4));

使用 default exports 的 CJS 範例

1
2
3
4
5
6
// @filenameL util.CJS
module.exports = (x, y) => x + y;

// @filenameL main.CJS
const whateverWeWant = reuqire("./util.CJS");
console.log(whateverWeWant(2, 4));

在 ESM 中,使用 importexport ,對於 default exportnamed export 也有兩種不同的語法。

使用 named export 的 ESM 範例。

1
2
3
4
5
6
// @filename: util.mjs
export const sum = (x, y) => x + y;

//@filename: main.mjs
import { sum } from "./util.mjs";
console.log(sum(2,v 4));

使用 default export 的 ESM 範例。

1
2
3
4
5
6
// @filename: util.mjs
export default (x, y) => x + y;

//@filename: main.mjs
import whateverWeWant from "./util.mjs";
console.log(whateverWeWant(2, 4));

ESM 和 CJS 是完全不同的

  • 在 CJS 中,imports複製 一份 exports 的內容來使用。
  • 在 ESM 中,imports 是使用 靜態結構read-only view 來使用裡面的內容。

16.7 Details: imports as views on exports

CJSrequire 是同步的,它不會回傳 Promisecallback

ESM 以非同步的方式加載,它依賴於 ES6 aka ES2015靜態結構

之後 Module Loader 會下載以及解析我們導入的所有 scripts,再來會輪到 scripts 所依賴的 scripts,然後建構依賴的 模組地圖 (ES module graph),直到找到沒有再導入任何 scripts 的 script。最後,允許執行該 scripts,再允許運行該 script 依賴的 script,以此類推。

建構出來的 模組地圖 (ES module graph) 的 scripts 都是併行下載的,但它們會按照順序執行,由 Module Loader 的規範保證。

ESM 改變了很多事情

ESM 改變了 Javascript 很多事情,首先,ESM script 默認使用嚴格模式 (use strict),它們的 this 不會引用全局物件,作用域的運作方式不同等等。

這就是為什麼即使在瀏覽器中,<script> 標籤的默認設定不是 ESM 的;必須添加 type="module" 才可以進入 ESM 模式,將默認設定從 CJS 切換到 ESM 將會是對兼容性的重大突破。

ESM 靜態模組結構 (static module structure)

靜態結構的意思是可以在編譯期間 靜態的 確定 importexportESM Loader 會在不運行程式碼的情況下解析 importexport;在解析階段, ESM Loader 可以在沒有真正 運行程式碼之前 立即的找出 命名導入 因拼寫錯誤而引發的異常。

只能在程式碼最上層 (top-level) 使用 export 或 import (不能放在條件式中),並且 importexport (不允許使用變數)。

下面是沒有 靜態結構 的 CJS 的兩個範例:

1
2
3
4
5
6
var my_lib;
if (Math.random()) {
my_lib = require("foo");
} else {
my_lib = require("bar");
}

如果沒有給 else,您必須運行代碼以找出它導出的內容:

1
2
3
if (Math.random()) {
exports.baz = ···;
}

ESM 會強制保持靜態結構,因此可以得到好處:

  1. Tree Shaking,打包期間移除沒有用到的代碼
  2. 更緊湊的 bundle
  3. 更快的查詢並導入

Tree Shaking

可以將應用程式想像成一棵樹,實際使用到的 code 和 library 代表了綠色的還活著的葉子。死掉的程式碼代表枯萎的褐色葉子,為了擺脫枯葉,必須搖動樹,使它們掉落。

在開發過程中,模組通常會這樣處理:

  • 開發過程,程式碼存在在許多模組 (通常很小)
  • 執行部屬,這些模組會被 bundle 並分類到幾個較大的區塊中

bundle 的原因:

  • 下載 bundle 時需要索引的文件更少
  • 壓縮 bundle 後的檔案擁有更好的效能
  • 可以在 bundle 期間刪除不必要的檔案

為了利用 Tree Shaking 的優勢有幾點需要注意:

  • 必須使用 ESM (importexport)
  • 確保編譯器沒有將 ESM 轉換成 CJS (這是 Babel 預設 @Babel 預設 @babel/preset-env 的默認行為,參閱 文檔 )
  • sideEffects 屬性加入倒 package.json
  • 啟用 webpackproduction mode 配置開啟 各種優化

更緊湊的 bundle

The module bundler Rollup 證明了 ESM 可以有效的組合,因為它們都適合單個範圍 (在重命名變數以消除變數衝突之後),這也仰賴於 ESM 的兩個特性:

  • 都是靜態結構意味著  bundle 格式不需要考慮條件式載入的模組

  • importexport 的 read-only view。這意味著不必複製一份並導出,可以直接使用它們。

例如,以下兩個 ESM 的範例

1
2
3
4
5
6
7
// lib.js
export function foo() {}
export function bar() {}

// main.js
import { foo } from "./lib.js";
console.log(foo);

Rollup 會將這兩個模組綁到單個 ES6 模組之下(刪除沒用到的 bar)

1
2
3
function foo() {}

console.log(foo());

更快的查詢並導入

如果需要 import CJS 的 libary,會得到一個物件:

1
2
var lib = require(lib);
lib.someFunc(); // property loopup

必須通過屬性去使用 lib.someFunc,這樣沒有效率,因為它是動態結構。

相反的,如果是使用 ESM import 某個 library,因為是靜態結構,我們可以很清楚的知道它裡面有什麼變數可以使用。

1
2
import * as lib from "lib";
lib.someFunc(); // statically resolved

ESM 不能 import 命名導出的 CJS

可以這樣做

1
import _ from "lodash.CJS ";

但不能這樣

1
import { shuffle } from "lodash.CJS";

這是因為 CJS script 是在執行期間計算 命名導出,而 ESM 則是在解析階段計算。

ESM 可以使用 require

require 在 ESM script 默認不在使用範圍內,但可以透過以下方式使用

1
2
3
4
import { createRequire } from "module";
const require = createRequire(import.meta.url);

const { foo } = require("./foo.CJS");

但是這樣的方式沒有實質上的幫助,實際上只是需要撰寫更多的程式碼,而且不僅僅是執行 import 與解構,像 webpack、Rollup 這樣的打包工具也不知道該怎麼處理 createRequire 模式,這樣就變得沒那麼有意義。

CJS 不能 require() ESM

CJS 不能 require ESM 的最簡單原因是因為 ESM 可以做 top-level await,而 CJS scripts 不行。

top-level await 可以讓我們在 async function 之外使用 await 關鍵字。
ESM 的 multi-pasge loader 使 ESM 可以實現 top-level await

由於 CJS 不支援 top-level await,因此甚至不可能將 ESM top-level await 轉換為 CJS。

CJS 可以 import() ESM 但不是很方便

如果想要使用 CJS import ESM script,可以使用 dynamic import 的方式:

1
2
3
async () => {
const { foo } = await import("./foo.mjs");
};

如何使 “CJS” 的 default exports 可以使用 named export

為 CJS 命名導出提供一個 ESM wrapper

為 CJS libraries 提供 ESM wrapper 很容易,但永遠不可能為 ESM libraries 提供 CJS wrapper

1
2
3
import CJSModule from "../index.js";

export const foo = CJSModule.foo;

將 ESM wrapper 放在 ESM 的子目錄,同層的 package.json 的 增加 {"type":"module}.

結語

  • CJS 使用 requireexports,ESM 使用 importexport
  • ESM 和 CJS 是完全不同的
  • CJS 是同步的,它不會回傳 promisecallback且是動態結構,ESM Module Loader 是異步的,並在運行前解析成靜態結構
  • ESM 解析出來的 Module Graph 會並行下載但會依序執行。
  • ESM 強制的靜態結構帶來了許多好處。
  • ESM 不能 import 命名導出 的 CJS。

參考