package.jsonにtype: moduleを指定して、CommonJSへの依存を減らしました

  • URLをコピーしました!

こんにちは、フロントエンドエンジニアの小張(@kobari41257)です。

PR TIMESのフロントエンドはpnpm workspaceを用いたモノレポ(以下prtimes-frontend)で開発されていますが、ほぼ全てのworkspace packageでpackage.jsonのtypeが指定されていませんでした。

そのため、Next.jsがCommonJSランタイムで実行されていたり、モジュール解決に不要なコストが発生するリスクを抱えていました。

今回全てのpackage.jsonに"type": "module" を指定し、上記のようなCommonJSに依存した挙動をなくし、モジュール解決の不要な実行コストを削減することができたので、その経緯についてご紹介します。

prtimes-frontendの構成や技術スタックについてはこちらをご覧ください。

あわせて読みたい
PR TIMES のフロントエンドを支える技術 2023 こんにちは。PR TIMES でエンジニアをしている岩元 (@yoiwamoto) です! プレスリリース配信サイト PR TIMES のフロントエンドは、一昨年ごろまでほぼ全てのページが Sm...
目次

背景

まず、今回の対応にあたり事前に調査したことなどを以下にまとめます。

CommonJSとES modulesについて

Node.jsを始めとするJSランタイムには大きく2つのモジュールシステムが存在しており、それがCommonJSとES modulesです。

両者では異なる記法が使われており、CommonJSではrequire()module.exports 、ES modulesではimportexport などが使用されています。

その他両者の特徴を簡単にまとめています。

  • CommonJS
  • ES modules
    • ECMAScript標準で仕様が定義されており、ブラウザとサーバー両方で動作する記法になっている
    • 静的解析がしやすい記法のため、tree-shakingなどの最適化を行いやすい

トレンドの変化:Dual formats packageからESM-only packageへ

これまで、npmパッケージをCommonJSとES modules両方のランタイムで使えるようにするには、CommonJSだけを配布する形か、CommonJSとES modules両方の形式で配布する形(Dual formats package)が一般的でした。

これはCommonJSからES modulesをインポートする(以下require(esm) )ことができないという仕様によるものでした。

// esmodule.mjs
export const hello = () => {
  console.log("Hello from ES Module!");
};

// commonjs.cjs
const { hello } = require('./esmodule.mjs'); // ❌ これはエラーになる
hello();

ところがNode.js v22でrequire(esm) ができるようになったことで、Top-level awaitの例外を除き、ES modulesのみで配布されたパッケージ(ESM-only package)がCommonJSとES modules両方のランタイムで使えるようになりました。

Anthony Fu氏は以下の記事で、今後新規のパッケージはES modulesのみを配布することを推奨すると述べています。

I strongly recommend that all new packages be released as ESM-only

あわせて読みたい
Move on to ESM-only Let's move on to ESM-only

require(esm) でのdefault exportの扱い

Node.jsでは、ES modulesのdefault exportをCommonJSでrequire(esm) するとmodule namespace objectとしてインポートされるため、以下のように.default でアクセスする必要があります。

// point.mjs
export default class Point {
  constructor(x, y) { this.x = x; this.y = y; }
}

// index.cjs
const point = require('./point.mjs');
console.log(point); // [Module: null prototype] { __esModule: true, default: [class Point] }
console.log(point.default); // [class Point](.defaultでアクセスする)

代わりにas 'module.exports’ を使うことで.default を使わず直接アクセスするようにできます。

// point.mjs
export default class Point {
  constructor(x, y) { this.x = x; this.y = y; }
}
export { Point as 'module.exports' }

// index.cjs
const point = require('./point.mjs');
console.log(point); // [class Point](そのままアクセスできる)

つまり、default exportしているESM-only package側がas 'module.exports’を使っているかどうかによって、CommonJS側でインポートの仕方を変える必要があることになります。

CommonJSをランタイムに選択する場合この挙動に注意する必要がありますが、将来的にはTypeScriptやバンドラによって予期せぬ挙動は一定防ぐことができそうです。

ただ2025年5月時点ではTypeScriptやesbuildにはas 'module.exports’ に対応するためのissueが報告されており、ツールチェイン側の対応はこれからのようです。

