📛

ネストオブジェクトの罠 RE: TypeScriptで「選択肢」の定義をEnum的な定数にまとめる

2024/08/14に公開3

この記事は、静的解析とビルドサイズ面で興味深いテーマでした。記事として自分の考えを書きます。

https://zenn.dev/yumemi_inc/articles/ts-enum-like-const

注意。あくまでビルドパフォーマンス視点での最適化です。強い意図があって、自分のドメインモデリングの方法論ではこれが最適なんだ、というなら元コードの方法論を止めるつもりはありません。

元記事のコードを minify するとどうなるか

元コードを参考に、それにアクセスするサンプルコードを書いてみます。

const sortingOptions = {
  priceDesc: {
    id: "priceDesc",
    sort: "price",
    order: "desc",
    label: "価格が高い順",
  },
  priceAsc: {
    id: "priceAsc",
    sort: "price",
    order: "asc",
    label: "価格が安い順",
  },
  ratingDesc: {
    id: "ratingDesc",
    sort: "rating",
    order: "desc",
    label: "平均評価が高い順",
  },
  ratingAsc: {
    id: "ratingAsc",
    sort: "rating",
    order: "asc",
    label: "平均評価が低い順",
  },
}

const labels = [
  sortingOptions.priceDesc.label,
  sortingOptions.priceAsc.label,
  sortingOptions.ratingDesc.label,
  sortingOptions.ratingAsc.label,
];

console.log(labels);

minify 時は型は残らないので、terser のプレイグラウンドで試しやすくするために落としてあります。

自分で試したい人は https://try.terser.org/

結果

const e=[{id:"priceDesc",sort:"price",order:"desc",label:"価格が高い順"}.label,{id:"priceAsc",sort:"price",order:"asc",label:"価格が安い順"}.label,{id:"ratingDesc",sort:"rating",order:"desc",label:"平均評価が高い順"}.label,{id:"ratingAsc",sort:"rating",order:"asc",label:"平均評価が低い順"}.label];console.log(e);

オブジェクトがまるっと残り、そのうち一部にアクセスすることで、ほとんどランタイム上でデッドコードになります。

これを消す方法があります。

最適化可能なコード

あくまで自分ならこう書く、という例です。

const enum SortingOptions$Labels {
  priceDesc = "価格が高い順",
  priceAsc = "価格が安い順",
  ratingDesc = "平均評価が高い順",
  ratingAsc = "平均評価が低い順",
};
const enum SortingOptions$Order {
  desc = "desc",
  asc = "asc",
}
const enum SortingOptions$SortType {
  price = "price",
  rating = "rating",
}

const labels = [
  SortingOptions$Labels.priceDesc,
  SortingOptions$Labels.priceAsc,
  SortingOptions$Labels.ratingDesc,
  SortingOptions$Labels.ratingAsc
];
console.log(labels);

$ を名前空間的なセパレータとして使ってますが、そこは好みの問題です。enum を使いたくないなら、 export const SortingOptions$Labels$priceDesc = ... と更に区切ってすべて展開します。

tsc + terser で次のように変換されます。

console.log(["価格が高い順","価格が安い順","平均評価が高い順","平均評価が低い順"]);

オブジェクト定義は綺麗サッパリ消え、定数だけがインライン化されました。

...自分に有利なベンチマークをしたことは認めます。ただ伝えたいのは、元コードは静的解析でビルドサイズを削る余地が残ってないのに対し、修正後のコードは未使用コード削ることができる、という点です。

これはプロジェクトが巨大な設定値を持つとき、問題として現れる可能性があります。例えばサーバーのバリデーション用の巨大な jsonschema を自前のコンバータでネストオブジェクトに変換してクライアントで使う、というようなシナリオで、実際にパフォーマンスが悪化したのを見たことがあります。

名前空間的再exportのアンチパターン

正直、上記のケースはほとんどのケースで些末な問題です。自分のモデリングではこうなんだと言われたら、特に止めません。ビルド面で不利なのは伝えますが。

問題なのは、export default {...} のパターンでこれを行ったときです。これについては元記事で踏んでいる問題ではないですが、上記の問題の解説を兼ねて、連続的な問題として取り上げます。

次のようなコードをよく見かけます。

import a from "./a";
import b from "./b";
export default {a, b}

デフォルトの名前空間としての一時オブジェクトを作成し、そのメンバとして値や関数を登録します。これは一見、同類のオブジェクトを再梱包して整理したコードに見えます

ですが、静的解析視点では、import 側が default オブジェクトの getter で何にアクセスするか、import 時に静的解決で解決できない、という問題が発生します。

import ns from "./api";
console.log(Object.keys(ns));

自分はこれを「名前空間的再 export のアンチパターン」と勝手に呼んでいます。Terser などの minifier では、残念ながらオブジェクトにアクセスするレベルでの使用/未使用は最適化の範囲外で、理由としては getter による迂回の可能性が言語仕様レベルで否定できないからです。

export default {
  x: 1,
  get y(){
    return this.x + 1;
  }
}

...これは些細な問題でしょうか?

自分は経験的に、これこそが大問題を引き起こすと考えています。 export defalut {..} はなんでも一時オブジェクトに詰め直すことで tree shake の対象外にします。結果すべてをビルドチャンクに含めてしまいます。

