Proxyとリフレクションの内部メソッド(トラップとMOP)
Proxyがなぜオブジェクト操作を丸ごと横取りできるのかが腑に落ちる。内部メソッド・トラップ・Reflectの三者対応と、違反すると例外になる不変条件までを仕様レベルで読み解きます。
- 1.あらゆるオブジェクト操作は11個の内部メソッド([[Get]] [[Set]] [[HasProperty]] など)に集約され、ProxyはこれをハンドラのトラップでJSコードに差し替える。
- 2.トラップ名と引数はReflectの同名メソッドと1対1で対応する。トラップ内でReflect.getなどを呼べば、既定の内部メソッドへ正しく委譲できる。
- 3.トラップの戻り値はターゲットの状態と矛盾できない。非設定可能プロパティの値を偽る・拡張不可なのに新規キーを返すなどの不変条件違反は、エンジンがTypeErrorで弾く。
すべてのオブジェクト操作は「内部メソッド」に集約される
JavaScript で obj.x、obj.x = 1、delete obj.x、'x' in obj と書くとき、エンジンの中ではいずれも 内部メソッド(internal methods) と呼ばれる仕様上の関数が呼ばれています。ECMAScript 仕様は、オブジェクトの振る舞いを [[Get]]、[[Set]] のように二重角括弧で囲んだ抽象操作の集合として定義します。構文(. や in)はその呼び出しの糖衣にすぎません。
この内部メソッド群が MOP(Meta-Object Protocol、メタオブジェクトプロトコル) です。Proxy はこの MOP の各メソッドを JS で書いたコードに差し替える仕組みであり、Reflect はその既定の実装を 関数として明示的に呼ぶための窓口です。三者は同じ内部メソッドを軸に綺麗に対応します。基礎は JavaScript を前提にします。
11個の必須内部メソッド
通常のオブジェクト(ordinary object)が備える内部メソッドは、引数の有無を問わず数えて以下の11個です。すべてのオブジェクトはこれらを実装しなければなりません(関数はさらに [[Call]]、コンストラクタは [[Construct]] を持つ)。
| 内部メソッド | 対応するトラップ | 発火する構文・操作の例 |
|---|---|---|
| [[Get]] | get | obj.x / obj['x'] |
| [[Set]] | set | obj.x = 1 |
| [[HasProperty]] | has | 'x' in obj |
| [[Delete]] | deleteProperty | delete obj.x |
| [[GetOwnProperty]] | getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor |
| [[DefineOwnProperty]] | defineProperty | Object.defineProperty |
| [[OwnPropertyKeys]] | ownKeys | Object.keys / for-in / Reflect.ownKeys |
| [[GetPrototypeOf]] | getPrototypeOf | Object.getPrototypeOf |
| [[SetPrototypeOf]] | setPrototypeOf | Object.setPrototypeOf |
| [[IsExtensible]] | isExtensible | Object.isExtensible |
| [[PreventExtensions]] | preventExtensions | Object.preventExtensions |
関数オブジェクトの [[Call]] には apply トラップ、[[Construct]] には construct トラップが対応し、合計13個のトラップが定義可能です。トラップを 定義しなかった内部メソッドは、自動的にターゲット(new Proxy(target, handler) の第1引数)の同名内部メソッドへそのまま転送されます。
for (const k in proxy) のような操作は単一のトラップでは完結しません。まず [[OwnPropertyKeys]](ownKeys)でキー一覧を取り、各キーについて [[GetOwnProperty]](getOwnPropertyDescriptor)で列挙可能性を確かめ、必要なら [[Get]](get)で値を読みます。1つの高水準操作が複数の内部メソッドへ分解される点は、トラップを書くときに必ず意識します。
トラップ・Reflect・内部メソッドの三位一体
Reflect オブジェクトのメソッドは、トラップと同じ名前・同じ引数を持つように設計されています。これは偶然ではなく、仕様が両者を同一の内部メソッドに紐付けているためです。
const handler = {
get(target, key, receiver) {
console.log('read:', key);
// 既定の [[Get]] へ委譲。receiver を渡すのが正解
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver);
},
};
const p = new Proxy({ a: 1 }, handler);
p.a; // read: a → 1
p.a = 2; // set 経由で 2 を書き込み
トラップ内で「既定動作をそのまま行いたい」場合、自前で target[key] と書くのは誤りになり得ます。理由は receiver(レシーバ)です。receiver は操作の起点となったオブジェクト(多くは Proxy 自身、あるいはそれを継承した子)で、アクセサ(getter/setter)の this をこの値に束ねるために使われます。Reflect.get(target, key, receiver) は receiver を正しく伝播しますが、target[key] は target を this にしてしまい、継承先での挙動がずれます。
set / defineProperty / deleteProperty / preventExtensions / setPrototypeOf の各トラップは 成否を表す真偽値を返す契約です。Reflect.set(...) は書き込みの成否をそのまま返すので、return Reflect.set(...) と書けば契約を満たせます。undefined(暗黙の戻り)を返すと「失敗」と解釈され、strict モードでは代入が TypeError になります。古い Object.defineProperty が例外を投げる API なのに対し、Reflect 系は真偽値を返す API である点が対照的です。
不変条件(invariants):トラップでも嘘はつけない
Proxy の最大の安全装置が 不変条件(invariants) です。トラップは任意の JS コードを書けますが、その戻り値が ターゲットの実際の状態と矛盾してはならない。矛盾すると、エンジンがトラップの結果を採用せず TypeError を投げます。これは言語の整合性(型システムやエンジン最適化が前提にする性質)を守るための仕掛けです。代表例を挙げます。
- 非設定可能(non-configurable)・非書き込み可能(non-writable)なデータプロパティを target が持つなら、
getトラップはその実値と 同じ値を返さねばならない。違う値を返すと TypeError。 - target に存在する 非設定可能プロパティを、
hasトラップが「無い」と偽ったり、ownKeysが一覧から 省くことはできない。 - target が 拡張不可(non-extensible) なら、
ownKeysは target の実際の自有キー集合と完全に一致させねばならず、新しいキーを でっち上げられない。 getPrototypeOfトラップは、target が拡張不可なら target の実際のプロトタイプと 同じものを返さねばならない。
const target = {};
Object.defineProperty(target, 'id', {
value: 42, writable: false, configurable: false,
});
const p = new Proxy(target, {
get() { return 'fake'; }, // 実値 42 と矛盾する
});
p.id;
// → TypeError: 'get' on proxy: property 'id' is a read-only and
// non-configurable data property on the proxy target but the
// proxy did not return its actual value
エンジンはトラップを実行し、その戻り値を受け取ってから不変条件を照合します。つまりトラップ本体の副作用(ログ出力や状態変更)は 走ってしまう。検査に通らなかった場合だけ結果が破棄されて例外になります。トラップ内で重い処理や外部への副作用を行うと、最終的に TypeError で巻き戻せない副作用が残り得る点に注意します。
なぜ不変条件が必要か
不変条件は単なる安全網ではなく、他のオブジェクトとの見分けがつかないことを保証するための要請です。Object.freeze で凍結したオブジェクトは「キーが増えない・値が変わらない」と他のコードが信頼できます。もし Proxy がこの保証を破れるなら、凍結という概念自体が当てになりません。不変条件は「Proxy であっても、通常のオブジェクトが守る規約からは逃げられない」ことを言語レベルで担保します。
この性質は、V8 のようなエンジンが行う形ベースの最適化とも整合します。エンジンはオブジェクトの「形」を観測してアクセスを高速化しますが、その前提となる不変条件を Proxy が破れないからこそ最適化の仮定が崩れません。形の最適化そのものは V8のオブジェクト表現と隠しクラス を参照してください。
取り消し可能 Proxy と実務での使いどころ
Proxy.revocable(target, handler) は { proxy, revoke } を返し、revoke() を呼ぶと以降あらゆる内部メソッドが TypeError になります。ターゲットへの参照を外部に渡しつつ、後から能力を失効(capability の取り消し)させたい場面で使います。
実務での主用途は、DOM 操作の監視・バリデーション・遅延ロード(プロパティ初回アクセス時に実体化)・API クライアントの動的ラッパなどです。一方で、すべてのアクセスにトラップ呼び出しのコストが乗るため、ホットパスでの濫用は避けます。get/set を多用する大量ループでは素のオブジェクトより明確に遅くなります。DOM をいじる場合の再計算コストは DOM と合わせて検討してください。
押さえるべきは4点です。❶ 全オブジェクト操作は内部メソッド([[Get]] 等)に集約され、Proxy はそれをトラップで差し替える。❷ トラップ名・引数は Reflect の同名メソッドと1対1で対応し、Reflect.x で既定動作へ委譲できる。❸ get/set のアクセサ this を正すため receiver を Reflect 経由で渡す。❹ 不変条件に反する戻り値(非設定可能プロパティの値を偽る等)は TypeError で弾かれる。
まとめ
JavaScript のオブジェクト操作は、[[Get]]・[[Set]]・[[OwnPropertyKeys]] といった 内部メソッド(MOP) に集約されます。Proxy はこれらをハンドラの トラップで JS コードに差し替え、Reflect は 同名・同引数で既定の内部メソッドを呼ぶ窓口です。トラップ内では Reflect.get(target, key, receiver) のように委譲すれば receiver を含め正しく振る舞えます。ただしトラップの戻り値は 不変条件に縛られ、ターゲットの非設定可能性・拡張可能性と矛盾すれば TypeError で弾かれます。これにより、Proxy であっても通常オブジェクトの規約からは逃げられず、凍結やエンジン最適化の前提が守られます。非同期実行の文脈は イベントループの内部構造 と合わせて読むと、メタプログラミングの全体像がつながります。
Web/フロントエンド Article
Proxyとリフレクションの内部メソッド(トラップとMOP)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
トラップ名と引数はReflectの同名メソッドと1対1で対応する。トラップ内でReflect.getなどを呼べば、既定の内部メソッドへ正しく委譲できる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / Proxy」に近いか確認する。
- 強みである「あらゆるオブジェクト操作は11個の内部メソッド([[Get]] [[Set]] [[HasProperty]] など)に集約され、ProxyはこれをハンドラのトラップでJSコードに差し替える。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。