📜

minify の効果を最大限に引き出す TypeScript コードを書く

に公開2

はじめに

2024年の11月に、札幌で開催された「クラメソさっぽろIT勉強会(仮) #6」という勉強会がありまして、そのライトニングトーク枠に登壇してきました。

タイトルは「minifyの効果を最大限に引き出すTypeScriptコードを書く」です。

昨今のフロントエンド開発では、TypeScriptを使ってコーディングし、それをトランスパイルしてできたJavaScriptファイルのサイズを minify によって削減するのが一般的でしょう。そうしたときに、ふと 「TypeScript の書き方を工夫したら、もっと minify が効率的に効くようになるかも?」 と思いたち、型安全性とコードの読みやすさを壊さない範囲で、どこまでファイルサイズを削れるか挑戦してみた、という話です。

今回はその LT ネタを、改めてブログ記事として共有させて頂きます。

今回のお題: Blazor SplitContainer の TypeScript コード

今回の挑戦の題材としたのは、以前に自分が作成した Blazor 向けのコンポーネントライブラリ "Toolbelt.Blazor.SplitContainer" です。

このコンポーネントが内部で利用している TypeScript コードを、今回の挑戦の対象とします。

オリジナルのコード

まずは、リファクタリング前のオリジナルのコードを見てみましょう。
これは class を使って実装されています。

この TypeScript コードをトランスパイル & minify した結果、ファイルサイズは 2,197 bytes でした。

ここから、いくつかのテクニックを駆使して、このファイルサイズをどこまで削減できるか試していきます。

テクニック 1: クラスからクロージャーへ

最初のステップとして、class ベースの実装をクロージャーを使った実装に変更します。

課題

class を使う実装には、minify の観点から次のような課題があります。

  • コード内に大量の this. が残ります。
  • クラスのプロパティ名やメソッド名は、minifyしても名前が変更されず、短くなりません。

ヒント

一方で、関数スコープ内で宣言された const や let などの変数名は、minifyによって短い名前に変更されます。

この特性を活かすため、次のような方針でリファクタリングしました。

解決策

  • class は使わず、状態を保持する変数と、それを参照する関数群(クロージャー)でコンポーネントのロジックを実装します。
  • これまでクラスのメソッドとして定義していたものは、アロー関数を const 変数に代入する形に変更します。

この変更により、minify 後のコードは "this.なんとか" という長い名前の参照がなくなり、圧縮された変数名が使われるようになります。

結果

このリファクタリングを適用し、minify したところ、ファイルサイズは 1,446 bytes になりました。

クラスをやめてクロージャーにするだけで、約34% もファイルサイズを削減できました。これは非常に効果が大きいですね。

テクニック 2: 文字列定数と null の重複排除

次に、コード内に繰り返し出現する定数に注目します。

課題

addEventListener と removeEventListener で、"pointerdown" や "pointermove" といった同じ文字列が繰り返し登場しています。また、定数 "null" もコードの複数箇所で使われています。これらはそのままでは minify されません。

ヒント

これも先ほどと同じ考え方を利用します。つまり、"変数名でさえあれば minify される" ということです。

解決策

  • 繰り返し出現する文字列定数や null を const 変数に格納し、その変数を参照するように変更します。
  • これにより、minify された短い変数名での参照に置き換わり、重複が排除されます。

const NULL = null; とか、ちょっと気持ち悪いかもしれませんがw

結果

この対応後、ファイルサイズは 1,446 bytes → 1,421 bytes と、わずかではありますが削減できました。

テクニック 3: メソッドを bind して関数化

続いて、イベントリスナーの登録・解除処理に着目します。

課題

splitter という同じ DOM 要素に対して、addEventListener と removeEventListener の呼び出しが何度も行われています。

ヒント

"同じオブジェクト" に対する、"同じメソッド" が、"繰り返し" 異なる引数で呼び出されている、というところがポイントです。

解決策

  • splitter.addEventListener.bind(splitter) のように、bind を使って対象のオブジェクトに束縛済みの関数を作成し、それを const 変数に格納して使い回します。
  • これにより、splitter.addEventListener という記述の重複を減らすことができます。

結果

この工夫により、ファイルサイズは 1,421 bytes → 1,336 bytes になりました。

地味ながらまぁまぁ効果があったほうかな、と思います。塵も積もればなんとやらとも言いますし。

テクニック 4: 状態オブジェクトを辞書化

最後に、状態管理の方法にメスを入れます。

課題

