Next.js with ESM

create-next-app
で esm 対応を最初から済ませたコードだとどういう挙動になるのか検証。それによって既存のcjs案件をどのようにesmに変換していくかの方向性を検討する。
学習メモの連なりであるため、単体の記事としての可読性、長さ、文脈、前提などは考慮しない。
目次

$ node -v
v18.12.1
$ npm -v
8.19.2

$ npx create-next-app
✔ What is your project named? … esm
✔ Would you like to use TypeScript with this project? … No / Yes
✔ Would you like to use ESLint with this project? … No / Yes

cat package.json
{
"name": "esm",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@types/node": "18.11.11",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"eslint": "8.29.0",
"eslint-config-next": "13.0.6",
"next": "13.0.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "4.9.3"
}
}

おもむろにesmにしてみるか。

そもそもpackage.json
の"type": "module"
は誰がどういう決まりで付けろとしているのか?それを調査する必要がある。
まず考えられるのはnpmのドキュメント。

npm 8系のpackage.json
のドキュメントにはtype
の定義がない。ということは外部が勝手にpackage.json
に生やすことを期待している可能性がある。
ということでNode.js側を探してみる。

Node.jsには "Node.js package.json
field definitions" という節があるのでここを確認していこう。

とりあえず最古はv14系。

どうも、Node.jsがどう振る舞うかpackage.json
に指定する際は、npm管轄ではなくNode.js管轄の"type"
を参照されるらしい。

今回扱うv18に関して読み進めてみる。

Files ending with
.js
are loaded as ES modules when the nearest parentpackage.json
file contains a top-level field"type"
with a value of"module"
.
とあるので、.js
のときにどう振る舞うかであって、.cjs
と.mjs
の話はここではしていない。ということはこれらの拡張子はどう扱うべきなのかを検討する必要がある。
-
.js
のままtype
をつければよいのか? -
.cjs
,.mjs
という拡張子を常用していけばよいのか? - その使い分けは?混在なら拡張子?一括なら
"type"
?

.cjs
, .mjs
という拡張子がどのように決まったのか経緯を調査する必要がありそうだ。

正直手探りなので、じゃあNode.js v14でドキュメントに"type": "module"
が追加されたのはなぜか?という部分を探れば関係者や関連repositoryが見つかって調査しやすくなるはず。

あった。ここを軸に調査を広げていけばわかりそう。

ガッツリ書かれたのはここ。

それっぽい名前のissueがきた。

この辺も気になる。

この辺で拡張子の話をしているので、ここをたどる。

ドキュメントの改定根拠を辿っていったらここにきた。

A new file extension
.cjs
.
ここが最古ではないだろうが、この辺ではもう合意が取れている様子が窺える。

--experimental-modules
Announcing a new
ここまできた。

.cjs extension
Just as the.mjs
file extension explicitly signifies that a file should be treated as an ES module, the new.cjs
file extension explicitly signifies that a file should be treated as CommonJS. (CommonJS is the other module system that Node.js supports, with require and module.exports.) The.cjs
extension provides a way to save CommonJS files in a project where both.mjs
and.js
files are treated as ES modules.
そのものズバリな内容がきた。

つまり明示的にそのファイルがCommonJSであることを表したい場合に拡張子をつけるのであって、運用としては"type": "module"
をpackage.json
に指定した上で.js
として開発すればよいという想定だと解釈した。
なのでわざわざすべてのファイルを.mjs
にして"type": "module"
を指定しないという手法は冗長であるといえる。

雰囲気わかって来たので明日は挙動確認。時間切れなので一旦調査終了。

2日目
間空いたけど調査再開する。今日はESM前提で初手に"type": "modue"
を設定したNext.jsはどう振る舞うのか確認していく。

今のpackage.json
はこれ。
{
"name": "esm",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@types/node": "18.11.11",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"eslint": "8.29.0",
"eslint-config-next": "13.0.6",
"next": "13.0.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "4.9.3"
},
"type": "module"
}

npm run build
> esm@0.1.0 build
> next build
error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
> Build error occurred
ReferenceError: module is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and '/Users/okunokentaro/Desktop/esm-next-js/esm/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
早々にお亡くなり。何を言ってるか調べる。

This file is being treated as an ES module because it has a '.js' file extension and '/Users/okunokentaro/Desktop/esm-next-js/esm/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
読みにくいので引用。

なるほど。/next.config.js
がmodule.exports
を使っているためCommonJS扱いになっているのか。
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
}
module.exports = nextConfig

"rename it to use the '.cjs'
file extension." と書かれているから、さっさと.cjs
を付けてもいいのだが、そもそもnext.config.js
をESMとして扱うことはできるのか、module.exports = nextConfig
を書き換えれば動くのかも調べてみる。

そもそもmodule.exports
とはなんだろうか、正確に理解し直す必要がある。

TypeScriptを使っているとなかなか書くことがないが、module.exports
の挙動をおさらいするとこうなる。
const someVariable = "hello";
module.exports = someVariable;
const a = require("./a");
console.log(`b.js: a is "${a}"`);
$ node b.js
b.js: a is "hello"

