在 2021 可以使用 ESModule 了嗎?

C.T. Lin
10 min readNov 2, 2021

--

隨著今年沒有完整支援 ESModule 的 Node 10 達到 EOL(End-of-life),推行 ESModule 的運動再次興起。其中 JavaScript 領域的知名開發者 Sindre Sorhus 更是吹起了號角,發了許多文章強烈表示他轉向 ESM 的決心:

我完全能懂那個心情,畢竟我們這個時代的開發者,看著沒有 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.jsonexports 欄位來增加轉換的彈性:

{
"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 1112 的版本大幅加強了對使用 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.jsonexports 欄位裡面的 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,這邊依然是有很多的變數。

--

--

C.T. Lin

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