Open8

0から調べる--moduleResolution bundler

akadoriakadori

背景

typescript 5.0 で追加される --moduleResolution bundler について、なんだろうと思って調べてた。

必要とされる前提知識に対して、僕の知識が思ったより足りてなかったのでまとめてる。

akadoriakadori

まず Native ESM を用意します

Node.jsにはNative ESMというモードがある。

この時点で「ほえー Node.js の話なんだ」となっている。

Node.js には CommonJS Modules と ES Modules のモジュールシステムがよく使われていて、 ES Modules の構文を Node.js に直接解釈させるのが、Native ESM。

Native ESM から CommonJS は読み込めるが、 CommonJS から Native ESM は読み込めない。

自分の用語理解がふわふわしてて、なんか頓珍漢な言い方になっていそうでとても不安。

akadoriakadori

なくなってわかる拡張子補完のありがたさ

Node.js はいつの頃からか、Native ESMとして(Native ESMモードで、の方が正しい?)動かせるようになっている。

だけども、Native ESMだと、Node.js は拡張子を補完しない。拡張子を書かないと失敗する。

これが失敗したやつ。

package.json
{
  "type": "module"
  ...
}
/* foo.js */
export const foo = "foo";

/* main.js */
import { foo } from "./foo"
console.log({foo});
> node src/main.js
node:internal/errors:484
    ErrorCaptureStackTrace(err);
    ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'main.js'
    at new NodeError (node:internal/errors:393:5)
    at finalizeResolution (node:internal/modules/esm/resolve:260:11)
    at moduleResolve (node:internal/modules/esm/resolve:879:10)
    at defaultResolve (node:internal/modules/esm/resolve:1087:11)
    at nextResolve (node:internal/modules/esm/loader:161:28)
    at ESMLoader.resolve (node:internal/modules/esm/loader:831:30)
    at ESMLoader.getModuleJob (node:internal/modules/esm/loader:413:18)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/module_job:77:40)
    at link (node:internal/modules/esm/module_job:76:36) {
  code: 'ERR_MODULE_NOT_FOUND'
}

Node.js v19.2.0

こうすると成功する

/* foo.js */
export const foo = "foo";

/* main.js */
import { foo } from "./foo.js"
console.log({foo});
> node src/main.js
{ foo: 'foo' }
akadoriakadori

TypeScript には module オプションがある

module オプションは TypeScript コンパイル時の import / export の変換挙動を決定する。

module: commonjs で以下をコンパイルする。

/* foo.ts */
export const foo = 1;

/* main.ts */
import { foo } from "./foo";

console.log({foo});
 yarn tsc --module commonjs --outDir commonjs

できたものがこちらです

/* foo.js */
"use strict";
exports.__esModule = true;
exports.foo = void 0;
exports.foo = 1;

/* main.js */
"use strict";
exports.__esModule = true;
var foo_1 = require("./foo");
console.log({ foo: foo_1.foo });

じゃあ module: node16 でコンパイルする。

材料

/* foo.ts */
export const foo = 1;

/* main.ts */
import { foo } from "./foo.js";

console.log({foo});

工程

yarn tsc --module node16 --outDir node16

結果

/* foo.js */
export const foo = 1;
/* main.js */
import { foo } from "./foo.js";
console.log({ foo });
akadoriakadori

ESM の世界では拡張子は省略されない。伏線は回収される。

さっきの投稿でmodule: node16をmodule:commonjsは、tsファイルをちょっと変えている。

main.ts

- import { foo } from "./foo"; // module:commonjs
+ import { foo } from "./foo.js"; // module: node16

そう、ESMの世界では、拡張子の省略はできない(参考)ので、jsを指定しています。
なぜ module: node16 だとESMの世界にコンパイルされるかは聞かないでください。調べてないです。
というかESMという用語の使い方がさっきから間違っている気がします。

驚きだったのは、tsに書くのにfoo.jsって指定すんのねというところ。
TypeScriptは「Module Sepecifer を書き換えない」というポリシーを選択しているらしいのでこうなるらしいです。詳しくはリンク先を見てください。もっと詳しくはリンク先のリンク先を見てください。

akadoriakadori

でも拡張子を書かなくてもいいみたい。そう、--moduleResolution bundler ならね。

コンパイルしたいやつら
/* foo.ts */
export const foo = 1;

/* main.ts */
import { foo } from "./foo";

console.log({foo});

moduleResolutionがNode16だと拡張子がないとコンパイルできない。

コンパイルできない

yarn tsc --outDir node16 --noemit --moduleResolution node16
yarn run v1.22.19
$ node_modules/.bin/tsc --outDir node16 --noemit --moduleResolution node16
main.ts:1:21 - error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './foo.js'?

1 import { foo } from "./foo";
                      ~~~~~~~


Found 1 error in main.ts:1

error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

じゃあ5.0で追加されたmoduleResolution: bundlerなら?

 yarn tsc --outDir node16 --noemit --moduleResolution bundler
yarn run v1.22.19
$ node_modules/.bin/tsc --outDir node16 --noemit --moduleResolution bundler
✨  Done in 3.50s.
akadoriakadori

もう飽きてきた

拡張子つけてくれると、Node.jsとかブラウザー的にはファイルのルックアップが早くなるし、JSの配信サーバーがナイーブでも見つけられるようになるしでいいこと(意訳元)が多いらしい。
確かにNode.jsのmoduleがファイル探す挙動とかは読んでて大変そうだなーって思ってた。

だけど、結構な数のみんながバンドルしてやってるよね。バンドルするときはTSファイルからバンドルしたもの作るし、バンドルして1ファイルにいろいろ入ってりゃファイルのルックアップとかは無くなるよね。そのバンドルする人たちにとって、都合のいいモジュール解決のオプションを用意したということらしい。

akadoriakadori

余談

moduleResolution nodeでいけるらしいけど、conditional exportsがどうのこうのって書いてあるけど、残念なことに今日の英語読むポイントを使い切ってしまった。後世に託す。