クロージャー化によって this はなくなりましたが、状態を管理している state オブジェクトのプロパティ名(state.Dir や state.PivotPos など)は、まだ minify されずにそのまま残ってしまっています。

ヒント

TypeScript の const enum って、トランスパイル後は、その名前は消えるんですよね。

解決策

  • state オブジェクトのキーを const enum で定義します。
const enum Props {
  Dir,
  PivotPos,
  ...
};
  • 状態変数 state の型を、辞書形式で定義します。
type State = {
  [Props.Dir]: Direction,
  [Props.PivotPos]: Position,
  ...
};
  • state.PivotPos のようなプロパティアクセスを、state[Props.PivotPos] のような辞書形式のアクセスに変更します。

結果

const enum で定義したキーは、TypeScript から JavaScript へトランスパイルされる際に、数値の即値(0, 1, 2...)にインライン展開されます。

つまり、以下のような TypeScript コードは、

state[Props.PivotPos] = getPos(ev);

JavaScript へのトランスパイルと minify 後は、以下のようにプロパティ名が消えて短縮化されるわけです。

s[1]=g(e);

上記のとおり短縮化されるいっぽうで、オリジナルの TypeScript コード上は、型安全性と可読性は維持できる、というわけです。

この最終手段を講じた結果、ファイルサイズは 1,336 bytes → 1,212 bytes となりました。

改めて結果比較

これまでの取り組みによるファイルサイズの推移を振り返ってみましょう。

  • オリジナル (class): 2,197 bytes
  • クラスからクロージャーへ: 1,446 bytes
  • 文字列定数の重複排除: 1,421 bytes
  • メソッドをbind: 1,336 bytes
  • 状態オブジェクトの辞書化: 1,212 bytes

最終的に、元のコードから 約45% のファイルサイズを削減することができました!

まとめと考察

今回の挑戦を通じて、保守性や型安全性を保った TypeScript コードでありながら、minify 効果をより高めるためのいくつかのテクニックを試すことができました。

とはいえ、昨今のブローバンド化された通信事情を考えますと、「その数百バイトを削ることにどれほどの意味があるのか?」というのが実際のところかもしれません。gzip や Brotli 圧縮のほうが効果絶大のような気もしますしw

また、一応は「可読性は維持できた」と言い張っておきましたけれども、やっぱり実際のところは「なぜこんな書き方をしているんだろう?」と混乱を招くこと必至で、実際の業務やチーム開発ではやらないほうがよさそうですね。

とはいうものの、今回のこの挑戦にまったく意味がなかったということはないかと思います。とくに「クラスではなくクロージャーで実装する」というアプローチが、ファイルサイズの削減効果が絶大であることが確認できたのはよかったと思います。クラスに基づく実装をやめてクロージャーベースにするのはそれほどトリッキーなハックでもないと思いますし、少しでも軽量にしたい、といった場面では検討する価値が大いにありそうだと感じました。まぁ、React 全盛の昨今、クラスを使った実装のほうが珍しくなっているかもしれませんが。

ということで、若干のネタ臭がある発表でありましたけれども、何かしら得るものがあれば、せめてこのネタでお楽しみ頂くことができれば幸いです。
最後までお読みいただきありがとうございました!

追補: 発表スライド資料

https://speakerdeck.com/jsakamoto/minify-noxiao-guo-wozui-da-xian-niyin-kichu-su-typescript-kodowoshu-ku

Discussion

レオレオ

クラスのプロパティ名やメソッド名は、minifyしても名前が変更されず、短くなりません。

ご参考情報ですが、少なくとも esbuild の場合は TypeScript の private 修飾子の代わりにプライベートプロパティを使用すると名前は短く変更されます。プライベートプロパティはクラスやインスタンス外部から直接参照する手段が無いので名前を変えても安全であるためだと思います。チーム開発において多少現実的な方法かもしれません。

export class Foo {
  public foo() {
    this.bar();
    this.#baz();
  }
  // bar は minify されない
  private bar() {}
  // #baz は minify される
  #baz() {}
}

esbuild デモ

@jsakamoto@jsakamoto

レオさん
有益な情報ありがとうございます! そうですね、プライベートプロパティはすっかり見落としていました。TypeScript のクラスメンバーのアクセス修飾子は、JavaScript へのトランスパイル時に消えてしまいますけれども、JavaScript レベルで提供されているプライベートプロパティの仕組みであれば、たしかに minify されますね。より現実的なアプローチだと思います。コメントありがとうございます!