原文地址: redfin.engineering/node-module…javascript
翻譯的比較快,後面會持續修正,建議閱讀原文html
在 Node 14 的項目裏,咱們依然能看到混雜着 CommonJS(CJS) 和 ES Modules(ESM) 風格的代碼。CJS 使用的是 require() 和 module.exports;ESM 用的是是 import 和 exports。
首先 ESM 和 CJS 徹底是兩套不一樣的設計。表面上,ESM 使用起來雖然有點接近 CJS,可是實現差別巨大。java
對於大部分初級 Node 開發者來講,這些規則很是的難以理解,下面會詳細對這些展開介紹。
不少 Node 生態的圍觀羣衆都把這些問題歸結到 ESM 自己,可是接下來我會說明清楚,這些坑都是有其存在的緣由,以及將來也很難有完美的解決方案。
最後我也會給框架/庫的維護者 3 個建議:node
基本上就能避開大部分坑。git
Node 從誕生開始就使用了 CJS 規範來編寫模塊。咱們用 require() 引用模塊,用 exprts 來定義對外暴露的方法,有 module.exports.foo = 'bar' 或者 module.exports = 'baz'。
下面是一個CJS 的示例,區分兩種不一樣的 exports 方式對於使用上的差別。
命名導出:github
// @filename: util.cjs
module.exports.sum = (x, y) => x + y;
// @filename: main.cjs
const {sum} = require('./util.cjs');
console.log(sum(2, 4));
複製代碼
默認導出:npm
// @filename: util.cjs
module.exports = (x, y) => x + y;
// @filename: main.cjs
const whateverWeWant = require('./util.cjs');
console.log(whateverWeWant(2, 4));
複製代碼
ESM 規範使用的是 import 和 export,和 CJS 同樣也有兩種 export 的模式。
命名導出:json
// @filename: util.mjs
export const sum = (x, y) => x + y;
// @filename: main.mjs
import {sum} from './util.mjs'
console.log(sum(2, 4));
複製代碼
默認導出:api
// @filename: util.mjs
export default (x, y) => x + y;
// @filename: main.mjs
import whateverWeWant from './util.mjs'
console.log(whateverWeWant(2, 4));
複製代碼
CJS 的 require() 是同步的,實際執行的時候會從磁盤或者網絡中讀取文件,而後當即返回執行結果。被讀取的模塊有本身的執行邏輯,執行完成後經過 module.exports 返回結果。
ESM 的模塊加載是基於 Top-level await 設計的,首先解析 import 和 export 指令,再執行代碼,因此能夠在執行代碼以前檢測到錯誤的依賴。
ESM 模塊加載器在解析當前模塊依賴以後,會下線這些依賴模塊並在此解析,構建一個模塊依賴圖,直到依賴所有加載完成。最後,按照編寫的代碼,順序對應的依賴。
根據 ESM 約定,這些依賴的 ES 模塊都是並行下載最後順序執行。 promise
ESM 對於 JavaScript 來講是一個巨大的規範變化,ESM 規範默認使用了嚴格模式,致使 this 指向和做用域都有變化,因此即便在瀏覽器裏,
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 實現這個特性。擔心包括:
提議的 stage 3 版本直接回應了這些問題:
(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 模塊的討論(在評論前儘可能閱讀完整的文章內容以及對應的討論連接)。若是你深刻了解,會發現 top-level await 並非惟一的問題。若是你同步 require 了一個 ESM 模塊,而這個模塊又異步 import 了一個 CJS 模塊,而後這個 CJS 模塊又同步 require 了一個 ESM 模塊,你能設想執行結果麼。
因此,最後的結論仍是在任何狀況下不要用 require() 來引入一個 ESM 模塊。
若是你要在 CJS 代碼裏 import 一個 ESM 模塊,須要使用異步的 dyniamic import()。
(async () => {
const {foo} = await import('./foo.mjs');
})();
複製代碼
這麼寫或許沒啥問題,只要你不須要 exports 一些執行結果。若是須要,那麼你須要對外導出一個 Promise,對使用者來講就是一個不小的成本。
module.exports.foo = (async () => {
const {foo} = await import('./foo.mjs');
return foo;
})();
複製代碼
你能夠在 ESM 裏引入一個以下的 CJS 模塊:
import _ from './lodash.cjs'
複製代碼
可是你不能引用一個 CJS 模塊具體導出的接口
import {shuffle} from './lodash.cjs'
複製代碼
這是由於 CJS 代碼是在執行的時候計算導出結果,可是ESM是在解析期進行。
不過咱們也有一些應對方案,雖然有點煩,但至少能用,就像下面的代碼:
import _ from './lodash.cjs';
const {shuffle} = _;
複製代碼
這樣的代碼沒啥缺點,CJS 庫甚至能夠被封裝成 ESM 模塊。
這樣挺好,不過還能夠有一些更好的方式。
有些開發者提議過在執行 ESM 導入以前執行 CJS 導入。按照這個模式,CJS 的命名式導出就能夠和在 ESM 的解析期執行。
可是這樣會引入另一個問題:
import {liquor} from 'liquor';
import {beer} from 'beer';
複製代碼
若是 liquor 和 beer 都是 CJS 模塊,那麼將 liquor 改爲 ESM 會將原來 liquor, beer 的執行順序改爲 beer, liquor,若是 beer 依賴 liquor 的一些執行結果,就會有問題。
有一些另外的提議來想辦法解決執行順序問題,叫作動態模塊。
在 ESM 規範中,經過靜態聲明的方式聲明瞭全部命名導出。在動態模塊規範下,引用模塊時能夠定義導出的名字。ESM 加載器會默認信任動態模塊(CJS 代碼)會暴露全部須要的命名導出,若是沒有暴露,就會拋出錯誤。
不幸的是,動態模塊須要 JavaScript 語言作一些修改才能被 TC 39 委員會接受,然而並無被接受。
比較特別的是,ESM 代碼支持這樣的寫法:
export * from './foo.cjs'
複製代碼
這樣意味着會覆蓋原來導出的名字,這樣叫作「星號導出 」。
惋惜在這個寫法下,加載器依然不知道具體導出了什麼。
動態模塊也給規範的可塑性上帶來了問題,好比
export * from 'omg';
export * from 'bbq';
複製代碼
這樣寫會致使 omg 和 bbq 下同名的導出衝突。容許名字被開發者從新定義,也意味着導出校驗基本能夠忽略不用了。
動態模塊的支持者提議去掉「星號導出」,可是 TC39 委員會拒絕了。其中一個 TC39 成員稱這個提議像「語法毒藥」,由於「星號導出」會由於動態模塊帶來一些反作用。
(我認爲咱們一直處於語法毒藥的世界,在 Node 14 下,命名導出是有反作用的,在動態模塊下,星號導出也是有反作用的。因爲命名導出使用的頻繁但星號導出用的少,因此動態模塊對生態的影響相對更小)
這也是並非動態模塊的盡頭。有一個提議是全部 Node 模塊都應該是動態模塊,即便是 ESM 模塊,也就是要放棄 ESM 的多重解析加載器。使人意外的是,這個提議並無明顯的反作用,除了會有一些性能問題,畢竟ESM 加載器是面向弱網環境設計的。
不過不幸的是,動態模塊的 Github 討論 issue 已經由於一年沒有討論而關閉了。
社區裏還有另一個提議,升級 CJS 模塊解析器來支持解析導出內容,不過這個常識基本不太可能實現(最近的一次 PR對應的測試結果,只能在 npm 排名前 1000 的模塊中經過62%)。因爲該方案的可靠性不足,部分 Node 工做組的成員反對了這個方案。
ESM 模塊默認沒有 require 方法,可是你能夠很簡單地實現這個方法。
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const {foo} = require('./foo.cjs');
複製代碼
這樣寫的意義不大,而且還比原來的寫法要多謝幾行代碼,而且 Webpack 和 Rollup 這樣的工具並不知道該怎麼處理 createRequire 的類型。
若是你當前維護了一個同時支持 CJS 和 ESM 的庫,你能夠根據下面的指南作的更好。
這樣能夠確保你的庫在舊版本 Node 下跑的更好。
(若是你寫的是 TypeScript 或者其餘須要編譯到 JS 的語言,那麼編譯到 CJS。)
(將 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 兩個版本,可是這樣可能會致使開發者不當心同時引用了 ESM 和 CJS 版本。
Node 一般會作一些模塊的合併,可是沒法判斷同個庫的 CJS 和 ESM 文件是不是同一個文件,那麼真正執行的時候,這些代碼會被執行兩遍,形成一些不可預期的問題。
以下:
"exports": { "require": "./index.js", "import": "./esm/wrapper.js" }
複製代碼
注意:增長 exports 映射是一個不兼容變動。
默認狀況下,開發者是能夠訪問到依賴包裏的任何文件,包括那麼包開發者本來只是指望內部使用的。 exports 映射確保了開發者只能引用到明確的入口文件。
這樣很好,可是確實是一個不兼容變動。
(若是你原本就容許開發者來引用更多的文件,那麼能夠設置多個入口,能夠參考 ESM 文檔)
確保 exports 映射的文件是包含明確後綴的。用 "index.js" 而不是 "index" 或者相似 "./build" 這樣的目錄。
若是你按照上面的指南作,能夠避開大部分問題。