1. ホーム
  2. JavaScript

【JavaScript】Proxy の使い方|オブジェクトの取得・代入を横取りして処理を挟む方法

Share

JavaScript の Proxy は、オブジェクトへの操作そのものに割り込むための仕組みです。プロパティを読み取る、値を代入する、delete で消す、in で存在を調べる——ふだん意識せずに書いているこうした操作を横取りして、自分の処理を挟み込めます。Vue 3 のリアクティビティのように「値が変わったら自動で画面を更新する」といった仕掛けも、この仕組みが土台になっています。この記事では new Proxy() の基本形から、Reflect と組み合わせる定石、実際に使える例、そして「トラップが効かない」という典型的なつまずきまでを解説します。

Proxy で何ができるのか

Proxy は、あるオブジェクトの「身代わり」を作る機能です。身代わり(プロキシ)に対して行われた操作は、いったんこちらが用意した関数に渡されます。そこで値を書き換えて返すことも、エラーを投げて止めることも、記録だけ取って何事もなかったように元の動作へ流すこともできます。

似たことは get / set アクセサ(ゲッター・セッター)でもできますが、あちらはあらかじめ決めたプロパティ名にしか付けられません。Proxy なら「まだ存在しないプロパティ名への読み取り」まで含めて、オブジェクトに対するあらゆる操作をひとまとめに拾えます。存在しない設定名へのアクセスを検出したり、どんなキーが来ても既定値を返したりといった処理は、Proxy でなければ書けません。

基本の形は new Proxy( target, handler )

Proxy は2つの引数を取ります。1つめの target が身代わりの対象になる元のオブジェクト、2つめの handler が「どの操作に割り込むか」を書いたオブジェクトです。handler に書く1つ1つの関数をトラップと呼びます。

main.js
const target = { name: '山田', age: 30 };

const handler = {
	// プロパティの読み取りに割り込むトラップ
	get( obj, prop ) {
		console.log( `${ String( prop ) } が読まれました` );
		return obj[ prop ];
	},
};

const proxy = new Proxy( target, handler );

console.log( proxy.name ); // 「name が読まれました」→ 山田
console.log( proxy.age );  // 「age が読まれました」→ 30

get トラップは get( target, property, receiver ) という3つの引数を受け取ります。第1引数は元のオブジェクト、第2引数はアクセスされたプロパティ名です。第3引数の receiver については後述しますが、ひとまず「プロキシ自身が入ってくる」と考えておけば十分です。

プロパティ名にはシンボルが渡ってくることもあります。テンプレートリテラルにシンボルを直接埋め込むと TypeError になるため、上の例では String( prop ) を挟んでいます。

書いていないトラップは既定の動作になる

ハンドラーは全部埋める必要はありません。用意していない操作は、そのまま元のオブジェクトに対する通常の動作として実行されます。上の例では get しか書いていないので、proxy.age = 40 という代入や 'name' in proxy という存在チェックは普通に動きます。空のハンドラーを渡せば、ほぼ素のオブジェクトと同じように振る舞います。

set トラップは true を返す約束になっている

代入に割り込むのが set( target, property, value, receiver ) です。get と違って、こちらには戻り値の決まりがあります。代入が成功したなら true、拒否したなら false を返さなければなりません。

main.js
const user = new Proxy( {}, {
	set( obj, prop, value ) {
		console.log( `${ String( prop ) } に ${ value } を代入します` );
		obj[ prop ] = value;
		return true; // 代入が成功したことを伝える
	},
} );

user.name = '佐藤'; // name に 佐藤 を代入します
console.log( user.name ); // 佐藤

この return true; を忘れると、戻り値は undefined になります。undefined は偽と判定されるため「代入に失敗した」という意味になり、strict mode では TypeError が投げられます。

main.js
'use strict';

const store = new Proxy( {}, {
	set( obj, prop, value ) {
		obj[ prop ] = value;
		// return を書き忘れている
	},
} );

store.name = '佐藤';
// TypeError: 'set' on proxy: trap returned falsish for property 'name'

「strict mode なんて書いていない」と思うかもしれませんが、ES モジュール(import / export を使うファイル)とクラスの中身は、宣言しなくても常に strict mode です。つまり最近のフロントエンド開発ではほぼ全域が strict mode なので、set トラップを書いたら戻り値は必ず返すものだと覚えてしまってください。同じことが deleteProperty や defineProperty にも当てはまります。

主なトラップの一覧

