JavaScript 模塊化現狀

原文鏈接:The state of JavaScript modules

ESM, CJS, UMD, AMD?—?到底應該選擇哪一個?

最近 在 twitter 上有很多關于 ES Module 現狀的討論,尤其是在 Node.js 上,他們計劃引入新的文件擴展名 *.mjs。人們有足夠理由對此感到 擔憂和不確定,因為這個話題異常復雜,接下來會盡力闡述清楚問題。

來自遠古的恐懼

大多數前端開發者應該還記得 Javascript 依賴管理的黑暗時期。那個時候,你需要把一個庫復制粘貼到 vendor 文件夾,然后作為一個全局變量引入,要自己去按次序組合所有東西,可能還要管理命名空間。

在過去的那些年,我們能深刻體會到公共模塊格式化和中央模塊管理的價值。

在今天,不管是發布還是使用一個庫都要容易得多,只需要使用 npm publishnpm install 命令就行。這就是人們會那么緊張兩種模塊系統兼容性問題的原因:他們不想失去已有的舒適區。

接下來我會解釋和總結現有實現的情況,以及為什么 Node 生態遷移到 ES Module(ESM)會那么難。在最后,總結這些變化對 webpack 使用者和模塊作者有什么影響。

現有實現

目前,ESM 有三種方式的實現:

為了更好地理解現在的討論,首先要知道 ES2015 包含兩種模式:

  • script 用于具有全局命名空間的常規腳本
  • module 用于具有明確導入和導出的模塊化代碼

如果你試圖在 script 標簽使用 import 或者 export 語句,會拋出一個 SyntaxError。這種語句在全局環境下沒有任何意義。另一方面,module 模式即意味著嚴格模式,禁止使用某些語言特性,比如 with 語句。因此,需要在腳本被解析和執行之前定義模式。

瀏覽器中的 ESM

截至到 2017 年 5 月,所有主流瀏覽器都開始做了 ESM 的實現工作。不過,大部分仍處于在實驗性質。這里不會做詳細介紹,因為 Jake Archibald 已經寫了一篇很厲害的文章

除了一些小的困難,在瀏覽器中實現起來非常容易,因為以前并沒有模塊系統。想要指定 module 模式,需要在 script 標簽添加 type="module" 屬性,如下所示:

<script type="module" src="main.js"></script>

在一個模塊中,現在只能使用有效的 URL 作為模塊標識符。模塊標識符是用于 require 或 import 其他模塊的字符串。為了確保未來兼容 CJS 模塊標識符,“純” 導入標識符(如 import "lodash")現在還不支持。模塊標識符必須是絕對 URL 或者是以 /./, ../ 開頭:

// Supported:
import {foo} from 'https://jakearchibald.com/utils/bar.js';
import {foo} from '/utils/bar.js';
import {foo} from './bar.js';
import {foo} from '../bar.js';

// Not supported:
import {foo} from 'bar.js';
import {foo} from 'utils/bar.js';
// Example from https://jakearchibald.com/2017/es-modules-in-browsers/

同樣需要注意的是,一旦處在一個模塊中,每個導入也將被解析為 module,而且沒有辦法 import 一個 script

ESM 與 webpack

類似 webpack 這樣的構建工具通常會嘗試用 module 模式解析代碼,有問題再切回到 script 模式。這些工具最終會生成一段 script,通常是在一定程度上模擬 CJS 和 ESM 行為的模塊運行時。

我們以這兩個簡單的 ESM 為例:

// a.js
export let number = 42;
export function incr() {
    number++;
}
// test.js
import { number } from "./a";

console.log(number);

webpack 使用函數包裝器封裝模塊范圍和對象引用來模擬 ESM 實時綁定。每次編譯,還包括一個模塊運行時,負責引導和緩存模塊。此外,將模塊標識轉換為數字模塊 ID。這樣可以減少打包的大小和引導時間。

這是什么意思呢?我們來看看編譯輸出:

