CommonJS 和 ES Module 終于要互相兼容了???
共 4505字,需瀏覽 10分鐘
·
2024-04-03 00:43
在現(xiàn)代 JavaScript 開發(fā)中,ECMAScript Module 已經(jīng)逐漸成為了公認(rèn)的業(yè)界標(biāo)準(zhǔn)。自 ESM 被引入 Node.js 以來,它的異步加載特性和模塊解析邏輯廣受大家好評。
然而,由于歷史原因,很多既有代碼和第三方庫仍依賴于 CommonJS 模塊系統(tǒng),然而因為 ESM 的異步加載的設(shè)計,兩個模塊化方案一直是無法共存的,這也成了很多開發(fā)者的一大痛點。
最近, joyeecheung 提交的一個關(guān)鍵的 Pull Request(https://github.com/nodejs/node/pull/51977) 來解決這個問題。
在開始介紹前,我們先回顧一下 JavaScript 的兩大模塊化方案:CJS 和 ESM。
CJS 和 ESM 的前世今生
在 JavaScript 的世界里,模塊化是構(gòu)建大型應(yīng)用程序的基礎(chǔ)。模塊化可以幫助開發(fā)者在不影響全局命名空間的前提下管理代碼,便于功能分離、代碼復(fù)用和依賴管理。在 Node.js 和瀏覽器環(huán)境中,有兩種主流的模塊系統(tǒng):CommonJS(CJS)和 ECMAScript Module(ESM)。
CommonJS 是 Node.js 原生支持的模塊系統(tǒng),起初為了滿足服務(wù)端模塊化的需求而被引入。CJS 使用 require 函數(shù)來加載模塊,用 module.exports 或 exports 對象將代碼暴露為模塊。CommonJS 模塊的特點是同步加載,這意味著代碼會在模塊被加載完成后立即執(zhí)行:
// math.js
function add(x, y) {
return x + y;
}
module.exports = { add };
// app.js
const math = require('./math.js');
console.log(math.add(0, 17)); // 打印出 17
在服務(wù)器環(huán)境中,同步加載通常不是問題,因為文件大都在本地。然而,在瀏覽器環(huán)境中,同步加載可能會導(dǎo)致性能問題,因為它會阻塞瀏覽器的事件循環(huán),直到腳本完全下載和解析。
ESM 是現(xiàn)代 JavaScript 的官方標(biāo)準(zhǔn)模塊系統(tǒng),也被最新版本的瀏覽器原生支持。與 CommonJS 不同,它們設(shè)計成靜態(tài)的,這意味著你不能在運(yùn)行時動態(tài)地加載或創(chuàng)建模塊。ESM 使用 import 和 export 語句進(jìn)行模塊的導(dǎo)入和導(dǎo)出,支持異步加載:
// math.js
export function add(x, y) {
return x + y;
}
// app.js
import { add } from './math.js';
console.log(add(0, 17)); // 打印出17
ESM 的設(shè)計允許瀏覽器優(yōu)化加載和解析過程,如通過 HTTP/2 進(jìn)行有效的并行加載,以及進(jìn)行 tree shaking 以剔除未使用的代碼,從而增強(qiáng)性能和效率。但是,在 Node.js 中,ESM 的異步特性與現(xiàn)有的大量 CommonJS 模塊存在不兼容問題。
當(dāng)前在 Node.js 中啟用 ESM 的方法要復(fù)雜一些,因為代表性的 .js 文件擴(kuò)展名默認(rèn)與 CommonJS 模塊關(guān)聯(lián)。為了解決此問題,Node.js 允許使用 .mjs 文件擴(kuò)展名或在 package.json 中明確指定 "type": "module" 屬性來表示 ESM 模塊。
由于 ESM 是在 Node.js 中提供支持的,所以我們可以 import cjs,但不可能 require(esm)。這種 ERR_REQUIRE_ESM 的挫敗感困擾著許多人,并且可能是 Node.js 生態(tài)系統(tǒng)中浪費(fèi)時間的主要原因。
如果包作者想要確保 CJS 和 ESM 用戶都可以使用他們的包,他們要么必須繼續(xù)將其模塊作為 CJS 發(fā)布,要么將 CJS 和 ESM 版本即作為雙模塊發(fā)布(這可能會導(dǎo)致一些問題,但現(xiàn)在這是一種非常常見的做法)。
同時,許多轉(zhuǎn)譯器(例如 TypeScript 編譯器)仍然配置為生成 CJS 代碼作為其最終輸出。這些轉(zhuǎn)譯器的用戶使用 ESM 語法編寫代碼,但他們不一定知道他們的代碼最終由 Node.js 作為 CJS 運(yùn)行。當(dāng)他們的代碼使用真正的 ESM 第三方模塊(無法 require)時,他們會看到一個 ERR_REQUIRE_ESM 。這可能會非常令人困惑,因為他們可能假設(shè)他們的代碼是作為真正的 ESM 運(yùn)行的。
為啥不能兼容?
自然地,人們可能會問:為什么 require() 就不能支持加載 ESM 呢?
很長一段時間以來,Node.js 項目的答案總是這樣:
使用
require來加載 ES 模塊是不被支持的,因為 ES 模塊是異步執(zhí)行的。
但這是一種文檔和其他交流方式有誤導(dǎo)作用的情況 - 也許它們只在談?wù)撛?Node.js 的 ESM 中發(fā)生的事情,而不是 ESM 本身被設(shè)計成什么樣的。去年,當(dāng) joyeecheung 閱讀 V8 代碼來修復(fù)內(nèi)存泄漏問題時,偶然發(fā)現(xiàn) ESM 本身并不是真正設(shè)計成無條件異步的。而是設(shè)計成只在條件下異步 - 只有當(dāng)代碼中包含頂級 await 時才會異步。
那么,對 require() 至少支持不包含頂級 await 的 ESM 當(dāng)然就沒毛病了。雖然一些庫可能有合理的理由使用頂級 await,但這可能并不會那么常見。
的確,當(dāng) joyeecheung 后來在 npm 注冊表中對 Top 影響力的僅提供 ESM 支持的包進(jìn)行 require(esm) 測試時,測試的約 30 個包中沒有一個包含頂級 await - 并且在 require() 中支持同步模塊可能已經(jīng)足夠解決生態(tài)系統(tǒng)中的許多頭痛問題。
早期的探索與嘗試
同步 ESM 的支持其實也經(jīng)歷了長期的討論、設(shè)計和試驗。早在 2019 年,Node.js 社區(qū)就開始探討如何支持 ESM 和 CommonJS 之間的互操作性。期間,不少開發(fā)人員提交了 Pull Requests,提出不同的實現(xiàn)方案和改進(jìn)措施。
在那個時候,一個具有里程碑意義的 PR 討論集中在如何在 Node.js 中支持 .mjs 后綴的文件,以及如何實現(xiàn)一個雙模塊系統(tǒng),可以同時支持 CommonJS 和 ESM 。
這個 pull request 試圖通過在加載器中循環(huán)事件來處理頂級 await,但它的處理方式是不安全的,這就是它被關(guān)閉的原因。
在規(guī)范方面,基于語法的 ESM 同步評估的理論基礎(chǔ)在 2019 年已經(jīng)確定。隨著時間的推移,Node.js 中似乎發(fā)展出了一種關(guān)于 “ESM 是異步的,CJS 是同步的,所以 CJS 不能加載 ESM” 的神話,而在標(biāo)準(zhǔn)機(jī)構(gòu)中,ES 規(guī)范特別注意保證 ESM 只是有條件的異步,W3C 規(guī)范使用它確保 Service Workers 只允許同步模塊評估。如果規(guī)范中基于語法的同步性得到了更廣泛的認(rèn)知,那么在 2019 年后可能會有更多的嘗試,文檔也不會像無條件地談?wù)?ESM 是異步的。
支持同步 require(esm)
在去年年末,joyeecheung 發(fā)現(xiàn)根據(jù)語法,ESM 可以是同步的,而且只有 Node.js 把異步性投入到加載過程中后,于是 joyeecheung 和 GeoffreyBooth 開始討論重新啟動同步 require(esm)。
在 2024 年 2 月底,joyeecheung 在為 CJS 和 ESM 加載器做一個類似 cache 的事情,開始再次深入研究它們時,他注意到似乎有一種更簡單的實現(xiàn)方式 - 只需放棄“使 ESM 加載器成為 Node.js 中唯一的加載器” 的想法,并為 CJS 加載器實現(xiàn)一些專用程序以支持同步 require(esm)。它使用的現(xiàn)有 ESM 加載器代碼越少,就越容易。
所以,這就有了這個 PR。
https://github.com/nodejs/node/pull/51977
它與 2019 年的 PR 的主要區(qū)別在于,這試圖使 require(esm) 的范圍保持小,并且只支持加載同步 ESM。事實證明,這在技術(shù)指導(dǎo)委員會(TSC)中根本不是一個有爭議的想法,并且沒有遭到多少爭議。
目前這個特性仍然在 --experimental-require-module 標(biāo)志下進(jìn)行實驗,還有一些工作需要在它走出實驗階段之前完成。
目前, require(esm) 僅支持顯式標(biāo)記為 ESM 的 ESM - 通過 .mjs 擴(kuò)展名或者對 .js 擴(kuò)展名的 "type“: "module" 包字段。這已經(jīng)足夠支持在 npm 中加載僅 ESM 包的功能。它可以實現(xiàn)當(dāng) .js 文件出現(xiàn) ESM 語法且其最近的 package.json 中沒有 "type": "module" 字段時,回退到 ESM 加載,但這通常是用戶應(yīng)該避免的 - ESM 語法檢測會產(chǎn)生開銷,一旦你的項目中有足夠的 ESM 模塊,你可能不希望 Node.js 浪費(fèi)時間去猜測你的模塊類型。尤其是,當(dāng)你可以只用一個顯式的 "type": "module" 字段在你的 package.json 中就可以節(jié)省這些開銷。
最后
說實話這個問題也困擾我很久了,相比很多 NPM 包開發(fā)者也都深受其害,希望這次 joyeecheung 的嘗試可以盡早走向生產(chǎn)吧!
參考:
-
https://github.com/nodejs/node/pull/51977 -
https://joyeecheung.github.io/blog/2024/03/18/require-esm-in-node-js/
點贊和在看是最大的支持??????