トラップは全部で13種類ありますが、実際によく使うのは一部です。代表的なものと、それが割り込む操作をまとめます。

トラップ割り込む操作返すべき値
getobj.prop / obj[ 'prop' ] の読み取り読み取り結果として見せたい値
setobj.prop = value の代入成功なら true、拒否なら false
has'prop' in obj の存在チェック存在するなら true
deletePropertydelete obj.prop の削除成功なら true
ownKeysObject.keys() / for...in / スプレッド構文などのキー列挙文字列かシンボルの配列
getOwnPropertyDescriptorObject.getOwnPropertyDescriptor()、列挙時の属性確認ディスクリプタか undefined
apply関数としての呼び出し fn( ... )呼び出しの結果
constructnew Fn( ... ) によるインスタンス生成オブジェクト(必須)

このほかに getPrototypeOf / setPrototypeOf / isExtensible / preventExtensions / defineProperty があります。名前を見れば分かるとおり、いずれも Object の静的メソッドや演算子と対応しています。なお最後の apply と construct だけは、target が関数(あるいはコンストラクター)のときにしか出番がありません。

Reflect とセットで使うのが定石

ここまでの例では、元の動作を obj[ prop ] や obj[ prop ] = value と自分で書いていました。実際のコードでは、これを Reflect に置き換えるのが定番です。Reflect は「オブジェクトに対する既定の操作」をメソッドの形で集めた組み込みオブジェクトです。メソッド名も引数の並びも Proxy のトラップと一対一で対応するように設計されているため、トラップの中から既定の動作を呼びたいときにそのまま使えます。

トラップ既定の動作を呼ぶ Reflect メソッド
getReflect.get( target, prop, receiver )
setReflect.set( target, prop, value, receiver )
hasReflect.has( target, prop )
deletePropertyReflect.deleteProperty( target, prop )
ownKeysReflect.ownKeys( target )
applyReflect.apply( target, thisArg, args )
constructReflect.construct( target, args, newTarget )

ありがたいのは戻り値の形がトラップの要求と一致していることです。Reflect.set() は代入できたかどうかの真偽値を返すので、return Reflect.set( ... ); と書くだけで「成功なら true」という決まりを満たせます。obj[ prop ] = value という代入式は代入した値そのものを返すため、そのまま return すると 0 や空文字のときに偽と判定されて TypeError になってしまいます。

receiver を渡すとゲッターの this がずれない

Reflect を使うもうひとつの理由が receiver です。get トラップの第3引数に渡ってくる receiver は、その操作が実際に行われたオブジェクト——通常はプロキシ自身です。これを Reflect.get() にそのまま渡すと、元のオブジェクトにゲッターがあったときの this が receiver になります。

main.js
const person = {
	first: '太郎',
	last: '山田',
	get fullName() {
		return `${ this.last } ${ this.first }`;
	},
};

const logged = new Proxy( person, {
	get( target, prop, receiver ) {
		console.log( '読み取り:', String( prop ) );
		return Reflect.get( target, prop, receiver );
	},
} );

console.log( logged.fullName );
// 読み取り: fullName
// 読み取り: last
// 読み取り: first
// 山田 太郎

fullName の中で読んでいる last と first にもログが出ているところが要点です。ゲッターの this がプロキシになっているため、その内側の読み取りもトラップを通っています。

これを return target[ prop ]; に書き換えると、ゲッターの this は元のオブジェクトになります。すると内側の読み取りはプロキシを経由しないため、ログは fullName の1行しか出ません。ログを取るだけなら些細な差ですが、変更検知やアクセス制御を目的にしていると「一部のプロパティだけ処理が漏れる」という分かりにくい不具合になります。

実際に使える3つの例

設定オブジェクトのタイプミスを検出する

設定値をまとめたオブジェクトは、キー名を1文字間違えても undefined が返るだけで、エラーになりません。「なぜか設定が効かない」と悩んだ末に timeOut と timeout の綴り違いだった、というのはよくある話です。get トラップで存在チェックを挟めば、その場で気づけます。

config.js
const settings = {
	apiUrl: 'https://example.com/api',
	timeout: 3000,
	retry: 2,
};

export const config = new Proxy( settings, {
	get( target, prop, receiver ) {
		// シンボルは内部処理で読まれることがあるので素通りさせる
		if ( typeof prop === 'symbol' ) {
			return Reflect.get( target, prop, receiver );
		}
		if ( ! Reflect.has( target, prop ) ) {
			throw new ReferenceError( `設定 "${ prop }" は存在しません` );
		}
		return Reflect.get( target, prop, receiver );
	},
} );