フロントのパフォーマンスだけならまだマシです。Next.js のようなクライアント/サーバーでモジュールを共有する環境では、次のような簡単なコードでセキュリティインシデントを発生させます。

// packages/shared/constants.ts
const CLIENT_ID = 1;
const SERVER_SECRET_TOKEN = "secret-id!!!";
// import 先で常にすべての値が露出する。
export default { CLIENT_ID, SERVER_SECRET_TOKEN }

自分は個人的に、APIスタイルに個人的の主観的な好みを持ち込むべきではなく、定量化された指標、例えばビルドサイズやカバレッジ、実行時コストで測るべきだと考えています。

自分の結論

昔、terser を複数回掛けたらコードが縮む例を調査したことがあります。その時の結果を引用しておきます。

https://zenn.dev/mizchi/articles/terser-many-times

ここから導かれるベストプラクティスは、とりあえず terser を複数回掛ける… ではなく、「定数宣言はオブジェクトメンバでやらない」ということです。terser にとっては無用な苦労です。

Discussion

Honey32Honey32

ありがとうございます!

言及いただいた記事とは別なのですが、以下の記事の内容(Enum 的なほうでなく、Config 的なほう)は、相互変換を伴わないのでインライン化の恩恵が大きそうで、にもかかわらず minify と tree-shaking を妨げていて、特に酷いことになっていることに気づきました…

https://zenn.dev/yumemi_inc/articles/js-front-constants-a1fb3c49eb1199

一旦は該当記事にコールアウトを追加して当記事へのリンクを示し、追って調査・修正します。

mizchimizchi

@Honey32

該当記事を読んだ感じ、特に避けたほうが良いのは次のコードだと思いました。

export const exportedFileNames = {
  userPosts: (userName: string) => `投稿一覧_${userName}さま.json`,
  everything: `この世の全て.json`,
} as const

これは userPosts がアロー関数かそうじゃないか (thisへのアクセス可能性を含むか)、everything の多層ネスト解決時に何回minify を掛けたかで結果が変化します。特に必要性のない複雑性を持ち込むので、ラップしたオブジェクトのネームスペースで区切るより、ファイルスコープやその他の手段で分割したいケースだと思います。

Honey32Honey32

アローじゃない関数の件、全く知りませんでした…

こういう細かな知識を要求されてしまうことを考えると、

exportedFileNames_userPosts のように、「ドル記号なりアンダースコアなりを使って擬似的な名前空間を作る」に寄せてしまったほうがのが、(自動補完の効きやすさとの兼ね合いも含めて)やりやすそうですね…

よぎったことはある気がしますが「でも、こんな変な書き方、癖が強くて受け入れられにくそうやな…」と思って却下していましたが、思い切って導入しようと思います。

一応、Next.js 14.2.5 App Router で試してみると、アローじゃない関数や、pagintaion["/download/"].itemsPerPage でも上手くインライン化されてしまい、困惑しています…

でも、Next.js 14.1.4 Pages Router のプロジェクトでネストの方を試すとインライン化に失敗していたのを確認できたので、最適化機能の「気を利かせた」挙動に依存しきった富豪的な方法よりも「手動名前空間」を、より安全ということで優先的に扱おうと思います。

アローじゃない関数で実際にやってみた
app/_consts/exported-file-names
export const exportedFileNames = {
  userPosts(userName: string) {
    return `投稿一覧_${userName}さま.json`;
  },
  everything: `この世の全て.json`,
} as const;

app/download/page.tsx
"use client";

import { FC } from "react";
import { exportedFileNames } from "../_consts/exported-file-names";

const DownloadPage: FC = () => {
  return (
    <div>
      <div className="container mx-auto p-4">
        <h1 className="text-4xl font-bold px-4">ダウンロード</h1>

        <button
          className="p-2 border-2 bg-slate-100 hover:bg-slate-200 rounded cursor-pointer"
          onClick={() => {
            console.log(`ダウンロード ${exportedFileNames.everything}`);
          }}
        >
          ダウンロード
        </button>
      </div>
    </div>
  );
};

export default DownloadPage;

/_next/static/chunks/app/download/page-a836fb35e742ef4d.js
(self.webpackChunk_N_E = self.webpackChunk_N_E || []).push([[40], {
  2439: function(n, e, s) {
    Promise.resolve().then(s.bind(s, 8896))
  },
  8896: function(n, e, s) {
    "use strict";
    s.r(e),
    s.d(e, {
      default: function() {
        return t
      }
    });
    var o = s(7437)
      , t = ()=>(0,
    o.jsx)("div", {
      children: (0,
      o.jsxs)("div", {
        className: "container mx-auto p-4",
        children: [(0,
        o.jsx)("h1", {
          className: "text-4xl font-bold px-4",
          children: "ダウンロード"
        }), (0,
        o.jsx)("button", {
          className: "p-2 border-2 bg-slate-100 hover:bg-slate-200 rounded cursor-pointer",
          onClick: ()=>{
            console.log("ダウンロード ".concat("この世の全て.json"))
          }
          ,
          children: "ダウンロード"
        })]
      })
    })
  }
}, function(n) {
  n.O(0, [971, 23, 744], function() {
    return n(n.s = 2439)
  }),
  _N_E = n.O()
}
]);