最近為了讓 Kosko 和 kubernetes-models 能夠支援瀏覽器或是 Deno,所以先做了一些前期準備,首先最重要的就是支援 ECMAScript Module (ESM),因為這是目前所有平台都能支援的標準,但是為了要保持 Node.js 的相容性,所以暫時還是不能放下 CommonJS。
這篇文章會介紹如何讓 Node.js package 能夠同時支援 CommonJS 和 ESM,以及使用 ESM 時的注意事項。
先講結論
以最低支援版本來區分。
Node.js 10 以上:
- CommonJS 輸出成
.js
副檔名 - ESM 輸出成
.mjs
副檔名 package.json
加上module
Node.js 12 以上:
- CommonJS 輸出成
.cjs
副檔名 - ESM 輸出成
.mjs
副檔名 package.json
加上module
和exports
,type
可設定為module
副檔名
正常來說,比較建議的做法是 CommonJS 一律採用 .cjs
副檔名,ESM 一律採用 .mjs
副檔名,這樣就能避免 Node.js 用 package.json
的 type
來判斷,但是在以下情況下會出問題。
Node.js 10
如果你需要 require package 裡的路徑的話,在 Node.js 10 可能就會出問題。
舉例來說,當 require package 的時候,Node.js 會根據 package.json
裡設定的 main
來決定路徑,所以不管副檔名是什麼都無所謂,只要內容是 CommonJS 就好。
// 假設 package.json 的內容是 {"main": "index.cjs"} 的話
require('example');
// -> node_modules/example/index.cjs
但如果是 require package 裡的路徑的話,就不會去參考 package.json
的設定了,如果 require 時沒有加上副檔名的話,就會根據 require.extensions
來尋找對應檔案。
// 預設只支援 .js, .json, .node
require('example/foo');
// -> node_modules/example/foo.js
// -> node_modules/example/foo.json
// -> node_modules/example/foo.node
如果把 CommonJS 檔案都一律改成 .cjs
副檔名的話,就會找不到對應檔案。
其中一種解決方法是在路徑後加上副檔名,但這樣就需要改寫現有的 require。
require('example/foo.cjs');
// -> node_modules/example/foo.cjs
另一種方法則是升級到 Node.js 12 以上,從 12.7.0 開始支援 export map,從 12.16.0 開始不用加 --experimental-exports
。如果 package.json
裡有指定 exports
的話,Node.js 就會改用 export map 來決定路徑。
{
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
},
"./foo": {
"import": "./foo.mjs",
"require": "./foo.cjs"
}
}
}
require('example/foo');
// -> node_modules/example/foo.cjs
Jest
Jest 為了要實作 mock 機制,所以有自己一套 module resolve 和 import 的機制,在 import 外部 package 路徑的情況下,似乎不會使用 moduleFileExtensions
設定,而是使用 .js
副檔名,我用過的其中一種解決方法是設定 moduleNameMapper
,手動在 import 路徑後加上 .cjs
副檔名。
{
"moduleNameMapper": {
"^example/(.+)$": "example/$1.cjs"
}
}
Jest 的 ESM 支援還在實驗階段,如果需要執行 ESM 檔案的話需要加上 NODE_OPTIONS=--experimental-vm-modules
,目前建議還是使用 CommonJS,並使用 .js
副檔名。
Import
如果要同時 import CommonJS 和 ESM package 的話,唯一的方法就是使用 import
,舊有的 require
只支援 CommonJS,import
和 require
相比有很多不同的地方,細節可以參考官方文件,本文只會說明一些我覺得重要的部分。
檔案路徑一定要加副檔名
Import 檔案路徑時,一定要加上副檔名,import
不會根據 require.extensions
來判斷支援哪些副檔名。此外,也不能直接 import
資料夾,必須加上 /index.js
。
require('./path')
import './path.js';
require('./dir');
import './dir/index.js';
不支援 __filename
和 __dirname
__filename
和 __dirname
這兩個變數只有 CommonJS 才支援,在 ESM 裡必須改用標準的 import.meta.url
,兩者的內容會有一點點不一樣,需要透過 url
package 裡的 fileURLToPath
和 pathToFileURL
來轉換。
__filename
// /workspace/test.js
__dirname
// /workspace
import.meta.url
// file:///workspace/test.js
fileURLToPath(import.meta.url)
// /workspace/test.js
new URL('.', import.meta.url);
// file:///workspace/
Dynamic Import
在 CommonJS 裡,到處都可以直接 require
;但是在 ESM 裡,只有最外層可以用 import
,其他地方只能使用 async 的 import()
,有些地方可能會因此而必須改成 async function。
檢測現有環境是否支援 ESM
除了檢查 Node.js 版本以外,另一個檢測方法就是利用 import
支援 data:
protocol 的特性,來檢查現有環境是否支援 ESM,這是從 ava 參考來的。
const supportsESM = async () => {
try {
await import('data:text/javascript,');
return true;
} catch {}
return false;
};
需要注意的是,使用 TypeScript 時,如果設定為 CommonJS module 的話,import
會被轉為 require
,所以建議改為 ESNext module 或改用 JavaScript。
編譯 TypeScript
目前有幾種方法可以把 TypeScript 編譯成 CommonJS 和 ESM 檔案。
跑兩次 tsc
這應該是最簡單的方法,只要把原本的 tsc
指令切成兩個然後同時執行就好了。
tsc -m commonjs
tsc -m esnext
用 Babel 把 ESM 轉成 CommonJS
讓 tsc 輸出 ESM,然後再用 Babel 產生 CommonJS 檔案。
{
"plugins": ["@babel/plugin-transform-modules-commonjs"]
}
tsc-multi
以上這兩種方法需要額外寫 script,如果要支援 monorepo 的話就更加痛苦了,所以我花了一點時間把工作時用的 build tool 重新改寫成 tsc-multi,之後會在下一篇文章介紹,用法大概會像是這樣。
{
"targets": [
{ "extname": ".mjs", "module": "esnext" },
{ "extname": ".js", "module": "commonjs" }
],
"projects": ["packages/*/tsconfig.json"]
}