8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ES2015/ES6 の Proxy を使って Model の変更を検出する(コードは TypeScript)

Last updated at Posted at 2018-07-06

Proxy を活用して、ModelView を知ることなく、Model が変更されたら、View の更新が予約される仕組みを作りました。

(2018/07/08 更新)方式を変えて Model の子オブジェクトの書き換えに対応

Proxy / Reflect とは

とりあえずの解説は他のページに譲ります。

Proxy を使うと get / set のインターセプトができる

メソッドは色々ありますが、今回は get / set のインターセプトに絞ります。
なお、TypeScript の Proxy の型をみると色々と察しが付きます(主要部抜粋)。

lib.es2015.proxy.d.ts
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 については以下の通りになります。

lib.es2015.proxy.d.ts
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ですが、setboolean を返す必要があります。普段は、何かして true を返せば良いのですが、代入をエラーにしたければ、false を返します。

set メソッドは真偽値を返します。割り当てが成功したことを示すために true を返します。set メソッドの戻り値が false で、strict モードで割り当てが起こった場合、TypeError がスローされます。
handler.set() - JavaScript | MDN

何が出来るの?

やろうと思えば、色々出来ますが、ES6 Proxy をつかって堅牢なオブジェクトをつくるTips などに譲ります。

今回は、set の監視を目的とします。

Model の変更を View に通知する

今回は Model の変更を View に通知することを考えます。Vue.js 使えとか言わないで。

controller.ts
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() というメソッドを持っているものとします。

view.ts
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 とかで上手く書けると良いのですが…

controller.ts
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、お前は駄目だ。

Proxy - JavaScript | MDN

Polyfill も難しいと思うので、ブラウザで使うならある程度環境を割り切れる方向けですね。

黒魔術的なところがあるので、使い方をあやまると危険ですが、パワフルな機能なのでうまく活用していきたいですね!

  1. なお、PropertyKey の中身は string|number|symbol です。

  2. 元々の代入は Controller.model[propertyKey] = value; などとやると再度 handler が呼ばれ再帰します。Reflect.set() に引数をそのまま渡して処理を委譲しましょう。

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?