console.log( config.timeout ); // 3000
console.log( config.timeOut ); // ReferenceError: 設定 "timeOut" は存在しません

シンボルを素通りさせているのは、console.log() や各種ライブラリーが Symbol.toStringTag のような内部用のプロパティを読みにくることがあるためです。そこで例外を投げてしまうと、デバッグ中に意味の分からないエラーが出て混乱します。

代入される値をチェックする

set トラップは、値がオブジェクトに入る直前の関所です。型や範囲を確認して、条件に合わない値を弾けます。

main.js
const scores = new Proxy( {}, {
	set( target, prop, value, receiver ) {
		if ( typeof value !== 'number' || Number.isNaN( value ) ) {
			throw new TypeError( `${ String( prop ) } には数値を指定してください` );
		}
		if ( value < 0 || value > 100 ) {
			throw new RangeError( `${ String( prop ) } は 0〜100 で指定してください` );
		}
		return Reflect.set( target, prop, value, receiver );
	},
} );

scores.math = 80;          // OK
console.log( scores.math ); // 80

scores.english = '90';     // TypeError: english には数値を指定してください
scores.science = 120;      // RangeError: science は 0〜100 で指定してください

エラーを投げずに return false; を返す書き方もありますが、strict mode では結局 TypeError になりますし、そのメッセージは「トラップが false を返した」という素っ気ないものです。何が悪かったのかを伝えたいなら、上のように自分でエラーを投げるほうが親切です。

存在しないキーに既定値を返す

逆に、存在しないキーを「無かったこと」にせず、決まった値を返してしまう使い方もできます。集計処理で「初回だけ undefined になる」問題を消せるので、地味に便利です。

main.js
function withDefault( obj, defaultValue ) {
	return new Proxy( obj, {
		get( target, prop, receiver ) {
			return Reflect.has( target, prop )
				? Reflect.get( target, prop, receiver )
				: defaultValue;
		},
	} );
}

const counts = withDefault( {}, 0 );

for ( const fruit of [ 'りんご', 'みかん', 'りんご' ] ) {
	// 初回でも undefined にならないので、そのまま足せる
	counts[ fruit ] = counts[ fruit ] + 1;
}

console.log( counts.りんご ); // 2
console.log( counts.みかん ); // 1
console.log( counts.ぶどう ); // 0(キーは追加されない)

変更を検知して再描画する

Proxy の応用としていちばん有名なのが、この「値が変わったら通知する」パターンです。set と deleteProperty を押さえておけば、状態オブジェクトへの変更をひととおり拾えます。

reactive.js
export function reactive( target, onChange ) {
	return new Proxy( target, {
		set( obj, prop, value, receiver ) {
			const before = obj[ prop ];
			const result = Reflect.set( obj, prop, value, receiver );

			// 代入に成功して、実際に値が変わったときだけ通知する
			if ( result && before !== value ) {
				onChange( prop, value, before );
			}
			return result;
		},
		deleteProperty( obj, prop ) {
			const result = Reflect.deleteProperty( obj, prop );
			if ( result ) {
				onChange( prop, undefined );
			}
			return result;
		},
	} );
}
main.js
import { reactive } from './reactive.js';

const output = document.getElementById( 'output' );

const state = reactive( { count: 0 }, () => render() );

function render() {
	output.textContent = `現在の値: ${ state.count }`;
}

document.getElementById( 'inc' ).addEventListener( 'click', () => {
	// 代入するだけで render() が呼ばれる
	state.count += 1;
} );

render(); // 初期表示

state.count += 1 と書くだけで画面が更新されます。イベントハンドラーの中で描画関数を呼ぶ必要がなくなるのがポイントで、Vue 3 のリアクティビティもこの発想を本格的にしたものです。Object.defineProperty() を使っていた Vue 2 では、あとから追加したプロパティを検知できないという制限がありました。

なお、配列を reactive() に渡すと、push() のような操作でも set トラップが動きます。要素の追加と length の更新という2つの代入として処理されるため、通知が複数回飛ぶ点だけ頭に入れておいてください。再描画が重い場合は、通知をまとめてから一度だけ処理する工夫が必要になります。

関数呼び出しに割り込む apply と construct

target に関数を渡すと、呼び出しそのものを横取りできます。処理時間の計測やログ、引数の検証を、元の関数に手を入れずに追加できます。

main.js
function heavyTask( n ) {
	// 時間のかかる処理
	return n * 2;
}

