torikatsu923.dev

サイト内検索

    【JavaScript】importの4つの書き方と、相手がCJSだったときに変わること

    前回、zodのimportとツリーシェイキングについて記事を書きました。そのとき import { z } と import as z はどちらも z.string() と書けるのに、入ってくるものは別物だという話が出てきました。

    はじめに

    こんにちは、とりかつ(@torikatsu923)です。

    前回、zodのimportとツリーシェイキングについて記事を書きました。

    【JavaScript】「import * はツリーシェイキングを壊す」はもう古い - torikatsu.dev

    そのとき import { z } と import * as z はどちらも z.string() と書けるのに、入ってくるものは別物だという話が出てきました。

    言われてみると、importには4つの書き方があるのに、それぞれ何が入ってくるのかを意識したことがありませんでした。しかも相手のパッケージがESMかCJSかで結果が変わります。

    備忘録としてまとめておきます。

    import構文の一覧

    まずは相手がESMのパッケージだった場合です。CJSだと結果が変わるのでそちらは後半で扱います。

    種類 構文 importされるもの export側に必要なもの
    named import import { foo } from "x" 個別のバインディング その名前のエクスポート
    default import import foo from "x" default という名前のバインディング export default
    namespace import import * as foo from "x" 名前空間オブジェクト 不要
    side effect import import "x" なし 不要

    私が気になったのは右端の列でした。

    named と default は、エクスポート側が対応していないと書けません。export default がないパッケージに import foo from "x" はできません。

    一方 namespace と side effect は、エクスポート側に何も要求しません。ESMモジュールでありさえすれば、どんなものでも import * as で読み込むことができます。

    前回の記事で「ネームスペースインポートはもともとできた」と書いたのは、これが理由でした。

    各importの性質

    バインディングとは

    JavaScriptには束縛(バインディング)という概念があります。識別子と、値を入れる箱の結びつきのことです。

    ESMのimportで入ってくるのは、この箱への参照です。値のコピーではありません。

    // counter.js
    export let count = 0;
    export const inc = () => { count++; };
    import { count, inc } from "./counter.js";
    
    console.log(count); // 0
    inc();
    console.log(count); // 1 ← 変わる

    inc() を呼んだだけで count の値が変わります。エクスポート元と同じ箱を見ているためです。

    これは named import に限った話ではなく、namespace import でも同じです。

    import * as counter from "./counter.js";
    
    counter.inc();
    console.log(counter.count); // 1

    const { count } = require(...) だとこうはなりません。CJSの分割代入は値のコピーなので、その時点の値が固定されます。

    なお、インポートしたバインディングに再代入することはできません。

    count = 5;  // エラーになる

    default importの正体

    default は特別な入口ではなく、予約された名前のエクスポートというだけです。

    import foo from "x";
    // ↓ これと同じ
    import { default as foo } from "x";

    なので混在して書けます。

    import foo, { bar } from "x";

    default も名前の1つだと理解しておくと、後半のCJSの話が分かりやすくなります。

    名前空間オブジェクトの制約

    namespace import で入ってくるのは、名前空間オブジェクトという特殊なオブジェクトです。各エクスポートがプロパティとして並びます。

    一番困るのが呼び出せないことです。

    import * as fn from "./some-function.js";
    
    fn();  // これはできない

    CJSで関数をエクスポートしているパッケージを namespace import すると、ここで詰まります。

    他にもプロパティの追加・削除ができない、Object.prototype を継承していないので toString などが使えない、などの性質があります。ただ実務でこれらを踏むことはまずないと思います。

    ツリーシェイキングとの関係については前回の記事に書いたので、ここでは触れません。

    side effect importの用途

    変数には何も入りません。モジュールを実行するだけです。

    import "./polyfill.js";
    import "./styles.css";

    ポリフィルや、グローバルな副作用を持つもの、CSSの読み込みで使います。

    import対象がCJSだった場合

    同じ4種類でも、読み込む相手がCJSだと結果が変わります。

    種類 ESMを読むとき CJSを読むとき
    named 個別のバインディング Nodeが推測した名前。外れると落ちる
    default default エクスポートの値 module.exports そのもの。確実
    namespace 各エクスポートが並ぶ { default: module.exports, ...推測分 }
    side effect 何も入らない 何も入らない

    CJSの構造とNodeの推測

    CJSは module.exports というオブジェクトを1個出す形しかありません。

    なのでESMから読むと、default に module.exports がそのまま入ります。

    問題は名前付きエクスポートの方で、これはNodeが cjs-module-lexer というツールで推測して用意しています。

    cjs-module-lexer は、ソースコードをトークンに切り分けるツールです。構文解析まではせず、exports.foo = ... のような字面のパターンマッチで済ませているので高速ですが、その分推測が外れることがあります。

    例えば以下のように動的にプロパティを生やしている場合は拾えません。

    exports[computeName()] = value;

    さらに、新しい検出パターンを追加すると後方互換性保てなくなることから検出パターンの仕様は凍結されており、今後賢く拾ってくれるようになることもありません。

    推測が外れた場合

    SyntaxError: The requested module 'xxx' does not provide an export named 'foo'

    型定義の上では存在していても、実行時にこうなることがあります。

    確実なのは default インポートを使用することで、Nodeが吐くエラーメッセージもそのように案内してくれます。

    import pkg from "lodash";
    const { last } = pkg;

    CJS由来の名前付きエクスポートとlive binding

    ESMのimportはバインディングの共有だと書きましたが、CJSから読んだ名前付きエクスポートは、module.exports からコピーされて名前空間にセットされます。

    なので後から module.exports に生えたものは反映されません。同じ import { foo } という書き方でも、相手がESMかCJSかで挙動が変わるということですね。

    import * as React from "react" の由来

    昔よく見たこの書き方が何だったのか、ずっと分かっていませんでした。調べてみると、これはNodeの話ではなくTypeScriptのコンパイル結果の話だったようです。

    esModuleInterop: false の時代、TypeScriptは import React from "react" を require("react").default に変換していました。ReactはCJSなので default は存在せず、undefined になってしまう。

    一方 import * as React from "react" は素の require("react") に変換されたため、module.exports がそのまま入って動いた。だからこう書くしかなかった、という事情らしいです。

    esModuleInterop: true が既定になった今は、import React from "react" で問題ありません。

    使い分けの指針

    ESMのパッケージなら named import を既定にしておけば十分です。

    does not provide an export named が出たら、そのパッケージはCJSで、推測が外れています。default インポートに切り替えれば動きます。namespace import は、ライブラリ側が指定しているときに使う、くらいで良さそうです。

    面倒なのが、Node直実行とバンドラ経由で挙動が変わる場合があることです。バンドラが吸収してくれることがあるので、動いた環境だけを見て判断しない方が良さそうでした。

    おわりに

    今回は、importの4つの書き方とそれぞれ何が入ってくるのか、そして相手がCJSだと何が変わるのかを整理しました。

    なんとなく使い分けていましたが、「バインディングが入るのか、オブジェクトが入るのか」で見ると整理することができました。

    同じような疑問を持った方の助けになれば幸いです。

    参考

    -––