【譯】Node 模塊之戰(zhàn):為什么 CommonJS 和 ES Module 不能共存
授權轉載自:步天
來源:https://juejin.im/post/6865557155102064648
翻譯自:https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1
翻譯的比較快,后面會持續(xù)修正,建議閱讀原文
在 Node 14 的項目里,我們依然能看到混雜著 CommonJS(CJS) 和 ES Modules(ESM) 風格的代碼。CJS 使用的是 require() 和 module.exports;ESM 用的是是 import 和 exports。
首先 ESM 和 CJS 完全是兩套不同的設計。表面上,ESM 使用起來雖然有點接近 CJS,但是實現差異巨大。
ESM 與 CJS 之間可以相互引用,但是有大量的坑
只能用 import() 調用 ESM 模塊,require() 不行,比如 import {foo} from 'foo' CJS 模塊不能使用 import 語法 CJS 模塊可以用異步的 dynamic import() 來加載 ESM 模塊,但是相對同步的 require 來說,會有一些坑 ESM 模塊可以 import CJS 模塊,但是只能通過“默認導入”的模式,比如 import _ from 'lodash',而不支持“命名導入”,比如 import {shuffle} from 'lodash'。 ESM 模塊可以 require() CJS 模塊,包括“命名導出”的,但是依然會有很多問題,類似 Webpack 或者 Rollup 這樣的工具甚至不知道該怎么出處理 ESM 里的 require() 代碼。 Node 默認支持的還是 CJS 規(guī)范,你需要選擇用 .mjs 這樣的后綴,或者在 package.json 里設置 "type": "module" 才能開啟 ESM 模式。通過 package.json 開啟的話,如果有 CJS 規(guī)范的文件,就得相反將后綴改成 .cjs。
對于大部分初級 Node 開發(fā)者來說,這些規(guī)則非常的難以理解,下面會詳細對這些展開介紹。
很多 Node 生態(tài)的圍觀群眾都把這些問題歸結到 ESM 本身,但是接下來我會說明清楚,這些坑都是有其存在的原因,以及未來也很難有完美的解決方案。
最后我也會給框架/庫的維護者 3 個建議:
提供 CJS 版本 基于 CJS 版本簡單包一個 ESM 版本出來 在項目的 package.json 里添加一個 exports 映射
基本上就能避開大部分坑。
Discuss on Reddit Discuss on Hacker News
背景:CJS 和 ESM 是什么?
Node 從誕生開始就使用了 CJS 規(guī)范來編寫模塊。我們用 require() 引用模塊,用 exprts 來定義對外暴露的方法,有 module.exports.foo = 'bar' 或者 module.exports = 'baz'。
下面是一個CJS 的示例,區(qū)分兩種不同的 exports 方式對于使用上的差異。
命名導出:
//?@filename:?util.cjs?
module.exports.sum?=?(x,?y)?=>?x?+?y;?
//?@filename:?main.cjs?
const?{sum}?=?require('./util.cjs');?
console.log(sum(2,?4));
默認導出:
//?@filename:?util.cjs?
module.exports?=?(x,?y)?=>?x?+?y;?
//?@filename:?main.cjs?
const?whateverWeWant?=?require('./util.cjs');?
console.log(whateverWeWant(2,?4));
ESM 規(guī)范使用的是 import 和 export,和 CJS 一樣也有兩種 export 的模式。
命名導出:
//?@filename:?util.mjs?
export?const?sum?=?(x,?y)?=>?x?+?y;?
//?@filename:?main.mjs?
import?{sum}?from?'./util.mjs'?
console.log(sum(2,?4));
默認導出:
//?@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 的 require() 是同步的,實際執(zhí)行的時候會從磁盤或者網絡中讀取文件,然后立即返回執(zhí)行結果。被讀取的模塊有自己的執(zhí)行邏輯,執(zhí)行完成后通過 module.exports 返回結果。
ESM 的模塊加載是基于 Top-level await 設計的,首先解析 import 和 export 指令,再執(zhí)行代碼,所以可以在執(zhí)行代碼之前檢測到錯誤的依賴。
ESM 模塊加載器在解析當前模塊依賴之后,會下線這些依賴模塊并在此解析,構建一個模塊依賴圖,直到依賴全部加載完成。最后,按照編寫的代碼,順序對應的依賴。
根據 ESM 約定,這些依賴的 ES 模塊都是并行下載最后順序執(zhí)行。
Node 默認 CJS 規(guī)范是因為 ESM 的不兼容變更
ESM 對于 JavaScript 來說是一個巨大的規(guī)范變化,ESM 規(guī)范默認使用了嚴格模式,導致 this 指向和作用域都有變化,所以即使在瀏覽器里,
CJS 無法 require() 基于 Top-level await 設計的ESM 模塊
CJS 無法 require() ESM 模塊,最簡單的原因就是 ESM 支持 Top-level await,但是 CJS 不支持。
Top-level await 支持在非 async 函數中使用 await。
ESM 支持多重解析的加載器,在不帶來更多問題的情況下,讓 Top-level await 變得可能。引用 V8 團隊博客的內容:或許你層級看到過 > Rich Harris 寫的 > gist,表達了一系列對于 Top-level await 的擔憂,并抵制 JavaScript 實現這個特性。擔憂包括:
Top-level await 可能會中斷執(zhí)行。 Top-level await 可能會終端資源下載。 無法和 CJS 模塊互通。
提議的 stage 3 版本直接回應了這些問題:
只要模塊能夠被執(zhí)行,就不會有中斷的問題。 Top-level await 在解析模塊依賴圖的階段執(zhí)行。在這個階段,所有字段都已經下載并建立對應關系,并不會阻斷資源下載。 Top-level await 限定在 ESM 模塊下,不會支持 CJS 模塊(沒有互通的必要)。
(Rich 現在已經接受了目前的 Top-level await 實現)
由于 CJS 不支持 top-level await,所以基本也無法把 ESM 的 top-level await 編譯成 CJS 代碼。那么,你會如何用 CJS 重寫下面的代碼?
export?const?foo?=?await?fetch('./data.json');
令人沮喪的是,絕大多數 ESM 代碼并沒有用到 top-level await 的寫法,不過這不是一個需要糾結的問題。
目前還有一個如何 require() ESM 模塊的討論(在評論前盡量閱讀完整的文章內容以及對應的討論鏈接)。如果你深入了解,會發(fā)現 top-level await 并不是唯一的問題。如果你同步 require 了一個 ESM 模塊,而這個模塊又異步 import 了一個 CJS 模塊,然后這個 CJS 模塊又同步 require 了一個 ESM 模塊,你能設想執(zhí)行結果么。
所以,最后的結論還是在任何情況下不要用 require() 來引入一個 ESM 模塊。
CJS 可以 import() ESM,但也不是一個好主意
如果你要在 CJS 代碼里 import 一個 ESM 模塊,需要使用異步的 dyniamic import()。
(async?()?=>?{??
????const?{foo}?=?await?import('./foo.mjs');?
})();
這么寫或許沒啥問題,只要你不需要 exports 一些執(zhí)行結果。如果需要,那么你需要對外導出一個 Promise,對使用者來說就是一個不小的成本。
module.exports.foo?=?(async?()?=>?{??
??const?{foo}?=?await?import('./foo.mjs');??
??return?foo;?
})();
ESM 不能引入導出命名變量的 CJS 模塊否則 CJS 代碼執(zhí)行順序會和期望的不同
你可以在 ESM 里引入一個如下的 CJS 模塊:
import?_?from?'./lodash.cjs'
但是你不能引用一個 CJS 模塊具體導出的接口
import?{shuffle}?from?'./lodash.cjs'
這是因為 CJS 代碼是在執(zhí)行的時候計算導出結果,但是ESM是在解析期進行。
不過我們也有一些應對方案,雖然有點煩,但至少能用,就像下面的代碼:
import?_?from?'./lodash.cjs';?
const?{shuffle}?=?_;
這樣的代碼沒啥缺點,CJS 庫甚至可以被封裝成 ESM 模塊。
這樣挺好,不過還可以有一些更好的方式。
執(zhí)行順序不可控會導致一些糟糕的問題
有些開發(fā)者提議過在執(zhí)行 ESM 導入之前執(zhí)行 CJS 導入。按照這個模式,CJS 的命名式導出就可以和在 ESM 的解析期執(zhí)行。
但是這樣會引入另外一個問題:
import?{liquor}?from?'liquor';?
import?{beer}?from?'beer';
如果 liquor 和 beer 都是 CJS 模塊,那么將 liquor 改成 ESM 會將原來 liquor, beer 的執(zhí)行順序改成 beer, liquor,如果 beer 依賴 liquor 的一些執(zhí)行結果,就會有問題。
動態(tài)模塊可以解決問題,但也會帶來其他坑
有一些另外的提議來想辦法解決執(zhí)行順序問題,叫做動態(tài)模塊。
在 ESM 規(guī)范中,通過靜態(tài)聲明的方式聲明了所有命名導出。在動態(tài)模塊規(guī)范下,引用模塊時可以定義導出的名字。ESM 加載器會默認信任動態(tài)模塊(CJS 代碼)會暴露所有需要的命名導出,如果沒有暴露,就會拋出錯誤。
不幸的是,動態(tài)模塊需要 JavaScript 語言做一些修改才能被 TC 39 委員會接受,然而并沒有被接受。
比較特別的是,ESM 代碼支持這樣的寫法:
export?*?from?'./foo.cjs'
這樣意味著會覆蓋原來導出的名字,這樣叫做“星號導出 ”。
可惜在這個寫法下,加載器依然不知道具體導出了什么。
動態(tài)模塊也給規(guī)范的可塑性上帶來了問題,比如
export?*?from?'omg';??
export?*?from?'bbq';
這樣寫會導致 omg 和 bbq 下同名的導出沖突。允許名字被開發(fā)者重新定義,也意味著導出校驗基本可以忽略不用了。
動態(tài)模塊的支持者提議去掉“星號導出”,但是 TC39 委員會拒絕了。其中一個 TC39 成員稱這個提議像“語法毒藥”,因為“星號導出”會因為動態(tài)模塊帶來一些副作用。
(我認為我們一直處于語法毒藥的世界,在 Node 14 下,命名導出是有副作用的,在動態(tài)模塊下,星號導出也是有副作用的。由于命名導出使用的頻繁但星號導出用的少,所以動態(tài)模塊對生態(tài)的影響相對更小)
這也是并不是動態(tài)模塊的盡頭。有一個提議是所有 Node 模塊都應該是動態(tài)模塊,即使是 ESM 模塊,也就是要放棄 ESM 的多重解析加載器。令人意外的是,這個提議并沒有明顯的副作用,除了會有一些性能問題,畢竟ESM 加載器是面向弱網環(huán)境設計的。
不過不幸的是,動態(tài)模塊的 Github 討論 issue 已經因為一年沒有討論而關閉了。
社區(qū)里還有另外一個提議,升級 CJS 模塊解析器來支持解析導出內容,不過這個常識基本不太可能實現(最近的一次 PR對應的測試結果,只能在 npm 排名前 1000 的模塊中通過62%)。由于該方案的可靠性不足,部分 Node 工作組的成員反對了這個方案。
ESM 可以 require(),但并不值得這么做
ESM 模塊默認沒有 require 方法,但是你可以很簡單地實現這個方法。
import?{?createRequire?}?from?'module';?
const?require?=?createRequire(import.meta.url);??
const?{foo}?=?require('./foo.cjs');
這樣寫的意義不大,并且還比原來的寫法要多謝幾行代碼,并且 Webpack 和 Rollup 這樣的工具并不知道該怎么處理 createRequire 的類型。
同時支持 CJS 和 ESM 包最佳實踐是什么
如果你當前維護了一個同時支持 CJS 和 ESM 的庫,你可以根據下面的指南做的更好。
提供一個 CJS 版本
這樣可以確保你的庫在舊版本 Node 下跑的更好。
(如果你寫的是 TypeScript 或者其他需要編譯到 JS 的語言,那么編譯到 CJS。)
基于 CJS 封裝到 ESM 版本
(將 CJS 封裝到 ESM 很容易,但是 ESM 庫是沒法封裝到 CJS 庫的。)
import?cjsModule?from?'../index.js';?
export?const?foo?=?cjsModule.foo;
把 ESM 封裝放到 esm 子目錄下,同時在 package.json 里聲明 {"type": "module"} 。(在 Node 14 下你也可以用 .mjs 后綴,不過有一些工具不一定支持 .mjs 文件,建議還是用子目錄的方式)
避免雙重編譯
如果你在用 TypeScript,是可以把 TypeScript 編譯出 CJS 和 ESM 兩個版本,但是這樣可能會導致開發(fā)者不小心同時引用了 ESM 和 CJS 版本。
Node 通常會做一些模塊的合并,但是無法判斷同個庫的 CJS 和 ESM 文件是否是同一個文件,那么真正執(zhí)行的時候,這些代碼會被執(zhí)行兩遍,造成一些不可預期的問題。
在 package.json 里增加 exports 映射
如下:
"exports":?{??"require":?"./index.js",??"import":?"./esm/wrapper.js"?}
注意:增加 exports 映射是一個不兼容變更。
默認情況下,開發(fā)者是可以訪問到依賴包里的任何文件,包括那么包開發(fā)者原本只是期望內部使用的。exports 映射確保了開發(fā)者只能引用到明確的入口文件。
這樣很好,但是確實是一個不兼容變更。
(如果你本來就允許開發(fā)者來引用更多的文件,那么可以設置多個入口,可以參考 ESM 文檔)
確保 exports 映射的文件是包含明確后綴的。用 "index.js" 而不是 "index" 或者類似 "./build" 這樣的目錄。
如果你按照上面的指南做,可以避開大部分問題。
