Proxy
を活用して、Model
が View
を知ることなく、Model
が変更されたら、View
の更新が予約される仕組みを作りました。
(2018/07/08 更新)方式を変えて Model
の子オブジェクトの書き換えに対応
Proxy / Reflect とは
- Proxy - Javascript | MDN
-
Reflect - Javascript | MDN
細部の仕様は MDN を参照のこと
とりあえずの解説は他のページに譲ります。
Proxy を使うと get / set のインターセプトができる
メソッドは色々ありますが、今回は get / set のインターセプトに絞ります。
なお、TypeScript の Proxy の型をみると色々と察しが付きます(主要部抜粋)。
interface ProxyConstructor {
revocable<T extends object>(target: T, handler: ProxyHandler<T>): { proxy: T; revoke: () => void; };
new <T extends object>(target: T, handler: ProxyHandler<T>): T;
}
declare var Proxy: ProxyConstructor;
new Proxy(target, handler)
としてやると、target
と同じ型のオブジェクトを返しますが、実は handler
でのインターセプトが仕組まれている、というインターフェースになります。
一方の handler
のインターフェースを観てみると、省略可能なメソッドが色々ありますが、今回、扱いたい get
/ set
については以下の通りになります。
interface ProxyHandler<T extends object> {
get? (target: T, p: PropertyKey, receiver: any): any;
set? (target: T, p: PropertyKey, value: any, receiver: any): boolean;
}
- target
- 操作されるオブジェクト
- p
- プロパティ名
- value
- 代入時の値
- receiver
- Proxy、または Proxy から継承するオブジェクトのどちらか
Proxy
を通しておくと、target.p
で取得しようとすれば、handler.get()
が、
target.p = value
で設定しようとしたら handler.set()
がコールバックされるわけです。1
get
は値を返せばOKですが、set
は boolean
を返す必要があります。普段は、何かして true
を返せば良いのですが、代入をエラーにしたければ、false
を返します。
set
メソッドは真偽値を返します。割り当てが成功したことを示すためにtrue
を返します。set
メソッドの戻り値がfalse
で、strict
モードで割り当てが起こった場合、TypeError
がスローされます。
handler.set() - JavaScript | MDN
何が出来るの?
やろうと思えば、色々出来ますが、ES6 Proxy をつかって堅牢なオブジェクトをつくるTips などに譲ります。
今回は、set
の監視を目的とします。
Model の変更を View に通知する
今回は Model の変更を View に通知することを考えます。Vue.js 使えとか言わないで。
const handler = {
set(target:any, propertyKey:PropertyKey, value:any, receiver:any):boolean {
Controller.view.schedule_update(); // Model が更新されたので View の更新を予約
return Reflect.set(target, propertyKey, value, receiver); // 元々の代入を実施
}
};
Controller.model = new Proxy(new Model(), handler); // Model は Proxy で変更を検出する
Controller.view = new View(Controller.model);
Controller.view.schedule_update()
で変更があったことをView
に通知した後、Reflect.set
で元々の代入を行います2。
View
は、例えば以下のような、更新を予約できる schedule_update()
というメソッドを持っているものとします。
const DRAW_TIMEOUT = 100;
export class View {
timeout: number | undefined;
constructor(public model: Model) {
this.model = model;
this.timeout = undefined;
}
schedule_update() {
if (this.timeout) { return; } // 予約済み
this.timeout = setTimeout(this.update.bind(this), DRAW_TIMEOUT);
}
update() {
clearTimeout(this.timeout);
// 更新><
// 実際に更新する処理が終わったら、ロックを解除
this.timeout = undefined;
}
}
model
の細かな変更で逐次 View.update()
が呼ばれないように View.schedule_update()
というクッションを挟んでいます。
これで、Controller.model
を変更したら、handler
経由で View.schedule_update()
に通知が行き、timeout したら View.update()
が実行されます。
子オブジェクトのプロパティの変更も検出したい
子オブジェクトのプロパティは Proxy
でインターセプトされません。そのため、子オブジェクトの代入を検知して、そのたびに Proxy
でつつんであげる必要があります。
マクロがないので個別に書いた
超絶ださいのですが、子オブジェクト自体の代入を検出云々を考えたらこうなりました。
オブジェクトの依存関係をここで手で書き下しているのがなんともです。for-in
とかで上手く書けると良いのですが…
class ProxyHandlerObject implements ProxyHandler<object> {
set(target:any, propertyKey:PropertyKey, value:any, receiver:any):boolean {
ModelProxy.view.schedule_update(); // view の更新を予約
return Reflect.set(target, propertyKey, value, receiver);
}
}
// Foo 用の ProxyHandler
// Foo.hoge, Foo.fuga への代入の度に Proxy を挟む
class ProxyHandlerFoo implements ProxyHandler<Foo> {
static retrapKeys: Array<PropertyKey> = ["hoge","fuga"];
constructor(target: Foo) {
// 監視対象の子オブジェクトをProxyで挟んでおく
for(let key of ProxyHandlerFoo.retrapKeys) {
let original_value = Reflect.get(target, key);
Reflect.set(target, key, new Proxy(original_value, new ProxyHandlerObject()));
}
}
set(target:any, propertyKey:PropertyKey, value:any, receiver:any):boolean {
ModelProxy.view.schedule_update(); // view の更新を予約
// 監視対象の子オブジェクトなら、代入の度にProxyを挟む
if (ProxyHandlerFoo.retrapKeys.includes(propertyKey)) {
return Reflect.set(target, propertyKey, new Proxy(value, new ProxyHandlerObject()), receiver);
} else {
return Reflect.set(target, propertyKey, value, receiver);
}
}
}
// Model 用の ProxyHandler
// Model.foo の子オブジェクトは ProxyHandlerFoo 経由で監視する
class ProxyHandlerModel implements ProxyHandler<Model> {
constructor(target: Model) {
// 監視対象の子オブジェクトをProxyで挟んでおく
let original_value = Reflect.get(target, 'foo');
Reflect.set(target, 'foo', new Proxy(original_value, new ProxyHandlerFoo(original_value)));
}
set(target:any, propertyKey:PropertyKey, value:any, receiver:any):boolean {
ModelProxy.view.schedule_update(); // view の更新を予約
// 監視対象の子オブジェクトなら、代入の度にProxyを挟む
// 子オブジェクトの子オブジェクトも ProxyHandlerFoo 経由で監視できる
if (propertyKey === 'foo') {
return Reflect.set(target, propertyKey, new Proxy(value, new ProxyHandlerFoo(value)), receiver);
} else {
return Reflect.set(target, propertyKey, value, receiver);
}
}
}
互換性はどうなん?
2015年の頃は Firefox を除いて対応が進んでいなかったようですが、get
/ set
だけならいまどきのブラウザなら問題無さそうです。(Chrome 49、Safari 10 以上)。ただし、IE、お前は駄目だ。
Polyfill も難しいと思うので、ブラウザで使うならある程度環境を割り切れる方向けですね。
黒魔術的なところがあるので、使い方をあやまると危険ですが、パワフルな機能なのでうまく活用していきたいですね!