GitHub
require(esm) 'module.exports' interop export name support · Issue #61645 · microsoft/TypeScript 🔍 Search Terms require(esm), module.exports export, node 22.12 ✅ Viability Checklist This wouldn't be a breaking change in existing TypeScript/JavaScript code...
GitHub
align require(esm) behavior with node · Issue #4102 · evanw/esbuild I tested some require(esm) edge cases at https://github.com/easrng/require-esm-tests. Here's where esbuild is differing from node: each call to require for a mo...

package.jsonのtypeが未指定なことによる課題

上記を踏まえて、prtimes-frontendには以下の課題があることがわかりました。

1. Next.jsのbuild結果がCommonJSになる

package.jsonのtypeを指定せずにNext.js(v15.3.0時点)をoutput: 'standalone’ でbuildすると、エントリファイルである.next/standalone/server.js はCommonJSになります(逆に"type": "module" を指定した場合build結果はES modulesになります)。

// server.jsは以下のようになる

const path = require('path')

const dir = path.join(__dirname)

require('next')
const { startServer } = require('next/dist/server/lib/start-server')

startServer();

つまりNext.jsのbuild結果がNode.jsサーバーで実行される際に、CommonJSで実行されている状態でした。

今後ESM-only packageが主流となった場合、CommonJSランタイムを選択することは上述したrequire(esm)のエッジケースなどを踏む可能性がある状態でした。

2. モジュール形式の検知に余計なコストがかかる

package.jsonのtypeを指定せずにES modulesで書かれたファイルをNode.jsで実行すると、以下のようなWarningが出ます。

(node:73059) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///server.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.

これはNode.jsが拡張子やpackage.jsonのtypeから判断できないファイルを実行する際、まずCommonJSとして実行し、途中でES modulesの記法を検知したらES modulesにfallbackして実行するという挙動があり、package.jsonのtypeを指定しないとパフォーマンス面で不利なためです。

GitHub
module: warn on detection in typeless package by GeoffreyBooth · Pull Request #52168 · nodejs/node Following up on #52093 (comment), this PR prints a warning when a .js file runs as ESM due to detection, if that file is within the scope of a package.json file...

幸いprtimes-frontendでこの影響を受ける箇所はローカル環境のみでしたが、今回の対応で合わせて解消することにしました。

対応内容

上記の課題を踏まえて以下の対応を実施しました。

  • 全てのpackage.jsonに"type": "module" を付与する
  • .xo-config.js.eslintrc.js の拡張子を.cjs にする
  • .mts 拡張子が付与されていたファイルを全て.ts 拡張子にする
  • package.jsonのmain指定をやめてexports指定にする
  • ts-nodeの使用をやめてnode --experimental-strip-types でサーバーを起動するようにする
  • Playwrightを実行するworkspaceにおいて、全てのimport文に.ts拡張子をつける

その中で特徴的なものをいくつかご紹介します。

ts-nodeの使用をやめてnode --experimental-strip-types でサーバーを起動するようにする

prtimes-frontendではPlaywrightのテストを行っており、テスト対象となるReactアプリケーションを配信するHonoサーバーの起動にts-nodeを使っていました。

詳細については以下の記事をご覧ください。

あわせて読みたい
PR TIMESにおけるPlaywrightを用いたVisual Regression Test こんにちは、フロントエンドエンジニアのやなぎ( @apple_yagi )です。 昨年、Integration TestツールをCypressからPlaywrightに移行しました。その際、Visu...

しかし、package.jsonに"type": "module" を付与するとNode.js v22.14.0、ts-node v10.9.2でts-nodeが起動できなくなり、調査したところts-nodeがES modulesとして実行できない問題がissueとして報告されていました。

GitHub
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" · Issue #2122 · TypeStrong/ts-... Search Terms When the node version is 18.20.0, running TS-node --esm **.ts displays TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts". Runs s...

そこでNode.js v22.6.0で導入されたTypeScriptファイルを直接実行できる--experimental-strip-typesというオプションを使って、Honoサーバーを起動するようにしました。

$ node --experimental-strip-types server.ts

