JS 社區(qū)臭名昭著的一個(gè)問(wèn)題
大廠技術(shù)??高級(jí)前端??Node進(jìn)階
點(diǎn)擊上方?程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群

今天因?yàn)?esbuild 的一個(gè) bug ,需要升級(jí) esbuild 的版本,升級(jí)完后驚訝的發(fā)現(xiàn) Babel 居然掛了,我只是升級(jí)了個(gè)小版本(0.14.1 -> 0.14.5),理應(yīng)不該出現(xiàn)如此大的變動(dòng),后來(lái)追蹤了下 esbuild 的 changelog ,發(fā)現(xiàn)了 esbuild 在 0.14.4 引入了一個(gè)巨大的 breaking change (嚴(yán)謹(jǐn)如 esbuild 也沒(méi)嚴(yán)格遵循語(yǔ)義化版本,可見(jiàn)業(yè)務(wù)如果強(qiáng)依賴語(yǔ)義化版本是個(gè)多不靠譜的事情)。
esbuild 0.14.4 引入的 breaking ,正是 js 社區(qū)臭名昭著的一個(gè)問(wèn)題,即 ESM 和 CJS 的 Interop(互操作性)問(wèn)題,esbuild 的 changelog 寫了相當(dāng)長(zhǎng)的篇幅總結(jié)了這個(gè)問(wèn)題( esbuild 的 changelog 是業(yè)界良心,總能學(xué)到新東西)。下面內(nèi)容均來(lái)自 esbuild changelog 的翻譯。
在開(kāi)發(fā) ECMAScript 模塊導(dǎo)入/導(dǎo)出語(yǔ)法時(shí),CommonJS 模塊格式(用于 Node.js )已經(jīng)被廣泛使用。正因?yàn)槿绱?,為了解決 ESM 和 CJS 的交互性問(wèn)題,名為 default 的導(dǎo)出名稱被賦予了特殊的語(yǔ)法。你可以不寫 import { default as foo } from 'bar',而只寫 import foo from 'bar'。
這個(gè)想法的初衷是,當(dāng) ECMAScript 模塊(又稱 ES 模塊)被引入時(shí),你可以使用新的導(dǎo)入語(yǔ)法來(lái)導(dǎo)入現(xiàn)有的 CommonJS 模塊來(lái)實(shí)現(xiàn)兼容性。由于 CommonJS 模塊的導(dǎo)出是動(dòng)態(tài)的,而 ES 模塊的導(dǎo)出是靜態(tài)的,一般來(lái)說(shuō),在模塊實(shí)例化的時(shí)候不可能確定一個(gè) CommonJS 模塊的導(dǎo)出名稱,因?yàn)榇藭r(shí)代碼還沒(méi)有被執(zhí)行。所以 module.exports 的值只能作為默認(rèn)的導(dǎo)出(因?yàn)闊o(wú)法確定其他 name ,只能約定一個(gè) default 作為整體的導(dǎo)出 name ),特殊的默認(rèn)導(dǎo)入語(yǔ)法讓你很容易訪問(wèn) module.exports(即import foo from 'bar'等價(jià)于 const foo = require('bar')。
到這里一切設(shè)計(jì)都很合乎情理,似乎這個(gè)設(shè)計(jì)也無(wú)懈可擊,然而這里同時(shí)埋下了禍根,即這個(gè)交互性問(wèn)題其實(shí)只需要支持個(gè)
import foo from 'bar'這個(gè) syntax sugar (語(yǔ)法糖)即可滿足,然而卻同時(shí)錯(cuò)誤的支持了export default 'xxx'這個(gè)語(yǔ)法,為后續(xù)的交互性問(wèn)題埋下了禍根。
然而(一切不幸的開(kāi)始),ES 模塊語(yǔ)法需要一段時(shí)間才能被 JavaScript 運(yùn)行系統(tǒng)原生支持,而人們?nèi)匀幌M谶@期間開(kāi)始使用 ES 模塊語(yǔ)法。Babel 通過(guò)將 ES 編譯到 CJS 讓你現(xiàn)在就可以使用 ES 模塊進(jìn)行編碼。你可以將每個(gè) ES 模塊文件轉(zhuǎn)化為一個(gè)行為相同的 CommonJS 模塊文件。
然而,這種轉(zhuǎn)換有一個(gè)問(wèn)題:如何準(zhǔn)確的將import語(yǔ)法降級(jí)到 commonjs,上述設(shè)計(jì)意味著export default 0和import foo from 'bar'在轉(zhuǎn)換為 CommonJS 時(shí)行為將不再一致。代碼export default 0變成了module.exports.default = 0,代碼import foo from 'bar'變成了const foo = require('bar') (這里是為了對(duì)齊上述的交互性行為)。這導(dǎo)致代碼在降級(jí)到 cjs 前和降級(jí)到 cjs 后的行為是不一致的了。
降級(jí)前:
bar.js
export?default?0
foo.js
import?foo?from?'bar'?//?foo結(jié)果應(yīng)該為0
console.log('foo',foo);
降級(jí)后:
bar.js
module.exports.default?=?0
foo.js
const?foo?=?require('bar')?//?foo結(jié)果為{default:0}
console.log('foo',foo);
降級(jí)前后運(yùn)行結(jié)果不一致,這是非常顯然的bug。
為了解決這個(gè)問(wèn)題,Babel 在將 ES 模塊轉(zhuǎn)換為 CommonJS 模塊時(shí),通過(guò)將屬性 __esModule 設(shè)置為true 標(biāo)記這個(gè)模塊是一個(gè)編譯后的 ES 模塊。然后,在導(dǎo)入 default 導(dǎo)出時(shí),它可以知道使用 module.exports.default 的值,而不是 module.exports 的值,以確保 CommonJS 模塊的行為與原始 ES 模塊的行為正確匹配。這一修正在整個(gè)生態(tài)系統(tǒng)中被廣泛采用,并進(jìn)入了其他工具,如 TypeScript ,甚至 esbuild 。babel 修復(fù)后的結(jié)果如下:
bar.js
"use?strict";
Object.defineProperty(exports,?"__esModule",?{?value:?true?});
exports.default?=?0;
foo.js
"use?strict";
var?__importDefault?=?(this?&&?this.__importDefault)?||?function?(mod)?{
????return?(mod?&&?mod.__esModule)???mod?:?{?"default":?mod?};
};
Object.defineProperty(exports,?"__esModule",?{?value:?true?});
var?bar_1?=?__importDefault(require("bar"));
consol.log('foo',?bar_1.default);?//?結(jié)果為0
//?計(jì)算過(guò)程如下
*?require("bar")=>?{default:?0,__esModule:?true}
*?bar_1?=>?__importDefault?=>?({default:0,_esModule}).__esModule???({default:?__esModule})?:?{default:?{default:0,__esModule:true}}?=>?{default:?0,__esModule:true}
*?bar_1.default?=>?({default:0,__esModule:true}).default?=>?0
至此,前端社區(qū)的代碼實(shí)際上可以認(rèn)為跑在了一個(gè)虛擬的 Babel |Webpack 的 runtime上,這個(gè) babel runtime 通過(guò)將ES編譯為CJS幫我們解決了ESM和CJS的交互性問(wèn)題了,如果沒(méi)有后續(xù)Node的背刺,實(shí)際上已經(jīng)是趨于穩(wěn)定了。
然而(另一個(gè)不幸的事情,讓事情雪上加霜),當(dāng) Node.js 最終發(fā)布他們的 ES 模塊實(shí)現(xiàn)時(shí),他們采用了原來(lái)的實(shí)現(xiàn),即 default 導(dǎo)出總是等于 module.exports ,這打破了與現(xiàn)有的 ES 模塊生態(tài)系統(tǒng)的兼容性(即和 Babel runtime 的兼容性),這些模塊已經(jīng)被 Babel 交叉編譯成 CommonJS 模塊。現(xiàn)在你必須根據(jù)你的代碼是需要在 Node 環(huán)境中還是在 Babel 環(huán)境中運(yùn)行,來(lái)添加或刪除一個(gè)額外的 .default 屬性,這就導(dǎo)致了更嚴(yán)重性的互操作性問(wèn)題。此外,像 esbuild 這樣的 JavaScript 工具現(xiàn)在需要猜測(cè)你是想要 Node 風(fēng)格還是 Babel 風(fēng)格的默認(rèn)導(dǎo)入。工具沒(méi)有辦法肯定地知道某個(gè)文件所期望的是哪一種,如果你的工具猜錯(cuò)了,你的代碼就會(huì)被破壞。
至此我們總結(jié)下,目前 ESM 和 CJS 的交互性問(wèn)題,由三件不幸的事情組成,
import xxx from 'bar'本來(lái)應(yīng)該是個(gè)處理交互性的語(yǔ)法糖,但是并沒(méi)有和其他的模塊導(dǎo)入 && 導(dǎo)出進(jìn)行區(qū)分(就不應(yīng)該支持export default), Babel 錯(cuò)誤的實(shí)現(xiàn)了 ESM 到 CJS 的降級(jí)方案,雖然后來(lái)修復(fù)了但是還是造成了一定問(wèn)題,node 選擇了與 Babel runtime (前端社區(qū))不兼容的方案,導(dǎo)致市面上存在兩套 interop 的邏輯,并且彼此不兼容,我們可以明顯的感知到node社區(qū)和前端社區(qū)存在很大的割裂性。
esbuild 的兼容性修復(fù)
這個(gè)版本改變了 esbuild 圍繞默認(rèn)導(dǎo)出和 __esModule 標(biāo)記的啟發(fā)式方法,以試圖改善與 Webpack 和 Node 的兼容性(大部分的生態(tài)都是基于他倆),其行為變化如下:
舊的行為:
如果導(dǎo)入語(yǔ)句被用來(lái)加載一個(gè)CommonJS文件,并且 module.exports 中存在 default 屬性,那么 esbuild 將把默認(rèn)導(dǎo)出設(shè)置為 module.exports.default(像 Babel)。否則默認(rèn)出口被設(shè)置為 module.exports(像Node)。 module.exports 是一個(gè)對(duì)象, module.exports.__esModule 是 truthy ,并且 如果一個(gè) require 調(diào)用被用來(lái)加載一個(gè) ES 模塊文件,返回的模塊命名空間對(duì)象的 __esModule 屬性被設(shè)置為 true 。這就像 ES 模塊通過(guò) Babel 兼容的轉(zhuǎn)換被轉(zhuǎn)換為 CommonJS 一樣。 當(dāng)編寫純 ESM 代碼時(shí),esModule 標(biāo)記可能會(huì)不一致地出現(xiàn)在模塊命名空間對(duì)象上(即 import * as)。具體來(lái)說(shuō),如果一個(gè)模塊命名空間對(duì)象被物化(materialized)了,那么 esModule 標(biāo)記就會(huì)出現(xiàn),但如果它被優(yōu)化掉了,那么 __esModule 標(biāo)記就會(huì)消失。不允許創(chuàng)建一個(gè)名為 esModule 的 ES 模塊導(dǎo)出。這避免了生成的代碼與上述行為沖突導(dǎo)致代碼 break ,同時(shí)也避免了 esModule 的重復(fù)定義問(wèn)題。
新的行為:
如果導(dǎo)入語(yǔ)句被用來(lái)加載一個(gè)CommonJS文件,并且 文件名不是以 .mjs 或 .mts 結(jié)尾,package.json 文件不包含 "type": "module",那么 esbuild 將把默認(rèn)導(dǎo)出設(shè)置為 module.exports.default(像Babel一樣)。否則,默認(rèn)出口將被設(shè)置為module.exports(像 Node )。 module.exports 是一個(gè)對(duì)象 module.exports.__esModule是真實(shí)的,并且
請(qǐng)注意,這意味著默認(rèn)出口在以前沒(méi)有被定義的情況下現(xiàn)在可能是未定義的。這與 Webpack 的行為相匹配,所以希望它能更加兼容。
還要注意,這意味著導(dǎo)入行為現(xiàn)在取決于文件的擴(kuò)展名和 package.json 的內(nèi)容。這也符合 Webpack 的行為,希望能提高兼容性。
如果一個(gè) require 調(diào)用被用來(lái)加載一個(gè) ES 模塊文件,返回的模塊命名空間對(duì)象的 __esModule屬性被設(shè)置為true。這就像ES模塊已經(jīng)通過(guò)Babel兼容的轉(zhuǎn)換被轉(zhuǎn)換為CommonJS一樣。 如果導(dǎo)入語(yǔ)句或 import() 表達(dá)式被用來(lái)加載一個(gè) ES 模塊,esModule 標(biāo)記現(xiàn)在不應(yīng)該出現(xiàn)在模塊命名空間對(duì)象上。這釋放了 esModule 的導(dǎo)出名稱,使其可以用于 ES 模塊。 現(xiàn)在允許在 ES 模塊中使用 __esModule 作為一個(gè)正常的導(dǎo)出名。這個(gè)屬性可以被其他 ES 模塊訪問(wèn),但不能被使用 require 加載 ES 模塊的代碼訪問(wèn),他們將會(huì)始終看到這個(gè)屬性被設(shè)置為 true。
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
???“分享、點(diǎn)贊、在看” 支持一波??