const measured = new Proxy( heavyTask, {
	apply( target, thisArg, args ) {
		const start = performance.now();
		const result = Reflect.apply( target, thisArg, args );
		console.log( `${ target.name }: ${ performance.now() - start } ms` );
		return result;
	},
} );

console.log( measured( 21 ) );
// heavyTask: 0.1 ms
// 42

同じ要領で、construct( target, args, newTarget ) トラップを書けば new による生成にも割り込めます。中身は return Reflect.construct( target, args, newTarget ); で既定の動作に流すのが基本形で、第3引数の newTarget(継承されたときに実際に new されたコンストラクター)をそのまま渡しておくとプロトタイプチェーンが壊れません。なお construct は必ずオブジェクトを返す必要があり、return; だけで終えたり数値を返したりすると TypeError になります。

トラップが動かないときに確認すること

「書いたはずのトラップが呼ばれない」という相談の原因は、だいたい次の3つに絞られます。

元のオブジェクトを直接触っても何も起きない

Proxy は元のオブジェクトを改造するものではありません。target はそのままで、別に身代わりが1つ増えるだけです。したがって プロキシ経由でアクセスしなければトラップは効きません。

main.js
const target = { count: 0 };

const proxy = new Proxy( target, {
	set( obj, prop, value ) {
		console.log( '代入を検知しました' );
		obj[ prop ] = value;
		return true;
	},
} );

proxy.count = 1;  // 「代入を検知しました」
target.count = 2; // 何も出ない(トラップを通っていない)

console.log( proxy.count ); // 2 ← 変更自体は反映されている

最後の行が 2 になっているのが、この仕組みを表しています。プロキシと元のオブジェクトは同じ中身を共有していて、単に「どちらの入口から入ったか」だけが違うわけです。

対策は単純で、元のオブジェクトへの参照を残さないことです。変数に入れずにその場でリテラルを渡してしまえば、あとからうっかり直接触ることができなくなります。関数に渡すときや、モジュールから外に出すときも、必ずプロキシのほうを渡してください。

ネストしたオブジェクトは自動では包まれない

先ほどの reactive() に入れ子のオブジェクトを渡すと、内側の変更は検知できません。

main.js
const state = reactive( { user: { name: '山田' } }, () => console.log( '変更' ) );

state.user = { name: '佐藤' }; // 「変更」が出る
state.user.name = '鈴木';      // 何も出ない

理由は、state.user の読み取りで返ってくるのが生のオブジェクトだからです。そこから先の .name = '鈴木' という代入は、プロキシとは何の関係もない普通のオブジェクトへの代入になります。

深いところまで面倒を見たいなら、get トラップの中で「返す値がオブジェクトなら、それもプロキシで包んでから返す」という再帰を書きます。

deep-reactive.js
export function deepReactive( target, onChange ) {
	// 同じオブジェクトには同じプロキシを返すための保管場所
	const cache = new WeakMap();

	return new Proxy( target, {
		get( obj, prop, receiver ) {
			const value = Reflect.get( obj, prop, receiver );

			if ( typeof value !== 'object' || value === null ) {
				return value;
			}
			if ( ! cache.has( value ) ) {
				cache.set( value, deepReactive( value, onChange ) );
			}
			return cache.get( value );
		},
		set( obj, prop, value, receiver ) {
			const before = obj[ prop ];
			const result = Reflect.set( obj, prop, value, receiver );

			if ( result && before !== value ) {
				onChange( prop, value );
			}
			return result;
		},
	} );
}

WeakMap でキャッシュしているのには理由があります。これが無いと get のたびに新しいプロキシが作られてしまい、state.user === state.user が false になります。同一性の比較が壊れると、React の再レンダリング判定のような「参照が変わったか」を見る処理が誤作動するので、キャッシュはほぼ必須と考えてください。WeakMap を使うのは、元のオブジェクトが不要になったときに一緒に回収されるようにするためです。

Map や Date を包むとメソッドが例外を投げる

これはやや意外なつまずきです。Map や Set、Date を Proxy で包むと、空のハンドラーであってもメソッド呼び出しが失敗します。

main.js
const map = new Proxy( new Map(), {} );

map.set( 'a', 1 );
// TypeError: Method Map.prototype.set called on incompatible receiver

