torikatsu923.dev

サイト内検索

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

    先日、こんな内容の投稿を見かけました。え、逆では?と思いました。import すると全部バンドルされてしまうから避けるべき、というのが私の中の常識だったからです。

    はじめに

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

    先日、こんな内容の投稿を見かけました。

    zodは import { z } from "zod" だとツリーシェイキングできないが、 import * as z from "zod" だとツリーシェイキングできる

    え、逆では?と思いました。

    import * すると全部バンドルされてしまうから避けるべき、というのが私の中の常識だったからです。バレルエクスポートを避けたほうがいいという話も、同じ理由だと理解していました。

    ですが調べてみると、この投稿は正しく、私の理解のほうが古いものでした。しかも原因はzod固有の事情にあり、import * 自体には何の問題もありませんでした。

    import * as はツリーシェイキングを阻害しない

    モダンなバンドラ(Rollup / esbuild / Vite / webpack 5)は、ネームスペースオブジェクトへの静的なプロパティアクセスを、個別のエクスポートへの参照に読み替えてくれます。

    import * as v from "valibot";
    
    v.string();  // → string というエクスポートへの参照に書き換えられる

    なので普通に書いている限り、ツリーシェイキングは効きます。

    効かなくなるのは、ネームスペースオブジェクトがランタイムに本当に生成される必要が出たときだけです。例えば以下のようなものがあります。

    const s = v[name]();   // 動的なキー
    Object.keys(v);        // リフレクション
    doSomething(v);        // 関数に丸ごと渡す
    { ...v }               // スプレッド

    こうすると「どのプロパティが使われるか」を静的に確定できないため、バンドラはオブジェクトを実際に作るしかなく、中身が全部残ります。

    裏付けとして、valibotの公式ドキュメントにも記述があります。個別インポートでもワイルドカードインポートでも違いはなく、ツリーシェイキングとコードスプリッティングはどちらでも動く、とのことでした。

    https://valibot.dev/guides/installation/

    import * そのものが悪いわけではなかったんですね。

    ではzodで何が起きているのか

    zodのエントリファイルは、だいたい次のような形になっています。

    export * from "./classic/external.js";
    export * as z from "./classic/external.js";

    1行目が個別のエクスポート、2行目がそれを全部詰めた z というオブジェクトです。

    ここで重要なのは、export * as z の z は名前空間ではなく、中身が全部入ったただのオブジェクトだということです。

    import { z } と書いた時点で、このオブジェクトはランタイムに存在しなければなりません。存在するということは、中身の全員が参照されている扱いになります。

    「使い方によっては」ではなく、この書き方をした時点で必ずそうなります。

    同じ z でも別物

    ややこしいのが、どちらの書き方でも z.string() と書けてしまう点です。

    import * as z from "zod";   // このzは自分で付けたローカル名。zzzでもzodでもいい
    import { z } from "zod";    // このzはzodがエクスポートしたもの。名前は変えられない

    前者はネームスペースオブジェクトに付けたローカル名で、z.string はビルド時に string というエクスポートへ解決されます。後者はzodが export * as z で出したオブジェクトそのものです。

    見た目が同じなので、コードからは区別がつきません。私も最初はここを取り違えていました。

    なのでzodがやったのは「2種類のimportに対応させた」ではありません。ネームスペースインポートはESMならもともとできるので、すでにできていた書き方に、名前付きインポートでも同じ書き味が出せる入口を1つ足した、という感じです。そして後から足したほうの入口が、オブジェクトなのでシェイクできません。

    バインディングとプロパティの違い

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

    const a = 1;          // スコープが持つバインディング
    const obj = { a: 1 }; // オブジェクトが持つプロパティ

    どちらも識別子は同じ a です。違うのは、それをスコープが持っているか、オブジェクトが持っているかです。

    そして決定的な差はここです。

    obj["a"];        // OK
    obj[keyName];    // OK ← 実行時に決まる名前でもアクセスできる
    
    a;               // これしかない

    プロパティ名は実行時に計算できます。ゆえに静的解析では「どのプロパティが使われるか」を確定できません。

    一方、バインディングの識別子はソースコードに書いてあるものが全てで、実行時に増えたり変わったりしません。

    バンドラが依存グラフのノードにできるのは、バインディングだけです。

    z.string() の string はプロパティ
    → グラフのノードにならない
    → 個別に切り離せない
    → z ごと丸ごと残る

    import { z } でツリーシェイキングが効かない理由はこれでした。

    ツリーシェイキングはどう動いているのか

    ツリーシェイキングがやっているのは「使われてないコードを探して消す」ではありません。エントリから辿り着けるかどうかを調べています。

    1. エントリポイントから出発する
    2. import / export を辿って、どのバインディングがどこから参照されているかのグラフを作る
    3. エントリから辿り着けたバインディングに印をつける
    4. 印のないものを捨てる

    これができる前提が、ESMのimport / exportが静的であることです。トップレベルにしか書けず、識別子が実行時に変わらない。だからコードを一切実行せずにグラフを作ることができます。

    CJSが原理的に無理なのはここですね。

    const mod = require(isDev ? "./a" : "./b");
    module.exports[computeName()] = value;

    require() はただの関数呼び出しなので、こう書けてしまいます。実行してみないと何が何に依存しているかわからないため、グラフを作ることができず、丸ごと残すしかありません。

    なおグラフを作れても、まだ消せません。未使用でもトップレベルの処理は実行されるのが言語仕様なので、バンドラが勝手に丸ごと削除することはできないためです。package.json の "sideEffects": false は、これに対して「うちのモジュールは読み込んでも副作用がないので消してOK」と作者が宣言する仕組みです。

    この誤解はどこから来たのか

    ではなぜ私は「import * はツリーシェイキングを壊す」と思い込んでいたのでしょうか。

    調べた限りでは、webpack 4 + babel-loader の時代に理由がありそうでした。当時はESMがCJSに変換されてから解析されるパイプラインがあり、そこではネームスペースインポートがシェイクできなかったようです。

    eslint-plugin-import の no-namespace ルールの議論を読んでも、ルールの根拠がツリーシェイキングになっています。

    かつて正しかった知識が、前提が変わったのに慣習として残っていた、ということみたいです。

    zodのような入口はレアである

    では他のライブラリでも import * にしたほうがいいのかというと、そうではありません。

    大半のライブラリは個別の名前付きエクスポートしか出していないため、どちらの書き方でも結果は同じになります。valibotは公式が「どちらでも違いはない」と明言していますし、three.js の import * as THREE も単なる慣習で、名前付きでも効きます。Effect の import * as Effect from "effect/Effect" は、ネームスペースが設計の単位になっているという別の話です。CJSのみのパッケージでネームスペースやdefaultが推奨されるのも、相互運用のためであってツリーシェイキングとは関係ありません。

    つまりzodだけが「同じ名前の入口が2つあり、片方だけ効かない」という珍しい形になっています。

    なぜ消さないのかというと、v3で import { z } from "zod" が事実上の標準になっていたからです。削除すると既存のコードが軒並み動かなくなるため残さざるを得ず、代わりにドキュメントを全面書き換えし、eslint-plugin-import-zod という専用のESLintプラグインまで用意して誘導する、という運用でしのいでいるようです。

    消せない選択肢を残したまま「使わないでください」と案内し続けるしかない、というのはなかなか大変だと思いました。

    では、どう気づけばいいのか

    危ないのは名前付きインポートそのものではなく、取り出したものがライブラリ全体の入口になっているケースです。

    見分け方はシンプルで、コードを書いていて「この変数1つから全部生えてるな」と感じたときです。

    z.string();
    z.object();
    z.union();
    z.array();
    // ずっと同じ変数を経由している

    こう感じたら、ドキュメントのimport例を確認してみてください。推奨形が明示されているなら、たいてい理由があります。

    逆に言えば、それ以外のケースでは悩まなくていいと思います。名前付きインポートを既定にしておけば、可読性でも未使用インポートの検出でも有利ですよね。

    そして最終的には測るのが確実です。

    • Rollup / Vite → rollup-plugin-visualizer
    • Next.js → @next/bundle-analyzer

    実際に、Next.jsで import * as z from "zod/mini" としてもロケールが全部残る、という報告はまだ解決していません。バンドラを跨いだ長い依存関係では、理屈通りにいかないこともあるみたいです。

    おわりに

    今回の記事では、zodのimportでバンドルサイズが変わる理由と、その背景にあるバインディングとプロパティの違いについて整理しました。

    「import * はツリーシェイキングを壊す」は、webpack 4の時代には正しかったものの、今では前提が変わっています。私自身、あの投稿を見かけるまで気づいていませんでした。

    同じ誤解を持っていた方の助けになれば幸いです。

    参考

    -––