1
/
5

実践 Node.js Native ESM — Wantedlyでのアプリケーション移行事例

Wantedlyではこのたび、フロントエンドアプリケーションのひとつをNative ESM化しました。本記事ではNative ESM化の必要性と、必要な作業について説明します。

この記事の概要

  • Node.jsにはNative ESMというモードがある。
  • Native ESMはまだ普及していないが、ライブラリ側の更新が進み、移行が必要になりつつある。
  • Native ESMをめぐる状況は (この記事の長さからわかるように) 色々複雑で、概念をちゃんと説明するだけでも大変。
  • Native ESMへの移行にあたってはさまざまな困難が待ち受けている。

Native ESMとは

歴史的経緯から、JavaScriptには複数のモジュールシステムがあります。そのうちNode.js周辺でよく使われるのはCommonJS ModulesES Modulesです。

  • CommonJS Modules (CJS) は実質的にNode.jsのために誕生したモジュールシステムで、 require関数 / module.exportsオブジェクト を用いてエクスポートを行います。
  • ES Modules (ESM) はJavaScriptの標準ES2015で新たに (といっても7年前ですが) 定義されたモジュールシステムで、CommonJS ModulesやAMDなど既存のモジュールシステムよりも後発です。

ES Modulesは後発だけあって優れた特徴をいくつか持っています。そのため、Node.js周辺でもES Modulesを使おうとする試みが行われてきました。これは大きくFake ESMとNative ESMの2種類に大別されます。

  • Fake ESM方式では、プログラマーはES Modulesの構文を用いてモジュールを記述しますが、BabelやTypeScriptなどを用いてCommonJSに変換してから実行します。
  • Native ESM方式では、Node.js自身がES Modulesを直接解釈します。Node.js 8.5.0以降 (正式サポートは12.17.0以降) で対応しています。

これはプログラムをNode.js上で実行する上での区別ですが、モジュールバンドラーもNode.jsの挙動を模倣するために同様の区別を導入している場合があります。たとえばWebpackサポートのためにpackage.jsonに "main" フィールドのほかに "module" フィールドや "browser" フィールドを設定するのは広く行われてきましたが、これらは通常Fake ESM相当の挙動になります。

標準化は7年前、Node.js側の正式サポートは2年も前であるにもかかわらず、いまでも多くのアプリケーションはNative ESM方式を使わず、Fake ESM方式で動いているようです。これにはあとに述べるようにNative ESM対応にさまざまな困難があるからだと考えられます。

自分たちのコードがNative ESMかどうかを知る

以下のどちらかに該当すればおそらくNative ESMです。

  • package.jsonに "type": "module" の記述があり、拡張子に .js を使っている。
  • 拡張子に .mjs を使っている。

また、TypeScriptの場合は通常 .ts/.tsx は .js に、 .mts は .mjs に対応します。しかし、このような追加の拡張子の挙動は、Node.jsの拡張フレームワーク側の設定 (たとえばJestであれば extensionsToTreatAsEsm) に依存するため、そちらの設定を確認する必要があります。

なぜNative ESMに対応する必要があるのか

なぜNative ESMに対応する必要があるのか。それは、Native ESMからCJS (Fake ESMを含む) のインポートは一方向的で、逆向きのインポートはできないからです。これはESMとCJSの実行モデルの違いに起因します。

  • CommonJSのモジュールは同期的に (JavaScriptの実行スレッドを一度もyieldせず、ブロックしたまま) 読み込まれ、同期的に実行されます。
    • 呼び出す側にとっては、 require() が同期的であるというメリットとしてあらわれます。
    • 呼び出される側にとっては、トップレベルで非同期処理の完了を待てない (top-level awaitが使えない) というデメリットとしてあらわれます。
  • ESMのモジュールは非同期的に (I/O待ちに入ったタイミングで別のJavaScriptの処理を許可しつつ) 読み込まれ、非同期的に実行されます。
    • 呼び出す側にとっては、 import() が非同期的であるというデメリットとしてあらわれます。
    • 呼び出される側にとっては、トップレベルで非同期処理の完了を待てる (top-level awaitが使える) というメリットとしてあらわれます。