ここでb.js
はそのままで、TypeScriptにてa.ts
を作成した際に同じ結果となるようなa.js
を生成したい。どうすればいいかを考える。

const someVariable = "hello";
export default someVariable;
TS Config の Module を CommonJS
に設定し、.js
ファイルを生成する。
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const someVariable = "hello";
exports.default = someVariable;
これはmodule.exports
と同じ挙動だろうか?実はこれは正しくない。
$ node b.js
b.js: a is "[object Object]"
このとき、b.js
はこうなっている。
const a = require("./a");
console.log(a); // { default: 'hello' }
console.log(`b.js: a is "${a}"`);
export default
はdefault
プロパティに格納するのである。

module.exports
を出力するためのTypeScriptの書き方はこっち。
const someVariable = "hello";
export = someVariable;
"use strict";
const someVariable = "hello";
module.exports = someVariable;
正直案件でexport =
は一切使わない。

TypeScriptを使っていて、初学者が戸惑いやすいのがexportのやり方。そしてそこにCJSとESMの事情が絡んできて、今自分が何につまずいているのかわからなくなりがちになる。
一度整理する。
export const
export const someVariable = "hello";
CommonJS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.someVariable = void 0;
exports.someVariable = "hello";
ESNext
export const someVariable = "hello";
Node16
export const someVariable = "hello";
export default
const someVariable = "hello";
export default someVariable;
CommonJS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const someVariable = "hello";
exports.default = someVariable;
ESNext
const someVariable = "hello";
export default someVariable;
Node16
const someVariable = "hello";
export default someVariable;
export =
const someVariable = "hello";
export = someVariable;
CommonJS
"use strict";
const someVariable = "hello";
module.exports = someVariable;
ESNext
Export assignment cannot be used when targeting ECMAScript modules. Consider using 'export default' or another module format instead. (1203)
Node16
Export assignment cannot be used when targeting ECMAScript modules. Consider using 'export default' or another module format instead. (1203)

このとき、重要な考察が2点ある。
ひとつは、export =
を使ってTypeScriptを記述した際に、CommonJS
としては書き出せるがESNext
, Node16
としては書き出せない点。
もうひとつは、ESNext
, Node16
というModule設定はどちらの出力結果が同じだがどういった違いを期待しているのかという点。

export =
この表記については、もはやESMを推進していく上では使ってはならない。module.exports
と等しい挙動を実現するための表記ではあるが、ECMAScriptとしては正しくない。
a.js:2
export = someVariable;
^^^^^^
SyntaxError: Unexpected token 'export'
at Object.compileFunction (node:vm:360:18)
at wrapSafe (node:internal/modules/cjs/loader:1088:15)
at Module._compile (node:internal/modules/cjs/loader:1123:27)
at Module._extensions..js (node:internal/modules/cjs/loader:1213:10)
at Module.load (node:internal/modules/cjs/loader:1037:32)
at Module._load (node:internal/modules/cjs/loader:878:12)
at Module.require (node:internal/modules/cjs/loader:1061:19)
at require (node:internal/modules/cjs/helpers:103:18)
at Object.<anonymous> (/Users/okunokentaro/Desktop/esm-next-js/b.js:1:11)
at Module._compile (node:internal/modules/cjs/loader:1159:14)
つまり歴史的経緯などを調べたところで将来使わなくなるため、TypeScript上の表記としては提供されているが現代において積極的に使う理由はないと判断できる。

ESNext
とNode16
この違いについてはQuramy氏の『TypeScript 4.7 と Native Node.js ESM』を参照したい。
なおQuramy氏の記事は個人ブログではあるが情報の正確性としてはとても信頼できるため、ここでは引用に留める。

上記のブログにも書かれているとおり、ESNext
とNode16
は"type": "module"
を指定している限り挙動差がないと言える。
ユースケースを想像すると"type": "module"
がついていないPackageと混在しており、かつモノレポ構造で、ひとつのTypeScriptですべてをコンパイルする際に--module commonjs
と--module esnext
と分けて指定することをせずに--module node16
とすればpackage.json
の指定に従って振る舞ってくれると解釈できそうだ。

TypeScriptでConfigをどう記述すべきかと、Node.jsとしてpackage.json
をどう指定すべきかが近い話題として出てきてしまうので、一気に解釈しようとすると苦労する。
そのためNode.jsとして.cjs
, .mjs
とはなにか、"type": "module"
とはなにかを理解することと、TypeScript上でCommonJSへの出力、ESNextへの出力がどうなるかを混同して理解してはならない。

話を戻す。Next.jsでESMのみにするにはどうすればいいかという話題。
/next.config.js
はこうなっている。
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
}
module.exports = nextConfig
ここで、module.exports =
と互換であるTypeScriptの記法export =
はESMでは非互換(構文エラー)であるため、そもそもnext.config.js
をnext.config.mjs
にする、あるいはnext.config.js
というファイル名のまま"type": "module"
を指定することは、もはや不可能なようにみえる。

ここで興味深いdiscussionを見つけたので読み込んでいく。