Map の中身は通常のプロパティではなく、内部スロットと呼ばれる外から見えない領域に入っています。メソッドは this の内部スロットを見に行きますが、プロキシ経由で呼ぶと this がプロキシになり、そこには内部スロットがないため「不適切なレシーバー」として弾かれます。Proxy は内部スロットを転送しません。同じ理由で、クラスのプライベートフィールド(#)を使ったインスタンスも包めません。

どうしても包みたい場合は、get トラップで関数を取り出すときに元のオブジェクトへ束縛します。

main.js
const realMap = new Map();

const loggedMap = new Proxy( realMap, {
	// receiver は渡さない(this を元の Map にしたいため)
	get( target, prop ) {
		const value = target[ prop ];

		if ( typeof value === 'function' ) {
			console.log( `Map.${ String( prop ) }() が呼ばれました` );
			return value.bind( target );
		}
		return value;
	},
} );

loggedMap.set( 'a', 1 );        // Map.set() が呼ばれました
console.log( loggedMap.get( 'a' ) ); // Map.get() が呼ばれました → 1
console.log( loggedMap.size );  // 1

ここでは Reflect.get() に receiver を渡していません。size はゲッターとして定義されているため、this がプロキシになると同じ TypeError が起きるからです。receiver を渡すのが定石とはいえ、内部スロットを持つオブジェクトを相手にするときは例外だと覚えておいてください。

トラップが自由に振る舞えない場面

Proxy は何でも好きな値を返せるように見えますが、オブジェクトとして守られるべき前提——仕様では不変条件(invariant)と呼ばれます——だけは破れません。破ろうとすると TypeError になります。

書き換え不可のプロパティには嘘をつけない

元のオブジェクトに writable: false かつ configurable: false のプロパティがある場合、get トラップはその実際の値を返さなければなりません。

main.js
const target = {};

Object.defineProperty( target, 'id', {
	value: 1,
	writable: false,
	configurable: false,
} );

const proxy = new Proxy( target, {
	get() {
		return 999; // 実際の値と違う値を返そうとしている
	},
} );

console.log( proxy.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

「書き換えられず、設定も変えられない」と宣言されたプロパティが、見るたびに違う値を返してしまっては約束が成り立ちません。そのため Proxy であっても、この種のプロパティには手を出せないようになっています。同様に、has トラップは設定変更不可の自プロパティを「存在しない」と答えられませんし、deleteProperty もそれを削除できたことにはできません。

Proxy はポリフィルできない

Proxy は ES2015 で追加された機能ですが、古い環境向けにポリフィルで補うことができません。ポリフィルとは「新しい機能を既存の機能で書いて補う」ものですが、ES5 の範囲には「オブジェクトへの任意の操作に割り込む」手段がそもそも存在しないためです。

近いことができる Object.defineProperty() は、あくまでプロパティ名を指定してゲッター・セッターを定義する機能です。まだ存在しないキーへの読み書き、delete、in 演算子、キーの列挙には手が届きません。よく知られた proxy-polyfill も、監視したいプロパティ名をあらかじめ列挙しておく必要があり、再現できるのは get と set の一部という限定的なものです。

とはいえ現在では、モダンブラウザーと Node.js のどちらでも広く使えます。実質的に問題になるのは Internet Explorer をサポート範囲に含める場合くらいなので、多くのプロジェクトでは気にせず使って構いません。判断に迷うときは、MDN の Proxy のページにあるブラウザー互換性の表で対象環境を確認してください。

まとめ

Proxy は new Proxy( target, handler ) で作り、handler に書いたトラップがオブジェクトへの操作に割り込みます。よく使うのは読み取りの get と代入の set で、set は成功したときに true を返す決まりです。strict mode では返し忘れが TypeError になるので、ここだけは忘れないでください。

トラップの中で既定の動作を呼ぶときは Reflect を使います。戻り値の形がトラップの要求と合っているうえ、receiver をそのまま渡せるため、ゲッターの this がずれて一部の処理が漏れるといった事故を防げます。

つまずきやすいのは、Proxy が元のオブジェクトを改造するわけではないという点です。プロキシ経由でアクセスしなければトラップは効かず、入れ子のオブジェクトも自動では包まれません。深く監視したいなら get の中で再帰的に包み、WeakMap で同じプロキシを返すようにします。Map や Date のように内部スロットを持つオブジェクトは、そのままでは包めない点にも注意してください。

設定値のタイプミス検出、代入時のバリデーション、既定値の提供、変更検知による再描画——どれも数行で書けて効果が分かりやすい使い道です。まずは開発時のデバッグ用途から試してみると、この仕組みの手触りがつかめるはずです。

参考ページ