(function(modules) {
    // This is the module runtime.
    // It's only included once per compilation.
    // Other chunks share the same runtime.
    var installedModules = {};
    // The require function
    function __webpack_require__(moduleId) {
        ...
    }
    ...
    // Load entry module and return exports
    return __webpack_require__(__webpack_require__.s = 1);
})
([ // An array that maps module ids to functions
    // a.js as module id 0
    function (module, __webpack_exports__, __webpack_require__) {
        "use strict";
        Object.defineProperty(__webpack_exports__, "a", {
            configurable: false,
            enumerable: true,
            get: () => number
        });

        let number = 42;

        function incr() {
            number++;
        }
    },
    // test.js as module id 1
    function (module, __webpack_exports__, __webpack_require__) {
        "use strict";
        var __WEBPACK_IMPORTED_MODULE_0__a__ = __webpack_require__(0);

        // Object reference as "live binding"
        console.log(__WEBPACK_IMPORTED_MODULE_0__a__["a" /* number */]);
    }
]);

簡化的 webpack 輸出,模擬 ES Modules 行為

結果已經簡化并刪除了一些與此示例無關的代碼。你會發現,webpack 在 exports 對象上將所有 export 語句替換成 Object.defineProperty,并使用屬性訪問器替換對引入值的所有引用。還要注意每個 ESM 開始時的 "use strict" 指令,這是由 webpack 自動添加,在 ESM 中必須是嚴格模式。

這種實現只是模擬,因為它試圖模仿 ESM 和 CJS 的行為 -- 但不是與其完全保持一致。比如,這種模擬并不符合某些邊緣情況。看下面這個模塊:

console.log(this);

如果你通過加上 babel-preset-es2015 的 Babel 來運行,結果是:

"use strict";
console.log(undefined);

從輸出結果可以看出,Babel 假設默認是 ESM,因為 module 模式即代表嚴格模式,在嚴格模式下會將 this 初始化為 undefined

然而,使用 webpack,結果是:

(function(module, exports) {

console.log(this);

})

在引導模塊時,this 將指向 exports ,與 Node.js 使用的 CJS 行為一致。這是因為語法上不確定是 script 還是 module,解析器無法判斷該模塊是 ESM 還是 CJS。在不明確的時候,webpack 會模擬 CJS,因為它仍然是最受歡迎的模塊風格。

這種模擬其實已經包含了很多情況,因為模塊作者通常會避免這種代碼。然而,“很多情況”對于像 Node.js 這樣的平臺是不夠的,因為它需要保證所有有效的 JavaScript 代碼都能正常運行。

Node.js 中的 ESM

Node.js 在執行 ESM 時遇到了麻煩,因為仍然需要支持 CJS,語法看起來相似,但運行時行為完全不同。Node.js 核心技術委員會(CTC)成員 James M Snell 撰寫了一篇很好的文章來解釋 CJS 與 ESM 之間的差異

歸結起來,CJS 是一個動態模塊系統,ESM 是靜態模塊系統。

CJS

  • 允許動態同步 require()
  • 導出僅在模塊執行后才知道
  • 導出可以在模塊初始化后添加,替換和刪除

ESM

  • 只允許靜態同步 import
  • 在模塊執行之前,導入和導出已經關聯
  • 導入和導出是不可變的

由于 CJS 早于 ES2015,所以一直在 script 模式下解析,封裝通過使用函數包裝器實現。在 Node.js 中加載 CJS,實際上會執行與此類似的代碼:

const module = {
    exports: {}
};
const require = makeRequireFunction();
const filename = "...";
const dirname = "...";
(function (exports, require, module, __filename, __dirname) {
/* YOUR CODE */
})(module.exports, require, module, filename, dirname);

對 Node.js 的 CommonJS 模塊的簡單函數包裝

問題出現了,將兩個模塊系統集成到同一個運行時時,ESM 和 CJS 之間的循環依賴可能會迅速導致類似死鎖的情況。

而且,由于現有 CJS 模塊數量龐大,也不能直接放棄對 CJS 的支持。為了避免 Node.js 生態的中斷,有兩點已經很明顯:

  • 現有的 CJS 代碼必須以相同的方式繼續工作
  • 兩個模塊系統都必須同時且盡可能無縫地工作