非同期的処理の中で同期的処理の完了を待つことはできますが、同期的処理の中で非同期的処理の完了を待つことはできません。CJSからNative ESMのインポートはまさにこの本質的な問題に直面しているわけです。

こういった理由から、ライブラリがNative ESM化すると、利用側アプリケーションもNative ESM化することが要求されます。

Native ESM化したパッケージ

2021年初頭に多数の有名npmパッケージを保守しているSindre Sorhus氏がNative ESM化を宣言したことで、様々なライブラリでNative ESM化の動きが出てきています。

Wantedlyでわかっている範囲内では以下のようなライブラリで、最新バージョンではNative ESM版だけが提供されています。

  • d3
  • node-fetch
  • parse5

したがって、これらのライブラリの最新バージョンを享受し続けるには、アプリケーションをNative ESM化する必要があります。

CJSからNative ESMのインポートができる例外的条件

「CJSからNative ESMのインポートは本質的に難しい」と書きましたが、これには例外が3つあります。

ひとつは、インポートの完了を待つ必要がない場合です。この場合はCJSモジュール内でもdynamic importを使うことができます。

// foo.cjs
import("./bar.mjs")
  .then((mod) => {
    // bar.mjsの内容を使った処理
  });

// bar.mjsのロード完了を待たずにfoo.cjsの実行は終了してしまう
// そのため、barの内容をreexportするといった使い方は基本的にできない

もうひとつは、Workerを立ち上げて、そこに処理を移譲できる場合です。これは @babel/eslint-parser など、どうしても同期的な関数としてインターフェースを提供しなければいけない場面で使われています。この方法ではオブジェクトは共有できないので、引数と戻り値がシリアライズ可能な場合のみ使えます。

最後に、依存先のライブラリを強制的にCommonJSにトランスパイルして使うことでも同様のことが実現できます。これが実現できるかどうかは、各環境 (Webpack, Jest, Node.jsなど) でモジュールローダーの挙動をどれくらいカスタマイズできるかに依存します。また、依存先のライブラリがTop-level awaitを使いはじめたら、これは実現が難しくなります。Wantedlyではこの方針について詳細に評価できるほど調査できていませんが、おそらくNative ESMの普及率がまだ高くない現時点ではこちらの対応方法も十分に考えられると思います。

Dual Packageとは

パッケージによってはCJSとESMの両方のバージョンを提供している場合があります (Dual Packages)。Native ESM と Fake ESM の違いにより、Dual Packagesも2種類あるので注意が必要です。

Dualではないものを含めると、npmパッケージは大きく4種類に分類できます。

  • CJS Package ... CJS形式でのみ配布。
  • CJS / Fake ESM Package ... Node.jsではCJS形式で動作し、バンドラー (Webpack等) ではFake ESM形式で動作する。フロントエンド向けパッケージではまだこの形式が優勢。
  • CJS / Native ESM Package ... 呼び出し元のモジュール種別に応じてCJSまたはNative ESM形式で動作する。
    • CJS または Fake ESM から呼び出されたときはCJS形式で動作する。
    • Native ESM から呼び出されたときはNative ESM形式で動作する。
  • Native ESM Package ... Native ESM形式のみ。CJSからは使えない。

CJS Packageのpackage.jsonは以下のような形をしています。

{
  "main": "./index.js"
}

CJS / Fake ESM Packageのpackage.jsonは以下のような形をしています。

{
  "main": "./index.js",
  "module": "./esm/index.js"
}

CJS / Native ESM Packageのpackage.jsonは以下のような形をしています。

{
  "main": "./index.js",
  "exports": {
    "require": "./index.js",
    "import": "./esm/index.mjs"
  }
}

または、 .js をESMに割り当てている場合は以下のようになります。

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "require": "./index.cjs",
    "import": "./esm/index.js"
  }
}

Native ESM Packageのpackage.jsonは以下のような形をしています。