--experimental-strip-types ではtsconfig.jsonのpathsなど一部の機能がサポートされていません。
今回ts-nodeで実行していたファイルでは幸運にもそれらの機能は使われていませんでしたが、全てのケースでts-nodeを--experimental-strip-types に置き換えられるわけではありません。

Playwrightを実行するworkspaceにおいて、全てのimport文に.ts拡張子をつける

"type": "module" を付与してPlaywright(v1.51.1)を実行すると、pnpm workspaceに対するimport文に拡張子がないというエラーが出るようになりました。

import {foo} from 'workspace-shared/utils'; // エラーになる
Error: Directory import '/workspace-test/node_modules/workspace-shared/utils' is not supported resolving ES modules imported from /workspace-test/test.ts

これはimportされる側のpackage.jsonにexports フィールドを指定することで回避できますが、exportsでBarrel Fileをエントリポイントに指定する必要がありデメリットも大きいので、今回は採用しませんでした。

あわせて読みたい
eslint-plugin-no-barrel-filesを導入してBarrel filesをやめた話 こんにちは、フロントエンドエンジニアのやなぎ(@apple_yagi)です。 PR TIMESではフロントエンドのReactリプレイス当初より Barrel file を作成するルールがありまし...

代わりに下記画像のようにworkspace-test配下の全てのimport文に拡張子を付与して対応しました。

.ts 拡張子を許容するためallowImportingTsExtensions をtsconfigに追加しています。

// tsconfig.json
{
  "compilerOptions": {
    "allowImportingTsExtensions": true,
  },
}

また、import文に拡張子がない場合ESLintでエラーを出したかったため以下のような設定を行いました。

// .xo-config.cjs
module.exports = {
  settings: {
	// import/extensionsがTypeScriptを解釈できるようにする
    'import/resolver': {
      typescript: true,
    },
    n: {
      // .ts拡張子を自動付与する
      typescriptExtensionMap: [
        ['', '.ts'],
        ['.js', '.ts'],
        ['.cjs', '.cts'],
        ['.mjs', '.mts'],
        ['.jsx', '.tsx'],
      ],
    },
  },
  rules: {
	// .ts拡張子がなければエラーにする(n/file-extension-in-importだけでは漏れるパターンがあるため追加)
    'import/extensions': ['error', 'always'],
    // .ts拡張子がなければ自動付与する
    'n/file-extension-in-import': [
      'error',
      'never',
      { '.ts': 'always', '.json': 'always' },
    ],
  },
};

実はpnpm workspaceに対するimport文以外は、拡張子を省略してもPlaywrightを実行することができました。

しかし、特定のimport文だけ拡張子を強制するようなESLintの設定が思いつかなかったのと、今後.ts 拡張子をつけることが主流になる可能性もあり、試験的な意味も含めてPlaywrightの実行ディレクトリのみ全てのimport文に.ts 拡張子を強制するようにしました。

今回対応できなかったこと

1. Next.jsのbuild結果がCommonJSになる で触れたように、"type": "module" を指定するとNext.jsのbuild結果をES modulesにすることができますが、代わりにnext.config.jsをTypeScript化することができなくなりました。

現在Pull requestが立てられており、こちらの解決を待ってnext.config.ts に移行したいと思います。

GitHub
fix(next-config-ts): properly handle ESM by devjiwonchoi · Pull Request #68365 · vercel/next.js NoteThis feature is unsupported in the Node.js version: 18.x that is below 18.19.0 19.x 20.x that is below 20.6.0 Why? next.config.ts was restricted to CJS d...

まとめ

"type": "module" を付与することでCommonJSランタイムからES modulesランタイムに移行し、モジュール検知の不要な実行コストを削減することができました。

また対応の一環でメンテナンスが滞っていたts-nodeの使用を止めることができました。

今後も技術の動向をキャッチアップしながら、メンテナンス性の高いシステムを維持できるよう改善を続けていきます。

We are hiring!

フロントエンドエンジニアを含む各種ポジションでの採用を進めています!興味があればぜひご応募ください。

あわせて読みたい
株式会社PR TIMES
02.開発部 の求人一覧 - 株式会社PR TIMES 株式会社PR TIMESが公開している、02.開発部 の求人一覧です
  • URLをコピーしました!

この記事を書いた人

2021卒でフロントエンド開発を担当しています。

目次