自 13.2.0
版本開始,Node.js 在保留了 CommonJS(CJS)語法的前提下,新增了對 ES Modules(ESM)語法的支援。
天下苦 CJS 久已,Node 逐漸擁抱新標準的規劃當然值得稱讚,我們也會展望未來 Node 不再需要藉助工具,就能打破兩種模組化語法的壁壘……
但實際上,一切沒有想象中的那麼美好。
一、並不完美的 ESM 支援
1.1 在 Node 中使用 ESM
Node 預設只支援 CJS 語法,這意味著你書寫了一個 ESM 語法的 js 檔案,將無法被執行。
如果想在 Node 中使用 ESM 語法,有兩種可行方式:
- ⑴ 在
package.json
中新增"type": "module"
配置項。 - ⑵ 將希望使用 ESM 的檔案改為
.mjs
字尾。
對於第一種方式,Node 會將和 package.json
檔案同路徑下的模組,全部當作 ESM 來解析。
第二種方式不需要修改 package.json
,Node 會自動地把全部 xxx.mjs
檔案都作為 ESM 來解析。
同理,如果在
package.json
檔案中設定"type": "commonjs"
,則表示該路徑下模組以 CJS 形式來解析。
如果檔案字尾名為.cjs
,Node 會自動地將其作為 CJS 模組來解析(即使在package.json
中配置為 ESM 模式)。
我們可以通過上述修改 package.json
的方式,來讓全部模組都以 ESM 形式執行,然後專案上的模組都統一使用 ESM 語法來書寫。
如果存在較多陳舊的 CJS 模組懶得修改,也沒關係,把它們全部挪到一個資料夾,在該資料夾路徑下新增一個內容為 {"type": "commonjs"}
的 package.json
即可。
Node 在解析某個被引用的模組時(無論它是被 import
還是被 require
),會根據被引用模組的字尾名,或對應的 package.json
配置去解析該模組。
1.2 ESM 引用 CJS 模組的問題
ESM 基本可以順利地 import
CJS 模組,但對於具名的 exports(Named exports,即被整體賦值的 module.exports
),只能以 default export 的形式引入:
/** @file cjs/a.js **/
// named exports
module.exports = {
foo: () => {
console.log("It's a foo function...")
}
}
/** @file index_err.js **/
import { foo } from './cjs/a.js';
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();
/** @file index_err.js **/
import pkg from './cjs/a.js'; // 以 default export 的形式引入
pkg.foo(); // 正常執行
具體原因我們會在後續提及。
1.3 CJS 引用 ESM 模組的問題
假設你在開發一個供別人使用的開源專案,且使用 ESM 的形式匯出模組,那麼問題來了 —— 目前 CJS 的 require
函式無法直接引入 ESM 包,會報錯:
let { foo } = require('./esm/b.js');
^
Error [ERR_REQUIRE_ESM]: require() of ES Module BlogDemo3\220220\test2\esm\b.js from BlogDemo3\220220\test2\require.js not supported.
Instead change the require of b.js in BlogDemo3\220220\test2\require.js to a dynamic import() which is available in all CommonJS modules.
at Object.<anonymous> (BlogDemo3\220220\test2\require.js:4:15) {
code: 'ERR_REQUIRE_ESM'
}
按照上述錯誤陳述,我們不能並使用 require
引入 ES 模組(原因會在後續提及),應當改為使用 CJS 模組內建的動態 import
方法:
import('./esm/b.js').then(({ foo }) => {
foo();
});
// or
(async () => {
const { foo } = await import('./esm/b.js');
})();
開源專案當然不能強制要求使用者改用這種形式來引入,所以又得藉助 rollup 之類的工具將專案編譯為 CJS 模組……
由上可見目前 Node.js 對 ESM 語法的支援是有限制的,如果不借助工具處理,這些限制可能會很糟心。
對於想入門前端的新手來說,這些麻煩的規則和限制也會讓人困惑。
截至我落筆書寫本文時, Node.js LTS 版本為 16.14.0
,距離開始支援 ESM 的 13.2.0
版本已過去了兩年多的時間。
那麼為何 Node.js 到現在還無法打通 CJS 和 ESM?
答案並非 Node.js 敵視 ESM 標準從而遲遲不做優化,而是因為 —— CJS 和 ESM,二者真是太不一樣了。
二、CJS 和 ESM 的不同點
2.1 不同的載入邏輯
在 CJS 模組中,require()
是一個同步介面,它會直接從磁碟(或網路)讀取依賴模組並立即執行對應的指令碼。
ESM 標準的模組載入器則完全不同,它讀取到指令碼後不會直接執行,而是會先進入編譯階段進行模組解析,檢查模組上呼叫了 import
和 export
的地方,並順騰摸瓜把依賴模組一個個非同步、並行地下載下來。
在此階段 ESM 載入器不會執行任何依賴模組程式碼,只會進行語法檢錯、確定模組的依賴關係、確定模組輸入和輸出的變數。
最後 ESM 會進入執行階段,按順序執行各模組指令碼。
所以我們常常會說,CommonJS 模組是執行時載入,ES6 模組是編譯時輸出介面。
在上方 1.2 小節,我們曾提及到 ESM 中無法通過指定依賴模組屬性的形式引入 CJS named exports:
/** @file cjs/a.js **/
// named exports
module.exports = {
foo: () => {
console.log("It's a foo function...")
}
}
/** @file index_err.js **/
import { foo } from './cjs/a.js';
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();
這是因為 ESM 獲取所指定的依賴模組屬性(花括號內部的屬性),是需要在編譯階段進行靜態分析的,而 CJS 的指令碼要在執行階段才能計算出它們的 named exports 的值,會導致 ESM 在編譯階段無法進行分析。
2.2 不同的模式
ESM 預設使用了嚴格模式(use strict
),因此在 ES 模組中的 this
不再指向全域性物件(而是 undefined
),且變數在宣告前無法使用。
這也是為何在瀏覽器中,<script>
標籤如要啟用原生引入 ES 模組能力,必須加上 type="module"
告知瀏覽器應當把它和常規 JS 區分開來處理。
2.3 ESM 支援“頂級 await”,但 CJS 不行。
ESM 支援頂級 await
(top-level await),即 ES 模組中,無須在 async
函式內部就能直接使用 await
:
// index.mjs
const { foo } = await import('./c.js');
foo();
在 CSJ 模組中是沒有這種能力的(即使使用了動態的 import
介面),這也是為何 require
無法載入 ESM 的原因之一。
試想一下,一個 CJS 模組裡的 require
載入器同步地載入了一個 ES 模組,該 ES 模組裡非同步地 import
了一個 CJS 模組,該 CJS 模組裡又同步地去載入一個 ES 模組…… 這種複雜的巢狀邏輯處理起來會變得十分棘手。
2.4 ESM 缺乏 __filename 和 __dirname
在 CJS 中,模組的執行需要用函式包起來,並指定一些常用的值:
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
所以我們才可以在 CJS 模組裡直接用 __filename
、__dirname
。
而 ESM 的標準中不包含這方面的實現,即無法在 Node 的 ESM 裡使用 __filename
和 __dirname
。
參考:Node.js 原始碼。
從上方几點可以看出,在 Node.js 中,如果要把預設的 CJS 切換到 ESM,會存在巨大的相容性問題。
這也是 Node.js 目前,甚至未來很長一段時間,都難以解決的一場模組規範持久戰。
如果你希望不借助工具和規則,也能放寬心地使用 ESM,可以嘗試使用 Deno 替代 Node,它預設採用了 ESM 作為模組規範(當然生態沒有 Node 這麼完善)。
三、藉助工具實現 CJS、ESM 混寫
藉助構建工具可以實現 CJS 模組、ES 模組的混用,甚至可以在同一個模組同時混寫兩種規範的 API,讓開發不再需要關心 Node.js 上面的限制。另外構建工具還能利用 ESM 在編譯階段靜態解析的特性,實現 Tree-shaking 效果,減少冗餘程式碼的輸出。
這裡我們以 rollup 為例,先做全域性安裝:
pnpm i -g rollup
接著再安裝 rollup-plugin-commonjs 外掛,該外掛可以讓 rollup 支援引入 CJS 模組(rollup 本身是不支援引入 CJS 模組的):
pnpm i --save-dev @rollup/plugin-commonjs
我們在專案根目錄新建 rollup 配置檔案 rollup.config.js
:
import commonjs from 'rollup-plugin-commonjs';
export default {
input: 'index.js', // 入口檔案
output: {
file: 'bundle.js', // 目標檔案
format: 'iife'
},
plugins: [
commonjs({
transformMixedEsModules: true,
sourceMap: false,
})
]
};
plugin-commonjs
預設會跳過所有含import/export
的模組,如果要支援如import + require
的混合寫法,需要帶transformMixedEsModules
屬性。
接著執行 rollup --config
指令,就能按照 rollup.config.js
進行編譯和打包了。
示例
/** @file a.js **/
export let func = () => {
console.log("It's an a-func...");
}
export let deadCode = () => {
console.log("[a.js deadCode] Never been called here");
}
/** @file b.js **/
// named exports
module.exports = {
func() {
console.log("It's a b-func...")
},
deadCode() {
console.log("[b.js deadCode] Never been called here");
}
}
/** @file c.js **/
module.exports.func = () => {
console.log("It's a c-func...")
};
module.exports.deadCode = () => {
console.log("[c.js deadCode] Never been called here");
}
/** @file index.js **/
let a = require('./a');
import { func as bFunc } from './b.js';
import { func as cFunc } from './c.js';
a.func();
bFunc();
cFunc();
打包後的 bundle.js
檔案如下:
(function () {
'use strict';
function getAugmentedNamespace(n) {
if (n.__esModule) return n;
var a = Object.defineProperty({}, '__esModule', {value: true});
Object.keys(n).forEach(function (k) {
var d = Object.getOwnPropertyDescriptor(n, k);
Object.defineProperty(a, k, d.get ? d : {
enumerable: true,
get: function () {
return n[k];
}
});
});
return a;
}
let func$1 = () => {
console.log("It's an a-func...");
};
let deadCode = () => {
console.log("[a.js deadCode] Never been called here");
};
var a$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
func: func$1,
deadCode: deadCode
});
var require$$0 = /*@__PURE__*/getAugmentedNamespace(a$1);
var b = {
func() {
console.log("It's a b-func...");
},
deadCode() {
console.log("[b.js deadCode] Never been called here");
}
};
var func = () => {
console.log("It's a c-func...");
};
let a = require$$0;
a.func();
b.func();
func();
})();
可以看到,rollup 通過 Tree-shaking 移除掉了從未被呼叫過的 c 模組的 deadCode
方法,但 a、b 兩模組中的 deadCode
程式碼段未被移除,這是因為我們在引用 a.js
時使用了 require
,在 b.js
中使用了 CJS named exports,這些都導致了 rollup 無法利用 ESM 的特性去做靜態解析。
常規在開發專案時,還是建議儘量使用 ESM 的語法來書寫全部模組,這樣可以最大化地利用構建工具來減少最終構建檔案的體積。
希望本文能為你提供幫助,共勉~
FYI:
Node Modules at War: Why CommonJS and ES Modules Can’t Get Along
CommonJs和ES6 module的區別 - 王玉略的回答
阮一峰 ES6 - Module