{
  "type": "module",
  "exports": "./index.js"
}

パッケージ形式の今後について

2022年時点で我々がESM対応だと思っているパッケージの多くは CJS / Fake ESM dual package であり、Node.js上ではCJSで動作します。

このように書くと、「次のステップとして、少しずつCJS / Native ESM dual packageが増えていくのだろう」と感じる人がいるかもしれませんが、話はそう単純ではありません。

まず、JavaScriptのOSSを広汎に支えているSindre Sorhus氏がNative ESMへの移行宣言を行った際のブログ記事で、dual packageのステップを飛ばしてNative ESMのみのパッケージをいきなり提供することを提案しており、実際にそれに従って最新メジャバージョンでNative ESMのみの提供になったパッケージが存在します。 (筆者が把握している例では d3, node-fetch が該当)

そしてその背景のひとつに、CJS / Native ESM dual package が抱えるいくつかの困難があります。

  • CJS と Native ESM を併用するには、必ず拡張子を分ける必要があります。しかも、Native ESMではimport文内で拡張子を省略できない (Node.jsでの挙動) ため、CJS版とNative ESM版ではimport文内のパスも書き分ける必要があります。そのためにはトランスパイラに専用のプラグインを噛ませるなどの対応が必要となり、設定を必要以上に複雑化してしまいます。
  • CJS版とNative ESM版の両方をテストするにも、設定の複雑化は必至です。
  • Dual package hazard問題。CJS / Native ESM dual package では、パッケージがどの環境で呼び出されたかではなくどのように呼び出されたかによって出し分けが行われます。そのため、同じ環境下でCJS版とNative ESM版が両方ロードされる可能性があります。これはモジュール内に状態を持つオブジェクトがあったり、オブジェクトの同一性に依存する処理があった場合にバグの温床になります。
    • さらに、ESMとCJSの間の呼び出しは実質的に一方向的であるため、もし状態を共有しようとするとNative ESM版をCJS版に対するプロキシモジュールにするしかありません。しかしそのような構成は結局処理系のCJSサポートに依存しており、CJS (Fake ESM) のみの構成に比べて利点が十分ではありません。

こうした理由もあり、Dual packageが十分に普及していない状態でのNative ESM対応が必要になっていくと考えられます。そしてこの「Dual packageが十分に普及していない状態」というのが、以降で説明する問題 (Native ESMからCJSを呼び出すときの非互換性) を引き起こします。

Native ESM移行にともなう困難

ライブラリに先行してアプリケーションをNative ESM化しなければいけないものの、不幸なことにその移行には困難が待ち受けています。ここではアプリケーションのNative ESM化に絞って話をします。

拡張子問題

Node.js自身はNative ESMモードでは拡張子を補完しません。つまり、 ./util ではなく ./util.js のように拡張子のついた名前でimportを行う必要があります。

Webpackもこの挙動を模倣しますが、fullySpecifiedをオフにすることで無効化できます。Jestはこの点に関してNode.jsの挙動を模倣しておらず、デフォルトで拡張子を補完するため問題ありません。

もし、 ./util.js のように拡張子を明示しようとした場合、今度は別の問題が発生します。TypeScriptなどで別の拡張子を使っている場合、拡張子の読み替えをする必要があるためです。これについてはNative ESM + TypeScript 拡張子問題: 歯にものが挟まったようなスッキリしない書き流しを参照してください。

WantedlyではNode.jsで直接実行するコードはなく、Webpack経由またはJest上で実行されるものだけだったため、拡張子を明示せずにfullySpecifiedをオフにすることで対応しています。

名前つきインポートの検出失敗

ESMは静的解析により実行前に全てのエクスポート名がわかる (ただし、 export * だけは依存先の静的解析も行う必要がある) という特徴があります。

Node.jsではESMからCJSをインポートする際、被インポート側モジュールをラップしたESMモジュールを内部的に生成しています。このラッパーモジュールもまたモジュールを実行せずに生成する必要があるため、cjs-module-lexerというライブラリを使って静的解析によってエクスポート名を検出しています。

