最近為了讓 Kosko 和 kubernetes-models 能夠支援瀏覽器或是 Deno,所以先做了一些前期準備,首先最重要的就是支援 ECMAScript Module (ESM),因為這是目前所有平台都能支援的標準,但是為了要保持 Node.js 的相容性,所以暫時還是不能放下 CommonJS。
這篇文章會介紹如何讓 Node.js package 能夠同時支援 CommonJS 和 ESM,以及使用 ESM 時的注意事項。
以最低支援版本來區分。
Node.js 10 以上:
.js
副檔名.mjs
副檔名package.json
加上 module
Node.js 12 以上:
.cjs
副檔名.mjs
副檔名package.json
加上 module
和 exports
,type
可設定為 module
正常來說,比較建議的做法是 CommonJS 一律採用 .cjs
副檔名,ESM 一律採用 .mjs
副檔名,這樣就能避免 Node.js 用 package.json
的 type
來判斷,但是在以下情況下會出問題。
如果你需要 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, .noderequire('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 為了要實作 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 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// /workspaceimport.meta.url// file:///workspace/test.jsfileURLToPath(import.meta.url)// /workspace/test.jsnew URL('.', import.meta.url);// file:///workspace/
在 CommonJS 裡,到處都可以直接 require
;但是在 ESM 裡,只有最外層可以用 import
,其他地方只能使用 async 的 import()
,有些地方可能會因此而必須改成 async function。
除了檢查 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 編譯成 CommonJS 和 ESM 檔案。
這應該是最簡單的方法,只要把原本的 tsc
指令切成兩個然後同時執行就好了。
tsc -m commonjstsc -m esnext
讓 tsc 輸出 ESM,然後再用 Babel 產生 CommonJS 檔案。
{ "plugins": ["@babel/plugin-transform-modules-commonjs"]}
以上這兩種方法需要額外寫 script,如果要支援 monorepo 的話就更加痛苦了,所以我花了一點時間把工作時用的 build tool 重新改寫成 tsc-multi,之後會在下一篇文章介紹,用法大概會像是這樣。
{ "targets": [ { "extname": ".mjs", "module": "esnext" }, { "extname": ".js", "module": "commonjs" } ], "projects": ["packages/*/tsconfig.json"]}