目前的權衡

2017 年 3 月,經過幾個月的討論,CTC 終于找到了一種解決問題的辦法。由于在 ES 規范和引擎不改變的情況下無法進行無縫集成,CTC 決定開始一些權衡之后的實現工作

1.ESM 必須是 *.mjs 文件擴展名

這是由于上面提及的模糊語法問題,無法通過解析來確切知曉 JavaScript 代碼是什么類型。為了 Node.js 向后兼容的目標,作者需要加入一種新模式。已經有關于各種替代品的討論,但使用不同文件擴展名是解決目前問題的最佳權衡。

2.CJS 只能異步導入 ESM import()

Node.js 將異步加載 ESM,以便盡可能接近瀏覽器的行為。因此,同步的 require() 在 ESM 是不可能的,并且依賴于 ESM 的每個功能都需要異步:

const driverPromise = import("dbdriver");

exports.readFromDb = async (query) => {
   return (await driverPromise).read(query);
};

3. CJS 向 ESM 暴露一個不可變的默認導出

使用 Babel 或 Webpack,我們通常將 CJS 重構為 ESM,如下所示:

// CJS
const { a, b } = require("c");
// ESM
import { a, b } from "c";

再一次地,他們的語法看起來很相似,但忽略了 CJS 中沒有命名導出的事實。只有一個叫做 default 的導出,等同于在 CJS 模塊完成計算后一個不可變的 module.exports 。從技術上講,有可能將 module.exports 解構成命名導入,但這需要對標準作更大的變更。這就是現在 CTC 決定采取這種方式的原因

4.模塊范圍的變量類似 modulerequire 以及 __filename 在 ESM 不存在

Node.js 和瀏覽器會實現一些 ESM 的特性,但標準化過程仍在進行中

鑒于將 CJS 和 ESM 集成到一個運行時的工程挑戰,CTC 在評估邊緣情況和權衡方面做了非常好的工作。比如使用不同的文件擴展名是就是一個很簡單的解決方案。

實際上,一個文件擴展名可以認為是一個二進制文件如何解釋的提示。如果一個 module 不是 script,我們應該使用不同的文件擴展名。其他工具(如 linter 或 IDE )也可以獲取相同信息。

當然,引入新的文件擴展名有成本,但是一旦服務器和其他應用程序確認 *.mjs 為JavaScript,我們很快就會忘記這個爭議。

將 * .mjs 作為 Node.js 的 Python 3?

考慮到所有這些限制,人們可能會問,這種過渡將對現在的生態造成什么樣的損害。雖然 CTC 會努力解決問題,但社區如何采用這一點仍然存在很大不確定性。這種不確定性 被眾多知名的 NPM 模塊作者 再次強調,他們聲稱將不會在模塊中使用 *.mjs

Python 3 is killing Python

很難預測社區如何反應,但是應該不會對現在的生態造成大破壞,甚至能看到從 CJS 平穩過渡到 ESM。主要有兩個原因:

1.與 CJS 嚴格向后兼容

那些不喜歡 ESM 的模塊作者可以繼續使用 CJS,保證自己不被排擠出局。這樣他們自己的代碼不會受到采用 ESM 的影響,降低遷移到另一個運行時的可能性,讓 NPM 遷移到新生態變得容易。從 CJS 到 ESM 的重構給包維護者帶來額外工作,不能指望所有人都有時間。

2. CJS 在 ESM 中的無縫整合

從 ESM 導入 CJS 模塊非常簡單。需要注意的是,CJS 僅導出一個默認值。一旦處于 ESM,甚至可能根本不會注意到依賴關系使用的模塊風格,尤其是與在 CJS 中使用 await import()相比。

由于 ESM 的這個優點以及其他有點,比如開箱即用的 tree shaking 和瀏覽器兼容性,預計在未來幾年內,我們可以看到向 ESM 的緩慢而穩定的過渡。CJS 的特性,如動態 require() 和猴子補丁導出,在 Node.js 社區一直是有爭議的,不比 ESM 帶來的好處。