このcjs-module-lexerは典型的なCJSの記述パターンやトランスパイラの出力をカバーしていますが、それでも検出できない事例がいくつか存在します。Wantedlyでは以下のライブラリで対応が必要でした。

  • immutable.js, draft-js, jest-immutable-matchers
  • @reduxjs/toolkit
  • @react-aria/overlays
  • pdfjs-dist
  • react-dom/server
  • react-share

デフォルトインポートの非互換性

CommonJSではmodule.exports変数に値を入れてエクスポートしますが、これは単一値のエクスポートとしての役割と名前空間としての役割を兼ねています。このモデルはES Modulesのエクスポートモデル (全てのエクスポートは名前つきエクスポートである) と異なるため、その差異を吸収するためにmodule.exportsをdefaultにマップすることになっています。

Fake ESMではこれだと困るため、 __esModule が定義されていれば module.exports.defaultがdefaultにマップされているとみなすようになっています。しかし、Node.jsのESM→CJSインポートでは同じ仕組みは実装されていないため、CJSモジュールに対してdefault importを行うと意図しない結果になってしまいます。詳しくはNode.jsのネイティブES Modulesサポートが抱える問題を解決するBabelプラグインを書いたを参照してください。

Wantedlyでは以下のライブラリで対応が必要でした

  • babel-plugin-react-css-modules
  • qhistory
  • rc-tooltip
  • redux-mock-store
  • redux-thunk
  • styled-components
  • typescript-fsa
  • react-dropzone

名前空間インポートの正常化

名前空間インポートは本来、「モジュール名前空間オブジェクト」と呼ばれる専用のオブジェクトが返される仕様になっています。そのため、以下のようなコードは本来であれば動きません。

import * as moment from "moment";
// モジュール名前空間オブジェクトは本来であれば関数呼び出しできない
console.log(moment());

しかし、仕様を厳密にしない処理系ではうまく動いてしまうことがあります。たとえばWebpackでは _interopRequireWildcard 相当の処理を挟まずに、コードを以下のように翻訳してしまいます。

const moment = require("moment");
console.log(moment());

このような理由から、CommonJS互換モードではたまたま動いていたものが、Native ESM互換環境では動かなくなってしまうことがあるので、上のように名前空間インポートの使い方がおかしいコードが含まれている場合は注意が必要です。

JestやStoryshotの非互換性

JestやStoryshotなど、Node.jsのモジュールシステムに手を加えていたり、密結合していたりする種類のライブラリでは、ESM対応が簡単ではないことがあります。

JestはJest 27, 28でNative ESMサポートが充実してきているので、慎重に移行すれば対応できます。

Storyshotは内部でjest.requireActualを使うなど、Native ESMに対応していない様子だったので、Storyshotを使わないように書き換えました。この作業については以下の記事で説明しています。

Storyshotsを止めてスナップショットテストの仕組みを自作した話 | Wantedly Engineer Blog
Storybook はWebフロントエンド向けのUIモックアップのフレームワークです。 あらかじめUIコンポーネントをデータストア非依存で作っておけば、そのモックアップを作ることができます。(モックアップといっても、バックエンドに繋がっていないだけで、ユーザーの操作には反応するというものを考えています。) Storybookでは、このようなモックアップのことをStoryと呼んでいます。Storyがあらわすコンポーネントの粒度もまちまちで、汎用のボタン1つに対するStoryもあれば、ページ全体に対するSto
https://www.wantedly.com/companies/wantedly/post_articles/421107

ライブラリが正しく記述されていない

ライブラリ側がNative ESM対応を正しく実装できていないケースもあります。特にpkg exportsを書くにはNode.jsのESMについて深い理解が必要です。ありがちなのが以下のようなミスです:

  • CJSとNative ESMの両モジュールを同じ拡張子 (.js) で提供してしまっている。
  • Native ESM非対応 (Fake ESM) であるにもかかわらず import/require 条件で分岐を書いている。
    • Fake ESMの場合はnode条件やmodule条件を使うのが望ましい。

