Proxy — 組み込みの Proxy と同じ名前の別概念
組み込み Proxy とパターンとしての Proxy を区別し、それぞれの使いどころを見ます。
JavaScript には Proxy という組み込みオブジェクトがあり、GoF の Proxy パターンとは別のものです。ただし Decorator で扱った言語機能との関係とは違い、こちらは片方でもう片方を実装できるという関係にあります。
- GoF の Proxy パターン — 本物のオブジェクトの代わりに置く身代わりを作り、アクセスを制御したり生成を先送りしたりする設計手法です。
- 組み込みの
Proxy— オブジェクトへの操作そのものを横取りして書き換える言語機能です。
本章は両方を扱い、どちらを選ぶかまで踏み込みます。
このパターンが解こうとした問題
GoF は Proxy を、別のオブジェクトへの代理を提供してアクセスの制御・コストの削減・複雑さの隠蔽を担うパターンとして収録しています1。
呼び出し側からは本物と同じ形に見えるので、置き換えても呼び出し側のコードは変わりません。よく挙がる用途は次の 3 つです。
- 生成の先送り — 重いオブジェクトを、実際に使われるまで作らない
- アクセスの制御 — 権限のない呼び出しを手前で弾く
- 記録 — どのプロパティが読まれたかを控える
Decorator と構造はほぼ同じで、動機が違うだけです。Decorator は振る舞いを足すため、Proxy は本物へ届く前に何かするために包みます。
クラスで素直に書く
大きな設定ファイルの読み込みを題材にします。起動のたびに読むのは重いので、実際に参照されるまで先送りします。
type SiteConfig = {siteName: string; locales: string[]};
interface ConfigStore {
read(): SiteConfig;
}
class FileConfigStore implements ConfigStore {
read(): SiteConfig {
// 実際にはファイル読み込み。ここでは省略
return {siteName: 'Reinvent Notes', locales: ['ja', 'en']};
}
}
// 本物と同じ形の身代わり。最初に呼ばれたときだけ本物を作る
class LazyConfigStore implements ConfigStore {
private cached: SiteConfig | undefined;
constructor(private readonly createInner: () => ConfigStore) {}
read(): SiteConfig {
this.cached ??= this.createInner().read();
return this.cached;
}
}
const store: ConfigStore = new LazyConfigStore(() => new FileConfigStore());
ConfigStore を受け取る側は、渡されたのが本物か身代わりかを知りません。ここは GoF のねらいどおりに働いています。
言語機能で置き換える
メソッドが 1 つなら、これも関数で書けます。ここまでの章と同じ話なので繰り返しません。この章で見るべきなのは、組み込みの Proxy を使うと何が変わるかです。
The
Proxyobject enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.2
横取りする関数はトラップと呼ばれます。
Handler functions are sometimes called traps, presumably because they trap calls to the target object.2
クラスで書く身代わりは、本物が持つメソッドを 1 つずつ自分でも宣言する必要がありました。Proxy はその素通しを自動化します。どのプロパティが読まれても get トラップ 1 つで受け取れるので、対象の項目数に関係なく書く量が変わりません。記録する例で比べてみます。
type SiteConfig = {siteName: string; locales: string[]};
const config: SiteConfig = {siteName: 'Reinvent Notes', locales: ['ja', 'en']};
// get トラップは、どのプロパティへのアクセスも 1 か所で受け取る
const observed = new Proxy(config, {
get(target, property, receiver) {
console.log(`read: ${String(property)}`);
return Reflect.get(target, property, receiver);
},
});
const shown = observed.siteName; // "read: siteName" が出る
プロパティが何個あっても、書くトラップは get 1 つです。素通しの記述が対象の項目数に比例しなくなるのが、クラスの身代わりとの決定的な違いになります。
型の面では注意が要ります。new Proxy(config, handler) の戻り値は SiteConfig として扱われますが、これは入力の型がそのまま出てくるだけです。そして get トラップの戻り値の型は any と宣言されているので、何を返しても検査されません。
type SiteConfig = {siteName: string; locales: string[]};
const config: SiteConfig = {siteName: 'Reinvent Notes', locales: ['ja', 'en']};
// get が数値を返しているのに、型は SiteConfig のまま通ってしまう
const broken = new Proxy(config, {
get() {
return 42;
},
});
const shown: string = broken.siteName; // 実行時は 42 が入る
get の戻り値が any なので、型検査はここで止まります。
これはすべてのトラップに当てはまるわけではありません。TypeScript の ProxyHandler の宣言では、戻り値が any なのは get と apply の 2 つだけです3。set / has / deleteProperty などは boolean、construct は object、ownKeys は ArrayLike<string | symbol> と宣言されていて、こちらは検査されます。穴が開いているのは、値を読み出す経路と関数を呼ぶ経路ということになります。
本連載の判断
判定: 条件付き。3 つに分かれます。
- メソッドが 1 つで、生成の先送りかキャッシュが目的 — 関数で書きます。
LazyConfigStoreは「一度だけ実行して結果を持つ関数」で置き換えられ、クラスにする利点はありません。 - メソッドが数個で、素通しの記述が許容できる範囲 — クラスで書きます。型検査が全経路に効くので、
Proxyより安全です。 - 対象の項目数が多い、または実行時にしか形がわからない — 組み込みの
Proxyを選びます。記録・監視・読み取り専用化のように、すべてのアクセスを一様に扱う用途が該当します。
境目は「読み出しの型検査を捨てる価値があるか」です。Proxy は素通しの記述を消す代わりに、get と apply の戻り値の型検査を失います。**項目が 3〜4 個なら、素通しを手で書いたほうが安全側に倒れます。**この目安は本ガイドの判断で、計測に基づくものではありません。
この判定が覆る条件
Proxy には実行速度の代償があります。プロパティアクセスのたびにトラップの関数呼び出しが挟まるため、頻繁に読まれるオブジェクトを包むと効いてきます。**どの程度効くかは実行環境と対象の形に依存するので、本ガイドでは数値を示しません。**性能が問題になる箇所では計測してから判断してください。
もう 1 つ、Proxy は包んだ結果が元のオブジェクトと別の参照になります。=== による同一性の比較や、WeakMap のキーとして使っている箇所があると、包んだ瞬間に一致しなくなります。既存のオブジェクトを後から包む場合は、この点を確認する必要があります。
既存記事との関係
- Decorator の「この判定が覆る条件」で触れた「素通しを
Proxyで自動化する」話が、本章の中身にあたります。あちらは振る舞いを足す動機、こちらはアクセスを制御する動機で、構造は共通です。 - クラスと継承 がクラス構文の基礎を扱っています。