Closed54

Next.js with ESM

okunokentarookunokentaro

create-next-app で esm 対応を最初から済ませたコードだとどういう挙動になるのか検証。それによって既存のcjs案件をどのようにesmに変換していくかの方向性を検討する。

学習メモの連なりであるため、単体の記事としての可読性、長さ、文脈、前提などは考慮しない。


目次

okunokentarookunokentaro
$ 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
okunokentarookunokentaro
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"
  }
}
okunokentarookunokentaro

そもそもpackage.json"type": "module"は誰がどういう決まりで付けろとしているのか?それを調査する必要がある。

まず考えられるのはnpmのドキュメント。

okunokentarookunokentaro

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

okunokentarookunokentaro

Files ending with .js are loaded as ES modules when the nearest parent package.json file contains a top-level field "type" with a value of "module".

とあるので、.jsのときにどう振る舞うかであって、.cjs.mjsの話はここではしていない。ということはこれらの拡張子はどう扱うべきなのかを検討する必要がある。

  • .jsのままtypeをつければよいのか?
  • .cjs, .mjsという拡張子を常用していけばよいのか?
  • その使い分けは?混在なら拡張子?一括なら"type"
okunokentarookunokentaro

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

okunokentarookunokentaro

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

okunokentarookunokentaro

A new file extension .cjs.

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

okunokentarookunokentaro

.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.

そのものズバリな内容がきた。

okunokentarookunokentaro

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

なのでわざわざすべてのファイルを.mjsにして"type": "module"を指定しないという手法は冗長であるといえる。

okunokentarookunokentaro

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

okunokentarookunokentaro

2日目

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

okunokentarookunokentaro

今の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"
}
okunokentarookunokentaro
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.

早々にお亡くなり。何を言ってるか調べる。

okunokentarookunokentaro

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.

読みにくいので引用。

okunokentarookunokentaro

なるほど。/next.config.jsmodule.exportsを使っているためCommonJS扱いになっているのか。

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
}

module.exports = nextConfig
okunokentarookunokentaro

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

okunokentarookunokentaro

TypeScriptを使っているとなかなか書くことがないが、module.exportsの挙動をおさらいするとこうなる。

a.js
const someVariable = "hello";
module.exports = someVariable;
b.js
const a = require("./a");
console.log(`b.js: a is "${a}"`);
$ node b.js
b.js: a is "hello"
okunokentarookunokentaro

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

okunokentarookunokentaro
a.ts
const someVariable = "hello";
export default someVariable;

TS Config の Module を CommonJS に設定し、.jsファイルを生成する。

a.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はこうなっている。

b.js
const a = require("./a");
console.log(a); // { default: 'hello' }
console.log(`b.js: a is "${a}"`);

export defaultdefaultプロパティに格納するのである。

okunokentarookunokentaro

module.exportsを出力するためのTypeScriptの書き方はこっち。

a.ts
const someVariable = "hello";
export = someVariable;
a.js
"use strict";
const someVariable = "hello";
module.exports = someVariable;

正直案件でexport =は一切使わない。

okunokentarookunokentaro

TypeScriptを使っていて、初学者が戸惑いやすいのがexportのやり方。そしてそこにCJSとESMの事情が絡んできて、今自分が何につまずいているのかわからなくなりがちになる。

一度整理する。


export const

TS
export const someVariable = "hello";

CommonJS

JS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.someVariable = void 0;
exports.someVariable = "hello";

ESNext

JS
export const someVariable = "hello";

Node16

JS
export const someVariable = "hello";

export default

TS
const someVariable = "hello";
export default someVariable;

CommonJS

JS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const someVariable = "hello";
exports.default = someVariable;

ESNext

JS
const someVariable = "hello";
export default someVariable;

Node16

JS
const someVariable = "hello";
export default someVariable;

export =

TS
const someVariable = "hello";
export = someVariable;

CommonJS

JS
"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)
okunokentarookunokentaro

このとき、重要な考察が2点ある。

ひとつは、export =を使ってTypeScriptを記述した際に、CommonJSとしては書き出せるがESNext, Node16としては書き出せない点。

もうひとつは、ESNext, Node16というModule設定はどちらの出力結果が同じだがどういった違いを期待しているのかという点。

okunokentarookunokentaro

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上の表記としては提供されているが現代において積極的に使う理由はないと判断できる。

okunokentarookunokentaro

上記のブログにも書かれているとおり、ESNextNode16"type": "module"を指定している限り挙動差がないと言える。

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

okunokentarookunokentaro

TypeScriptでConfigをどう記述すべきかと、Node.jsとしてpackage.jsonをどう指定すべきかが近い話題として出てきてしまうので、一気に解釈しようとすると苦労する。

そのためNode.jsとして.cjs, .mjsとはなにか、"type": "module"とはなにかを理解することと、TypeScript上でCommonJSへの出力、ESNextへの出力がどうなるかを混同して理解してはならない。

okunokentarookunokentaro

話を戻す。Next.jsでESMのみにするにはどうすればいいかという話題。

/next.config.jsはこうなっている。

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
}

module.exports = nextConfig

ここで、module.exports =と互換であるTypeScriptの記法export =はESMでは非互換(構文エラー)であるため、そもそもnext.config.jsnext.config.mjsにする、あるいはnext.config.jsというファイル名のまま"type": "module"を指定することは、もはや不可能なようにみえる。

okunokentarookunokentaro

ということでドキュメントを読み直すとそのものが書かれていた。

https://nextjs.org/docs/api-reference/next.config.js/introduction


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.jsmodule.exports =はそのままではESMとして解釈できないので悩んだが、ちゃんとexport defaultでも扱えるように内部で改修がされていたようである。

okunokentarookunokentaro

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

okunokentarookunokentaro
$ 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で生成する場合はドキュメントに従って書き換える必要がある。

okunokentarookunokentaro

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

okunokentarookunokentaro
$ 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

okunokentarookunokentaro
next.config.js
/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
    /* config options here */
}

export default nextConfig

よく見直したらなにも書いてなかった。そりゃそうだ。中身を書くようにして再実行する。


next.config.js
/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
    env: {
        customKey: 'my-value',
    },
}

export default nextConfig

ドキュメントから引用しただけではあるが、項目を追加した。

https://nextjs.org/docs/api-reference/next.config.js/environment-variables

okunokentarookunokentaro
$ 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 が出なくなった。ということは問題なく動いているらしい。

okunokentarookunokentaro

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

/.next/package.jsonを見るとそれが明らかである。

.next/package.json
{"type": "commonjs"}
okunokentarookunokentaro

結論をいうと、npx create-next-appで生成した直後のコードをesmとしてビルドすること自体はとても容易であった。

問題はここから複数のライブラリをインストールしたり、CJSによる手元の既存資産を流用したりする場合にどう支障がでるかである。

今回は新規立ち上げ時の対応方法の調査のため、既存案件の移行については別スクラップとしたい。

以上。

このスクラップは2022/12/08にクローズされました