最近、色々なライブラリをCommonJS(CJS)からECMAScript Module(ESM)へとマイグレーションしています。 その際に、ESMでは__dirnamerequireなどCommonJS特有の機能は使えなくなっています。 また、TypeScriptやBabelなど多くのツールはCJSではimport時に拡張子はなくても大丈夫ですが、ESMの場合はimport時に拡張子が必要になります。

import url from "node:url";
- import { mdEscape } from "./mdEscape";
+ import { mdEscape } from "./mdEscape.js"; // ESMでは相対パスに拡張子は省略できない
+ const __filename = url.fileURLToPath(import.meta.url); // __filenameはESMにはないためimport.meta.urlから取得する
+ const __dirname = path.dirname(__filename); // __dirnameはESMにはない

console.log(__filename, __dirname)

具体的にAPIや構文などでCJSからESMへと移行する際に気を付けるNode.jsのAPIは次にまとめてあります。

ほとんど対応する構文や機能があるので機械的に置き換えたり、チェックできるものばかりです。 そのため、CJSとESMへと機械的に移行するのを補助するツールを書きました

eslint-cjs-to-esm

eslint-cjs-to-esmは、名前の通りESLintを使ってCJSをESMへと移行するためのツールです。 ESLint自体を同梱しているため、別途ESLintの設定をせずにルール入りのESLintとして利用できます。

含まれているESLintのルールとしては次のgistのものをベースにしています。

具体的には次のようなESLintのルールを同梱しています。

eslint-plugin-file-extension-in-import-ts

eslint-plugin-node

ESLint Plugin Rule Source Description Fixable
node no-extraneous-import :link: disallow import declarations which import extraneous modules -
node no-sync :link: disallow synchronous methods -
node file-extension-in-import :link: enforce the style of file extensions in import declarations Yes

eslint-plugin-import

ESLint Plugin Rule Source Description Fixable
import extensions :link: Ensure consistent use of file extension within the import path. -
import no-unresolved :link: Ensure imports point to a file/module that can be resolved. -
import no-useless-path-segments :link: Prevent unnecessary path segments in import and require statements. Yes
import no-extraneous-dependencies :link: Forbid the use of extraneous packages. -
import no-commonjs :link: Report CommonJS require calls and module.exports or exports.*. -

eslint-plugin-unicorn

ESLint Plugin Rule Source Description Fixable
unicorn prefer-module :link: Prefer JavaScript modules (ESM) over CommonJS. Yes
unicorn prefer-node-protocol :link: Prefer using the node: protocol when importing Node.js builtin modules. Yes
unicorn prefer-top-level-await :link: Prefer top-level await over top-level promises and async function calls. Suggest

使い方

使い方は単純で、次のようにESLintと同じ引数を渡して対象のファイルをLintと修正できます。

npx eslint-cjs-to-esm [ESLint Arguments!]

ESLintのオプションは次のページを参照してください。

具体的に./src/*以下のJSとTSがESMとして問題ないかは次のようにチェックできます。

npx eslint-cjs-to-esm "./src/**/*.{js,ts}"

自動的に修正できるものは--fixオプションを渡すと修正できます。

npx eslint-cjs-to-esm "./src/**/*.{js,ts}" --fix

Note: ちょっとした制限があって、ファイルパスは.から始める必要があります。

NG: npx eslint-cjs-to-esm "src/**/*.ts"
OK: npx eslint-cjs-to-esm "./src/**/*.ts"

具体的にこのツールのコマンド叩くだけでコードの9割ぐらいはESMへと移行できました。 面倒な.jsを追加する作業もほとんどやってくれます。

さらにCJSとESMを一つのパッケージ内でサポートするDual packagesにするtsconfig-to-dual-packageと次のようなスクリプトと使うとほぼ機械的にできます。

Dual Packageについてはtsconfig-to-dual-packageを紹介する記事を別途書きます。

まとめ

ESLintとルールをラップしてCJSからESMへと移行するeslint-cjs-to-esmというツールを作りました。

実装も eslint コマンドをラップして叩いているだけではあるので単純です(ちょっと設定の渡し方やFORCE_COLORなどの色付けは工夫が必要)。

CJSからESMへとマイグレーションするツールは色々あるのですが、codemod的なツールではなくTranspilerだったり、なんかうまく動かなかったりして、求めてたものが見つかりませんでした。

その中で、ESLint rules for migrating projects from CommonJS to ESMを見て、ESLintとルールを使えば大体は実現できそうと思ってESLintのラッパーを実装しました。 ESLintをラップしたのは、自分のライブラリプロジェクトではESLintを使ってないことが多く、また単なるマイグレーションツールが欲しかったからです。 一度移行すれば、TypeScriptのmodule: ESNextの設定だとCJSのコードを使うとコンパイルエラーになるため、継続的にLintする必要性が薄いです。

そのため、codemod的なマイグレーションツールをイメージしてeslint-cjs-to-esmを作りました。