這些對我來說意味著什么?

因為最近這些事情,很容易對目前存在的所有選擇和限制感到困惑。在接下來,整理了開發人員面臨的典型問題以及我們的回答:

現在需要重構現有的代碼嗎?

不需要。Node.js 才剛剛開始實現 ESM,仍然有大量的工作要做。James M Snell 預計至少還需要一年時間,還有很多變化的余地,所以現在重構是不安全的。

應該在新代碼中使用 ESM 嗎?

  • 如果你已經有或者打算使用像 webpack 這樣的構建工具,答案是肯定的。這將更容易完成代碼庫的過渡,并使 tree shaking 成為可能。但要小心:一旦 Node.js 支持原生 ESM,可能需要重構其中的一些部分。
  • 如果你正在編寫一個庫,答案是也肯定的,你的模塊使用者將受益于 tree shaking。
  • 如果你不想進行構建操作,或者正在編寫一個 Node.js 應用程序,還是用 CJS 吧

現在應該使用 .mjs 嗎?

不要這樣做,目前沒有什么好處,工具支持依然薄弱。建議一旦原生 ESM 支持登陸 Node.js,盡快開始遷移。記住,瀏覽器只關心 MIME 類型,而不是文件擴展名

應該關心瀏覽器兼容性嗎?

是的,需要在一定程度上關注這個問題。 不應該在導入語句中省略 .js 擴展名,因為瀏覽器需要完整的 URL,無法像 Node.js 這樣執行路徑查詢。同樣,應該避免 index.js 文件。不過,人們并不會很快在瀏覽器中使用 NPM 軟件包,因為仍然不能 bare 導入。

作為庫作者該怎么辦?

用 ESM 編寫代碼,并使用 Rollup 或 Webpack 轉換成單個 CJS 模塊,然后在 package.jsonmain 字段指向此 CJS 包,并將 module 字段指向原始 ESM。如果還使用 ESM 之外的其他新語言功能,則應編譯成 ES5,并提供 CJS 和 ESM 的打包。這樣,你的庫用戶仍然可以從 tree shaking 獲利而無需對代碼進行轉換。

看一下這些完成 tree shaking 的模塊

總結

關于 ES 模塊有很多不確定性。由于目前 Node.js 在實現上的權衡,開發人員擔心可能會破壞 Node.js 的生態。

這些還不會發生,有兩個原因:CJS 的嚴格的后向兼容和 CJS 在 ESM 中的無縫集成

在 Node.js 發布原生 ESM 支持之前,應該仍然使用 Rollup 和 Webpack 等工具。它們在一定程度上模擬了 ESM 環境,但要注意它們不完全符合規范。此外,使用打包仍然是個很好的選擇,一旦可以在瀏覽器中使用 NPM 軟件包。

我們 webpack 團隊正在努力做一些工作,幫助開發者平穩過渡。為了這個目標,我們計劃在 Node.js 的 ESM 支持成熟后,模擬 Node.js 導入 CJS 的方式。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容

  • 無意中看到zhangwnag大佬分享的webpack教程感覺受益匪淺,特此分享以備自己日后查看,也希望更多的人看到...
    小小字符閱讀 8,214評論 7 35
  • GitChat技術雜談 前言 本文較長,為了節省你的閱讀時間,在文前列寫作思路如下: 什么是 webpack,它要...
    蕭玄辭閱讀 12,705評論 7 110
  • 版權聲明:本文為博主原創文章,未經博主允許不得轉載。 webpack介紹和使用 一、webpack介紹 1、由來 ...
    it筱竹閱讀 11,204評論 0 21
  • 寫在開頭 先說說為什么要寫這篇文章, 最初的原因是組里的小朋友們看了webpack文檔后, 表情都是這樣的: (摘...
    Lefter閱讀 5,310評論 4 31
  • 今天是我第二次造訪少年宮的中心書城,上一次是剛到深圳那會兒,因為面試的緣故,意外的走進了中心書城。第一次的初...
    東孫飛閱讀 377評論 0 3