Wantedlyではframer-motionの古いバージョンがこのような問題を抱えていることが発見されました。これは修正済みで、新しいバージョンに更新することで問題は解消されました。

トレーシング系ライブラリの対応状況

OpenTelemetryやNew Relicなどのトレーシング系のライブラリはrequireされるモジュールを差し替えることで計測を行うことがあり、ライブラリ側がNative ESMで読み込まれた場合は以前のように情報を取得できない可能性があります。詳しくはOpenTelemetryのissueを見るとよいでしょう。

ただ、アプリケーションがNative ESM化されていてもライブラリ側がNative ESM化していなければおそらく問題ないはずです。

Native ESM対応方針

今回移行したのは "wantedly/wantedly" と呼ばれるWantedlyのモノリスに付属するフロントエンドです。

wantedly/wantedly 内のソースコードは以下のようにコンパイルされ実行されています。

今回は設定ファイル以外の全てのCommonJS Modulesの利用を一度に外す方針で移行しました。つまり、

  • JestはテストコードとアプリケーションコードをNative ESMとして実行する。
  • Webpackは入力をNative ESM準拠として扱う。
  • WebpackのSSR向け出力はNative ESM形式で行い、Node.jsで実行するときもNative ESMとして実行する。

となります。

Native ESM対応のためにやったこと

以降はWantedlyでNative ESMへの移行のためにやったことを1つずつ説明していきます。

Native ESMを有効化

まずはNative ESMを有効化します。今回は拡張子を変えずにパッケージ全体でNative ESMを有効化するため、package.jsonを以下のようにします。

{
  // Node.jsでNative ESMを有効化
  "type": "module",
  ...
  "jest": {
    // JestでNative ESM化する拡張子を指定
    "extensionsToTreatAsEsm": ["jsx", "ts", "tsx", "mts"],
    ...
  }
}

もしbabel-nodeやts-nodeなど、他のローダーを使っている場合は、別途設定が必要です。 (babel-nodeは現時点ではNative ESM非対応です)

Webpackも既知の拡張子 (.js, .cjs, .mjs) 以外は挙動が異なるため、挙動を揃えておきます。以下のようなヘルパを作っておきます。

// webpack-esm-ext.cjs

// Extends rules for ESM
// See https://github.com/webpack/webpack/blob/v5.73.0/lib/config/defaults.js#L546-L567
/**
 * @returns {import("webpack").RuleSetRule[]}
 */
exports.esmRulesExt = function esmRulesExt() {
  const esm = {
    type: "javascript/esm",
    resolve: {
      byDependency: {
        esm: {
          fullySpecified: true,
        },
      },
    },
  };
  const commonjs = {
    type: "javascript/dynamic",
  };
  return [
    {
      test: /\.mts$/i,
      ...esm,
    },
    {
      test: /\.(jsx|ts|tsx)$/i,
      descriptionData: {
        type: "module",
      },
      ...esm,
    },
    {
      test: /\.cts$/i,
      ...commonjs,
    },
    {
      test: /\.(jsx|ts|tsx)$/i,
      descriptionData: {
        type: "commonjs",
      },
      ...commonjs,
    },
  ];
};

以下のように利用します。

// webpack.config.cjs

const { esmRulesExt } = require("./util/webpack-esm-ext.cjs");

module.exports = {
  module: {
    rules: [
      ...esmRulesExt(),
      // ...
    ],
  },
};

有効化すると色々なエラーが出てくるので、ひとつずつ対処していきます。

設定ファイルの.cjs化

現在はBabelやESLintを含め、多くのツールがESM形式の設定ファイルもサポートしています。ただ、今回はアプリケーションコードのESM化が目標のため、設定ファイルはCJSに戻して対応しました。

  • webpack.config.js → webpack.config.cjs
  • .babelrc.js → .babelrc.cjs
  • jest.config.js → jest.config.cjs
  • .storybook/main.js → .storybook/main.cjs

