一篇文搞清楚 Node.js 模組行為,自由運用 CommonJS 與 ESM 模組

C.T. Lin
Dcard Tech Blog
Published in
6 min readJun 13, 2024

我們在 Dcard 前端的程式碼中,把 Server 端 Node.js 程式碼主體從 CommonJS 模組轉移到 ESM 已有約兩年的時間,但即便如此,還是很難避免使用到 CommonJS 模組的套件,又或者是有些套件只支援 CommonJS 模組的 config 檔或 plugin。

因此,搞清楚在沒有使用 Bundler 時,Node.js 處理不同模組互用之間的行為至關重要。

而我們又怎麼知道怎樣的模組引入會成功呢?首先可以先專注了解兩個部分:

  • 辨識模組類型與進入點
  • 了解不同種類模組間的互相引入方式

辨識模組類型與進入點

如果你已經很熟練的話,辨識模組的第一件事,一定是先打開 package.json 尋找有沒有這樣的一行:

{
// ...
"type": "module",
// ...
}

如果有的話,.js 檔將被當成 ESM, 如果不想要檔案被這行影響的話,檔名就必須要好好的寫成 .mjs(ESM)跟 .cjs(CommonJS)才行。

許多套件的作者至今都沒有把這部分處理正確,導致檔案被 Node.js 當成 CommonJS 模組,裡面卻寫著 import/export 等 ESM 語法,而造成語法錯誤。

要同時支援兩種模組的話,就可以考慮使用 conditional exports

{
// ...
"exports": {
"import": "./index-module.mjs",
"default": "./index-require.cjs"
},
// ...
}

我個人常用的安全做法,是讓 import 和 dynamic import 時使用 ESM 的檔案,其他情況則如以往使用 CommonJS 模組。

這邊常見的失誤是,沒有處理好副檔名,這樣其中一邊將會因為模組類型錯誤而報錯:

{
// ...
"exports": {
"import": "./index-module.js",
"default": "./index-require.js"
},
// ...
}

副檔名分好 .mjs.cjs 還是最可靠的。不過因為我們大部分檔案都是 ESM 了,所以大多是這樣設定的:

{
"type": "module",
"exports": {
"import": "./index-module.js",
"default": "./index-require.cjs"
},
// ...
}

模組之間的互相引用

同類模組引用

同種類的模組,在使用上是相當直覺的,CommonJS 模組直接呼叫 require 引入:

// file.cjs
const pkg = require('pkg-commonjs');

而 ESM 使用 import 關鍵字來引入:

// file.mjs
import pkg from 'pkg-esm';

ESM 引用 CommonJS 模組

ESM 要引入 CommonJS 模組的話,最有保障的方式是使用 createRequire

// file.mjs
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

const pkg = require('pkg-commonjs');

這樣產生的 require function 基本上就可以如同以往 CommonJS 模組的使用方法,甚至適用於 JSON 檔。

另一個可能沒太多人注意到的是,ESM 是可以直接 import CommonJS 模組的!

// file.mjs
import pkg from 'pkg-commonjs';

CommonJS 模組提供的 module.exports 可以用 default import 的方式引入。

不過不清楚套件是哪種模組的人,常常會無意識的使用 named imports:

// file.mjs
import { func } from 'pkg-commonjs';

這個部分需要倚賴 Node.js 內部的 CJS Module Lexer 能正確解析出 named exports,無法解析的話則會拋出下面這樣的錯誤:

import { func } from 'pkg-commonjs';
^^^^
SyntaxError: Named export 'func' not found. The requested module 'pkg-commonjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from 'pkg-commonjs';
const { func } = pkg;

這個部分也容易因為套件的程式碼改變而受到影響,可靠性會差一點,使用 default import 還是比較可靠的做法。

CommonJS Module 引用 ESM

CommonJS Module 要使用 ESM 的話,目前最可靠的方法是使用 dynamic import function:

// file.cjs
async function () {
const pkg = await import('pkg-esm');

// ...
};

不過這個方法的缺點顯而易見。

我們常常無法這麼輕易就把 function 改成 async function,而且必須知道 CommonJS Module 是不支援 top-level await 的!

最近 Node v22 推出了一個新的 flag — --experimental-require-module,來讓 CommonJS Module 可以用 require() 引入同步的 ESM。

Update: 最近 Node v20.17 也加入了這個功能

// file.cjs
const pkg = require('pkg-esm');
// [Module: null prototype] {
// default: [Function pkg],
// func: [Function: func]
// }
console.log(pkg);

假以時日若這個功能穩定,真的能幫助很大。

總結

考量到目前 CommonJS Module 要引用 ESM 遠比 ESM 去引用 CommonJS Module 困難,建議在改寫時盡量採用 top-down 的方式改寫,從進入點開始改,一路改到底端模組,這樣即可採用漸進式的方式改寫而不需要一步到位。

如果你覺得你已經充分的了解 Node.js 模組知識了,歡迎來考考看我之前出給公司同事的考題 — ESM Quizzes。少少三題的題目中,涵蓋了最核心的概念,能全部答對的話相當厲害!

--

--

C.T. Lin
Dcard Tech Blog

Architect @ Dcard. Author of Electron React Boilerplate and Bottender. JavaScript Developer.