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

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

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

# Proxy / Reflect とは
* [Proxy - Javascript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy)
* [Reflect - Javascript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Reflect)
細部の仕様は MDN を参照のこと

# とりあえずの解説は他のページに譲ります。
* [ECMAScript 2015 の Proxy(Proxies) / Reflect をなんとなく理解する](https://qiita.com/ToshihiroGoto/items/36a4b355ba274ab25b46)
* [メタプログラミング | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Meta_programming)

# Proxy を使うと get / set のインターセプトができる
メソッドは色々ありますが、今回は get / set のインターセプトに絞ります。
なお、[TypeScript の Proxy の型](https://github.com/Microsoft/TypeScript/blob/master/lib/lib.es2015.proxy.d.ts)をみると色々と察しが付きます（主要部抜粋）。

```typescript: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` については以下の通りになります。

```typescript: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;
}
```

<dl>
<dt>target</dt><dd>操作されるオブジェクト</dd>
<dt>p</dt><dd>プロパティ名</dd>
<dt>value</dt><dd>代入時の値</dd>
<dt>receiver</dt><dd>Proxy、または Proxy から継承するオブジェクトのどちらか</dd>
</dl>

[^1]: なお、`PropertyKey` の中身は `string|number|symbol` です。

`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](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/set)

# 何が出来るの？
やろうと思えば、色々出来ますが、[ES6 Proxy をつかって堅牢なオブジェクトをつくるTips](https://aloerina01.github.io/blog/2017-03-14-1) などに譲ります。

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

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

```typescript: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]。

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

`View` は、例えば以下のような、更新を予約できる `schedule_update()` というメソッドを持っているものとします。

```typescript: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` とかで上手く書けると良いのですが…

```typescript: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](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy#Browser_compatibility)

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

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