読み進めると、対応してそうな内容が言及されていたので差分を確認する。どうもそれっぽいテストを書いている箇所を発見。

ということでドキュメントを読み直すとそのものが書かれていた。
If you need ECMAScript modules, you can use
next.config.mjs
:
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
/* config options here */
}
export default nextConfig
npx create-next-app
を実行して生成されるnext.config.js
のmodule.exports =
はそのままではESMとして解釈できないので悩んだが、ちゃんとexport default
でも扱えるように内部で改修がされていたようである。

ということはnpx create-next-app
で最初からnext.config.mjs
を出力させる手段があるのでは?

$ npx create-next-app -V
13.0.6
$ npx create-next-app -h
Usage: create-next-app <project-directory> [options]
Options:
-V, --version output the version number
--ts, --typescript
Initialize as a TypeScript project. (default)
--js, --javascript
Initialize as a JavaScript project.
--eslint
Initialize with eslint config.
--experimental-app
Initialize as a `app/` directory project.
--use-npm
Explicitly tell the CLI to bootstrap the app using npm
--use-pnpm
Explicitly tell the CLI to bootstrap the app using pnpm
-e, --example [name]|[github-url]
An example to bootstrap the app with. You can use an example name
from the official Next.js repo or a GitHub URL. The URL can use
any branch and/or subdirectory
--example-path <path-to-example>
In a rare case, your GitHub URL might contain a branch name with
a slash (e.g. bug/fix-1) and the path to the example (e.g. foo/bar).
In this case, you must specify the path to the example separately:
--example-path foo/bar
-h, --help output usage information
esm
フラグを期待したがなさそうだった。ということはcreate-next-app
で生成する場合はドキュメントに従って書き換える必要がある。

ドキュメントにはファイル名を.mjs
にしろと書かれているが、"type": "module"
の指定によって.js
がCJSではなくESMとして振る舞うことを理解していれば、ファイル名を変更せずexport default nextConfig
に書き換えるだけで動きそうという予想ができる。

$ npm run build
> esm@0.1.0 build
> next build
warn - Detected next.config.js, no exported configuration found. https://nextjs.org/docs/messages/empty-configuration
info - Linting and checking validity of types
info - Creating an optimized production build
info - Compiled successfully
info - Collecting page data
info - Generating static pages (3/3)
info - Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 4.33 kB 77.5 kB
├ └ css/ae0e3e027412e072.css 707 B
├ /_app 0 B 73.2 kB
├ ○ /404 181 B 73.3 kB
└ λ /api/hello 0 B 73.2 kB
+ First Load JS shared by all 73.4 kB
├ chunks/framework-8c5acb0054140387.js 45.4 kB
├ chunks/main-11be7a5831576890.js 26.7 kB
├ chunks/pages/_app-3893aca8cac41098.js 296 B
├ chunks/webpack-8fa1640cc84ba8fe.js 750 B
└ css/ab44ce7add5c3d11.css 247 B
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
動いてそう。ただwarn
が出ていることに注意したい。
warn - Detected next.config.js, no exported configuration found. https://nextjs.org/docs/messages/empty-configuration

/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
/* config options here */
}
export default nextConfig
よく見直したらなにも書いてなかった。そりゃそうだ。中身を書くようにして再実行する。
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
env: {
customKey: 'my-value',
},
}
export default nextConfig
ドキュメントから引用しただけではあるが、項目を追加した。

$ npm run build
> esm@0.1.0 build
> next build
info - Linting and checking validity of types
info - Creating an optimized production build
info - Compiled successfully
info - Collecting page data
info - Generating static pages (3/3)
info - Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 4.33 kB 77.5 kB
├ └ css/ae0e3e027412e072.css 707 B
├ /_app 0 B 73.2 kB
├ ○ /404 181 B 73.3 kB
└ λ /api/hello 0 B 73.2 kB
+ First Load JS shared by all 73.4 kB
├ chunks/framework-8c5acb0054140387.js 45.4 kB
├ chunks/main-11be7a5831576890.js 26.7 kB
├ chunks/pages/_app-3893aca8cac41098.js 296 B
├ chunks/webpack-8fa1640cc84ba8fe.js 750 B
└ css/ab44ce7add5c3d11.css 247 B
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
warn
が出なくなった。ということは問題なく動いているらしい。

さて、どういうファイルが出力されたのかを確認したい。"type": "module"
を指定しているから出力結果はbundledな.js
ファイルではなく個別の.mjs
ファイルなのか?というとまったくそんなことはなく、SWCを使ってコンパイル、バンドルしているため、従来と変わらない内容が出力される。
/.next/package.json
を見るとそれが明らかである。
{"type": "commonjs"}

結論をいうと、npx create-next-app
で生成した直後のコードをesmとしてビルドすること自体はとても容易であった。
問題はここから複数のライブラリをインストールしたり、CJSによる手元の既存資産を流用したりする場合にどう支障がでるかである。
今回は新規立ち上げ時の対応方法の調査のため、既存案件の移行については別スクラップとしたい。
以上。