ただし一口に設定ファイルといっても、JestのセットアップファイルやStorybookの .storybook/preview.js などはアプリケーションと同じ環境で読まれるため、アプリケーションと一緒にESM化します。

モジュールトランスパイルの停止

Babelの設定でCommonJSへの変換を止めます。

// .babelrc.cjs
module.exports = {
  presets: [
   [
     "@babel/preset-env",
     {
       // だいたいこうなっているはず
       modules: process.env.NODE_ENV === "test" ? "commonjs" : false
       // こう直す
       modules: false
     }
   ],
  ],
};

なお、もしトランスパイルをTypeScript (tsc) で行っている場合はtsconfigの設定を変えます。

// tsconfig.test.json
{
  "compilerOptions": {
    // これを、
    "module": "commonjs",
    // こうする (選択肢→ https://www.typescriptlang.org/tsconfig#module )
    "module": "esnext",
  }
}

Webpackの拡張子ルールを緩める

Webpack 5はNative ESM設定を検知して、Node.jsのNative ESMと同等の厳しい拡張子ルールを有効化します。今回はプログラムをNode.jsで直接実行する要件はないため、この拡張子ルールを無効化する方向で対応します。

// webpack.config.cjs
module.exports = {
  module: {
    rules: [
      // fullySpecifiedを無効化するルールを追加
      {
        test: /\.[jt]sx?/,
        resolve: {
          fullySpecified: false,
        },
      },
      ...
    ],
    ...
  },
  ...
};

インポートの問題を直す

node-cjs-interopで直す

Fake ESMベースのパッケージの多くは筆者が作ったbabel-plugin-node-cjs-interopで対応できます。

// .babelrc.cjs
module.exports = {
  plugins: [
    process.env.NODE_ENV === "test" && [
      "babel-plugin-node-cjs-interop",
      {
        packages: [
          // default exportの非互換性問題を抱えるパッケージ
          "babel-plugin-react-css-modules",
          "qhistory",
          "rc-tooltip",
          "redux-mock-store",
          "redux-thunk",
          "styled-components",

          // default exportの非互換性問題とは別だが、cjs-module-lexerがうまく動かないパッケージ
          "react-share",
        ],
        // いくつかのパッケージはこれがないとうまく動かない
        loose: true,
      },
    ],
  ].filter(Boolean),
};

この方法のよいところは、元のコードを損なわないことです。特にstyled-componentsは別途babel-plugin-styled-componentsによる変換を噛ませる必要があるため、styled-componentsからのimport文に手を加えたくないという事情がありました。

default import化する

手書きのCommonJSモジュール等のなかには、名前つきインポートがうまく検出できないものがあります。これらは、 __esModule がない場合は、常にdefault importを使うことで挙動を安定させることができます。

import { List } from "immutable";
console.log(List());

// ↓

import immutable from "immutable";
console.log(immutable.List());

不適切なnamespace importを直す

namespace importされたものは本来関数呼び出しできないはずですが、Webpackなどでは規格に反して呼び出せてしまうことがあります。このような使い方をしている例があればあらかじめ直しておきます。

import * as moment from "moment";
console.log(moment());

// ↓

import moment from "moment";
console.log(moment());

なお、TypeScriptを使っている場合、この問題は esModuleInterop オプションを設定することで検出できます。

// tsconfig.json
{
  "compilerOptions": {
    "esModuleInterop": true
  }
}


特別対応

上のような方法で一貫性のある方法で対応できない場合は、環境差異を吸収するための特別対応をする必要がある場合があります。Wantedlyの場合は @react-aria/overlays でこの対応が必要でした。

// @react-aria/overlays 対応用のshim module

import * as overlays_ from "@react-aria/overlays";

function interop<T>(mod_: T): T {
  const mod = mod_ as T & { default?: T };
  return { ...mod, ...mod.default };
}

const overlays = interop(overlays_);

