Node 模組規範鏖戰:難以相容的 CJS 與 ESM

vajoy發表於2022-02-21

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();  // 正常執行

點選到 Github 獲取示例程式碼(test1)

具體原因我們會在後續提及。

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'); 
})();

點選到 Github 獲取示例程式碼(test2)
點選查閱 dynamic import 文件

開源專案當然不能強制要求使用者改用這種形式來引入,所以又得藉助 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 標準的模組載入器則完全不同,它讀取到指令碼後不會直接執行,而是會先進入編譯階段進行模組解析,檢查模組上呼叫了 importexport 的地方,並順騰摸瓜把依賴模組一個個非同步、並行地下載下來。

在此階段 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 區分開來處理。

檢視 ESM 嚴格模式的更多限制

2.3 ESM 支援“頂級 await”,但 CJS 不行。

ESM 支援頂級 awaittop-level await),即 ES 模組中,無須在 async 函式內部就能直接使用 await

// index.mjs
const { foo } = await import('./c.js');
foo();

點選到 Github 獲取示例程式碼(test3)

在 CSJ 模組中是沒有這種能力的(即使使用了動態的 import 介面),這也是為何 require 無法載入 ESM 的原因之一。

試想一下,一個 CJS 模組裡的 require 載入器同步地載入了一個 ES 模組,該 ES 模組裡非同步地 import 了一個 CJS 模組,該 CJS 模組裡又同步地去載入一個 ES 模組…… 這種複雜的巢狀邏輯處理起來會變得十分棘手。

點選查閱關於更多“如何實現 require 載入 ESM”的討論。

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();

點選到 Github 獲取示例程式碼(test4)

打包後的 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

相關文章