隨著今年沒有完整支援 ESModule 的 Node 10 達到 EOL(End-of-life),推行 ESModule 的運動再次興起。其中 JavaScript 領域的知名開發者 Sindre Sorhus 更是吹起了號角,發了許多文章強烈表示他轉向 ESM 的決心:
- https://github.com/sindresorhus/meta/discussions/15
- https://blog.sindresorhus.com/get-ready-for-esm-aa53530b3f77
- https://blog.sindresorhus.com/hello-modules-d1010b4e777b
我完全能懂那個心情,畢竟我們這個時代的開發者,看著沒有 Module System 的 JavaScript,一路在 CommonJS、AMD、UMD 等等的系統轉來轉去。在 ES2015(ES6)導入 Module 的 Spec 以後,經過了超過五年持續使用 Babel 的日子以及漫長的等待,終於迎來可以在 Node.js 啟用 ESM 的一個起跑點。
Sindre Sorhus 目前已經把不少套件的新版本(latest)使用不支援 CommonJS 的方式推上了 npm,這個舉動雖然稍微激進,但我覺得以 Node.js ESM 目前的狀況來說這是不可或缺的,不然可能 5 年後我們還是沒辦法看到普遍使用 ESM 的未來。
這篇打算激起大家對 ESM 的關注,並跟大家分享一下轉移到 ESM 的現狀與需要注意的問題。
以前 Babel 針對 ESM 語法的處理
由於 ES2015 出現了 ESModule 相關的語法,Babel 作為一個目標為搶先讓大家撰寫還不能支援的語法的 Transpiler,當然也去支援了這個語法,例如下面這段 ESM 的 default export:
export default {}
Babel 會把它編譯成:
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _default = {};
exports.default = _default;
因爲兩個 Module System 本來就有些不同之處,Babel 用 __esModule
來標記它,並把 default export 放到 exports.default
去,這個機制稱為 esModuleInterop
,你也可以在 TypeScript 找到這個設定。
至於 import 的部分:
import React from 'react';
Babel 會把它編譯成:
var _react = _interopRequireDefault(require("react"));function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
來處理這個標記的 __esModule
。
當初的這個做法,雖然讓這個語法可行,但是卻產生了一些跟 ESM Spec 不同的行為,讓你可能會出現 Babel 可以但是原生的 ESM 不可以的一些副作用。
CommonJS 與 ESM 的相容性
CommonJS 使用 CommonJS 又或是 ESM 使用 ESM 的狀況當然是沒有問題,但在這個未來一段時間的過渡期,兩者混用的狀況肯定不會少見。
首先,來看看 ESM 要怎麼使用 CommonJS Module,非 JSON、非 Native Module 直接 import 應該是可以的:
import cjsModule from './cjs-module';
會幫你做簡單的轉換,不過官網的說法 「Named exports may be available」聽起來有點恐怖
When importing CommonJS modules, the
module.exports
object is provided as the default export. Named exports may be available, provided by static analysis as a convenience for better ecosystem compatibility.
另一種做法是,用 createRequire
來建立我們以前所熟悉的 require
function:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const cjsModule = require('./cjs-module');
CommonJS Module 要怎麼使用 ESM,則是需要靠現在已經頗為人知的 Dynamic Import:
await import('./esm-module');
這個動作會導致 synchronous 的 function 被迫需要轉成 asynchronous,進而影響到所有 synchronous 的部分。
CommonJS 轉移成 ESM 需要注意的點
.mjs 以及 .cjs 檔名
在判斷模組類型時,會參照檔名以及 package.json
上的 type
欄位來做判斷:
- .mjs
-> ESModule
- .cjs
-> CommonJS Module
- 在 package.json
設定 "type": "module"
,載入 .js
-> ESModule
- 在 package.json
設定 "type": "commonjs"
或沒設定,載入 .js
-> CommonJS Module
無法在 ESM 使用 filename 跟 dirname
__filename
跟 __dirname
都是 CommonJS 的產物,不能在 ESM 裡面使用,需要改用 import.meta.url
。
無法用 import 載入 JSON Module
JSON import 目前還在實驗階段,需要用--experimental-json-modules
flag 來啟用,因此還是建議先用 readFile
的方式來做:
import { readFile } from 'fs/promises';
const json = JSON.parse(await readFile(new URL('./dat.json', import.meta.url)));
或是使用 createRequire
也可以:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const json = require('./dat.json');
無法用 import 載入 Native Module
以往用 C/C++ 寫的或是最近相當熱門的用 Rust 寫的 Node Addons 也就是 *.node
檔,無法直接在 ESM 用 import 載入,這部分也可以用 createRequire
解決或是使用 process.dlopen
。
CommonJS fallback
雖然 Sindre Sorhus 認為在所有 Active Node 版本都支援 ESM 的這當下,沒有理由提供 CommonJS 的 Fallback,不過為了減少使用上的摩擦,我覺得 Package Author 還是可以利用 package.json
的 exports
欄位來增加轉換的彈性:
{
"type": "module",
"main": "./index.cjs",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
這樣就可以同時支援 CommonJS 跟 ESM 了,雖然會讓 Package Author 要同時準備兩種麻煩許多。
生態系的狀況
套件
在 Sindre Sorhus 打算在今年(2021)把他的 1000+ 個套件轉移到 ESM 的帶動下,近期安裝 latest 時已經越來越常直接安裝到 ESM 的版本,包括一些比較多下載量的套件 node-fetch
等等,都已經在新的 Major 放上 ESM-only 的版本,有鑒於要同時維護新舊版本對維護者來說是非常難以承受的負擔,CommonJS 的版本很可能停止修復 bug,因此可以想見未來一段時間內大部分的專案都必須要遷移到 ESM 或是在 CommonJS 中使用 Dynamic Import 來使用 ESM-only 的套件。
Webpack
最為最廣泛使用的 Bundler, Webpack 也已經做好 ESM 的支援,不管在 input 還是 output 使用 ESM 都不成問題。
Next.js
Next.js 本身是使用 Webpack 在做 Bundle,也已經吸收了 Webpack 的作者加入團隊,因此也是在 Next 11 跟 12 的版本大幅加強了對使用 ESM 套件的支援,這部分看起來也不成問題。Next.js 也在支援 ESM 套件的情況下,繼續的對目前 CommonJS 的用法提供良好的支援。
TypeScript
最新的 TypeScript 4.5 Beta 看起來大幅度的加強了 ESM 的支援。可以把 module
設定為 node12
或是 nodenext
:
{
"compilerOptions": {
"module": "nodenext",
}
}
對照 .mjs
跟 .cjs
,TypeScript 也新增了對應的 .mts
跟 .cts
以及型別定義檔 .d.mts
跟.d.cts
。
也參加了對 package.json
的 exports
欄位裡面的 types
設定的支援:
{
"type": "module",
"exports": {
"import": "./esm/index.js",
"require": "./commonjs/index.cjs",
"types": "./types/index.d.ts"
}
}
> 更新:TypeScript 的 ESM Support 有些問題趕不及在 4.5 修復,所以將會推遲:https://github.com/microsoft/TypeScript/issues/46452
Testing
測試這部分是我看到非常迫切但又是問題重重的地方,Mocha 也好、Jest 也好都有不少尚未解決的問題。
以目前 JavaScript 最廣為使用的 Jest 來舉例好了,因為需要 Node 裡面的 vm.Module 所以就必須要啟用實驗階段的--experimental-vm-modules
flag。
再來就是 mock 的部分,因為 ES Module 的設計 Jest 的 mock API 已經無法靠 Babel 抬升到 import 的上面了(以前 require 可以),所以必須先 mock 後再 Dynamic Import 順序才會對:
import { jest } from '@jest/globals';
jest.unstable_mockModule('someModule', async () => ({
foo: 'bar',
}));
const someModule = await import('someModule');
test('some test', () => {
expect(someModule.foo).toBe('bar');
});
再來,Jest 有相當多的設定 Plugin 都必須要能支援 ESM。
除了這些以外,最大的問題還是來自於 Node VM Module 的一些嚴重 Issue 未解,這部分問題又有不少來自 V8 Engine,能完全解決我覺得還要花上不少時間。
Module Monkeypatching
這個是另一個我有注意到在 ESM 被 Block 相當久的一種用法,一些埋追蹤的工具,例如 Datadog,以往是透過在 require
function 上做一層 wrapper,來實現對 Module 的注入與 Patch。
改用 import
之後,對這個用法的影響不只是一點點而已。
目前他們嘗試的做法跟我之前想像的類似,只能依靠草創初期要使用--experimental-loader ./loader.mjs
的 flag 而且 API 隨時有可能會被改掉的 ESM Loader,這邊依然是有很多的變數。