// eslint-disable-next-line no-restricted-imports
export type {
  AriaPositionProps,
  DismissButtonProps,
  ModalAria,
  ModalAriaProps,
  ModalOptions,
  ModalProviderAria,
  ModalProviderProps,
  OverlayAria,
  OverlayContainerProps,
  OverlayProps,
  OverlayTriggerAria,
  OverlayTriggerProps,
  PositionAria,
  PreventScrollOptions,
} from "@react-aria/overlays";

export const DismissButton = overlays.DismissButton;
export const ModalProvider = overlays.ModalProvider;
export const OverlayContainer = overlays.OverlayContainer;
export const OverlayProvider = overlays.OverlayProvider;
export const ariaHideOutside = overlays.ariaHideOutside;
export const useModal = overlays.useModal;
export const useModalProvider = overlays.useModalProvider;
export const useOverlay = overlays.useOverlay;
export const useOverlayPosition = overlays.useOverlayPosition;
export const useOverlayTrigger = overlays.useOverlayTrigger;
export const usePreventScroll = overlays.usePreventScroll;

exportsがおかしい場合

package.jsonのexportsを使うとCJS/ESMのDual Packageを実現することができますが、このexportsは色々と間違いやすいポイントがあります。たとえば、

{
  "exports": {
    "require": "./dist/index.js",
    "import": "./dist/esm/index.js",
  }
}

という記述は間違いです。 (require/import分岐を書くときは拡張子を分ける必要がある)

Wantedlyでは実際にframer-motionというパッケージでこのような問題が起きていましたが、framer-motionを最新版にすることで問題が解決しました。

もしライブラリの間違いをただちに訂正できない場合、プロジェクト内に拡張子 .cjs を持つダミーモジュールを作って間接的にライブラリをインポートするなどの対応が考えられます。

Webpack側の非互換性対応

WebpackではもともとESMの自動検出がありましたが、Webpack 5ではNode.jsの挙動に近づけるために以下のように自動検出と手動検出の区別が導入されました。 (ただし、typeの区別自体は元々あったようです)

  • Node.jsと同等の基準 (拡張子mjsまたは、拡張子がjsでpackage.jsonにtype: moduleが指定)
    • type = javascript/esm
    • Node.jsのNative ESMに準じる扱いを受ける。
  • 自動判定 (拡張子がjsでpackage.jsonにtype指定無し)
    • type = javascript/auto
    • ファイルがESM構文 (import / export) を含む場合はFake ESMに準じる扱いを受ける。

WebpackのNative ESMモードではFake ESMモードと比べて以下の違いがあります。

  • import宣言で拡張子の省略ができなくなる
  • 存在しない名前をインポートしようとするとビルドエラーになる
  • デフォルトインポートが __esModule 設定を読まなくなる
    • __esModule の有無にかかわらず、CommonJSからのデフォルトインポートは module.exports に解決される
  • CommonJS 由来のAPIが使えない
    • module.hot (代替: import.meta.webpackHot)
    • module.loaded
    • module.id
    • require (代替: import宣言/import関数)
    • require.context (代替: import.meta.webpackContext)
    • require.ensure (代替: import関数)
    • require.include (代替: if (false) import(/* webpackMode: "eager" */ "...") など)
    • require.main
    • __filename (代替: import.meta.url から算出)
    • __dirname (代替: import.meta.url から算出)
  • Node.js 由来のAPIが使えない
    • global (代替: globalThis, window, selfなど)
  • AMD / RequireJS 由来のAPI (define.amd, require.amd等) が使えない
  • SystemJS 由来のAPI (System) が使えない

ただし、上のリストのうち拡張子の扱いは設定項目が別 (fullySpecified) です。残りはtypeがjavascript/esmかjavascript/autoかによって決定されます。

Webpackの出力をESMにする

今回対象にしたフロントエンドではSSRのためにNode.jsサーバーを持っています。画像やCSSアセットなどの扱いの関係から、このサーバー向けのコードもWebpackによってコンパイルされています。

このとき、プロジェクト内のコードはWebpackによりバンドルされますが、依存先のライブラリはバンドルせず実行時に読み込む構成になっています。この設定のためにwebpack-node-externalsを使っています。

デフォルトではランタイム依存の読み込みにrequireを使ってしまうので、これもimportで読み込めるようにしておきます。

// webpack.config.cjs
module.exports = {
  experiments: {
    // ESM出力はまだ実験的という扱いなので、機能フラグを有効化する必要がある
    outputModule: true,
  },
  // 出力形式をESMにする
  output: {
    library: {
      type: "module",
    },
    chunkFormat: "module",
  },
  externals: [
    // 外部モジュールはimportで読み込む。
    // ただし、subpath importを使っているものに関しては、拡張子問題に引っかかるので一旦除外する。
    nodeExternals({
      allowlist: [/^(date-fns|lodash|source-map-support|react-dom|parse5)(\/|$)/],
      importType: "module",
    }),
    // 上のルールで除外されたものについてはrequireで読み込む。
    // "node-commonjs" を指定するとESM内からでもrequireが使えるようになる。
    nodeExternals({
      allowlist: [],
      importType: "node-commonjs",
    }),
  ],
};

また現在ではWebブラウザのESM対応が進んでいるため、Webブラウザ向けのWebpack出力をESM化することも考えられますが、今回の移行の目的である「ライブラリのNative ESM化」には必要ないのでスキップします。

Jest API対応

"jest" objectの対応

Jest環境内ではdescribe, test, it, expect, jestなどのグローバル変数が使えますが、このうち "jest" はNative ESMのJest環境では使えなくなります。

// Native ESMでは動かない
const mock = jest.fn();

Jestのグローバル変数は @jest/globals からもインポートできます。こちらなら動くので、明示的にインポートするようにします。

import { jest } from "@jest/globals";
const mock = jest.fn(); // OK

module mockの対応

jest.mock() を使うとモジュールをモックすることができますが、現時点ではNative ESMで同APIを使うことはできません。

かわりに jest.unstable_mockModule() が提供されていますが、jest.mockを置き換えるだけでは動きません。これにはjest.mockの巻き上げが関係しています。

Jestはテストコード中に jest.mock() の呼び出しがあった場合、実行前にそれをソースコードの先頭に移動するという特別な処理を行います。これによりどの require() よりも前に jest.mock() を呼び出せます。しかしESMでは宣言順に関係なくimport/export fromは他の文よりも先に実行されるため、同様の巻き上げを行うのは簡単ではありません。

現時点では、ユーザーが手動で実行順を調整し、関連するimportが呼ばれるよりも前に jest.unstable_mockModuleが呼ばれることを保証しなければいけません。

ちょうど我々はNative ESMに移行している最中なので、ここではTLA (Top-level await) を使うことである程度必要な変更を最小化することができます。

import { foo } from "./bar";

// bazはbarの中で使われている
jest.mock("./baz");

console.log(foo);

// どのような定義でモックするかをあらかじめ指定する必要がある
jest.unstable_mockModule("./baz", () => /* ... */);

// 普通のimportにすると実行順序の関係で正しくmockされない (実際の実装が読み込まれてしまう)
// await import + 分割代入 であれば書いた順に実行される
const { foo } = await import("./bar");

console.log(foo);

まとめ

  • Node.jsのNative ESMサポートはそれ自体はよく出来ているが、CommonJSとの互換性や、既存のフレームワークとの関わりでは適切な解決策のない問題が残っていて、移行には苦痛がともなう。
  • しかし、様々なワークアラウンドをうまく組み合わせれば、大きなプロジェクトでも移行できる。
  • このような状況ではNative ESM移行にはメリットがないように見えるが、ライブラリ側の移行に追われる形でアプリケーションのNative ESM移行は今後必要になってくる。

関連リンク

Invitation from Wantedly, Inc.
If this story triggered your interest, have a chat with the team?
Wantedly, Inc.'s job postings
28 Likes
28 Likes

Weekly ranking

Show other rankings
Like Masaki Hara's Story
Let Masaki Hara's company know you're interested in their content