JSで値変更を検知したい-4（まとめ編）

## はじめに
* 以下までの総まとめとして、一つのコードにまとめます。
    * [JSで値変更を検知したい-1（オブジェクト・defineProperty編）](https://qiita.com/ymgd-a/items/5a6bb79e90aa5633d026)
    * [JSで値変更を検知したい-2（配列・defineProperty編）](https://qiita.com/ymgd-a/items/34f0ec606ab57459334a) （廃案）
    * [JSで値変更を検知したい-3（配列・Proxy編）](https://qiita.com/ymgd-a/items/f82a6708746949960fc4)


## 実装

### コード
```javascript
/**
 * 値の変更を監視します
 * @param {Object} obj 監視対象のオブジェクト
 * @param {String} propName 監視対象のプロパティ名
 * @param {function(Object, Object)} func 値が変更された際に実行する関数
 */
function watchValue(obj, propName, func) {
    let value = obj[propName];
    Object.defineProperty(obj, propName, {
        get: () => value,
        set: newValue => {
            const oldValue = value;
            value = newValue;
            func(oldValue, newValue);
        },
        configurable: true
    });
}

/**
 * 配列オブジェクトの動作を監視します
 * @param {Array} array 監視したい配列
 * @param {function(Array, Array)} onChange 変更時の動作
 */
function watchArray(array, onChange) {
    let deletedArray = null;
    return new Proxy(array, {
        // プロパティ削除時の動作をカスタマイズ
        deleteProperty: (target, property) => {
            // 削除操作呼び出し直後は empty item になるため、
            deletedArray = [...array];
            const result = Reflect.deleteProperty(target, property);
            return result;
        },
        // プロパティ設定時の動作をカスタマイズ
        set: (target, property, val, receiver) => {
            const oldArray = [...array];
            const result = Reflect.set(target, property, val, receiver);
            if (deletedArray) {
                // 削除操作を伴う場合の検知
                onChange(deletedArray, target);
                deletedArray = null;
            } else if (property !== 'length') {
                // その他：追加や変更の検知
                onChange(oldArray, target);
            }
            return result;
        },
    });
}

/**
 * 与えられたオブジェクトのプロパティを監視します
 * @param {Object} obj 監視対象のオブジェクト
 * @param {function(Object, Object)} func 値が変更された際に実行する関数
 */
function watchAll(obj, func) {
    Object.getOwnPropertyNames(obj).forEach(propName => {
        const val = obj[propName];
        if ((val instanceof Object) && !Array.isArray(val)) {
            // オブジェクトの場合
            watchAll(val, func);
        } else if (Array.isArray(val)) {
            // 配列の場合
            obj[propName] = watchArray(val, func);
        } else {
            // その他の場合
            watchValue(obj, propName, func);
        }
    });
}


// 適当なオブジェクトを定義
const obj = {
    name: '太郎',
    age: 18,
    favorite: {
        food: 'ice cream',
        animal: 'cat'
    },
    clubs: ['Soccer', 'Baseball', 'Tennis']
};

// 変更時に実行したい関数を定義
function changeFunc(v1, v2) {
    console.log(v1);
    console.log(' =>', v2);
    console.log('');
};

// 監視
watchAll(obj, changeFunc);

// 実際に変更してみる
console.log('プロパティの変更');
obj.name = '次郎';
obj.age = 32;
obj.favorite.food = 'potato';
obj.favorite.animal = 'dog';

console.log('配列に push');
obj.clubs.push('Swimming');

console.log('配列に unshift');
obj.clubs.unshift('Sumo');

console.log('配列を pop');
obj.clubs.pop();

console.log('配列を shift');
obj.clubs.shift();
```

### 実行結果

```bash
プロパティの変更
太郎
 => 次郎

18
 => 32

ice cream
 => potato

cat
 => dog

配列に push
[ 'Soccer', 'Baseball', 'Tennis' ]
 => [ 'Soccer', 'Baseball', 'Tennis', 'Swimming' ]

配列に unshift
[ 'Soccer', 'Baseball', 'Tennis', 'Swimming' ]
 => [ 'Soccer', 'Baseball', 'Tennis', 'Swimming', 'Swimming' ]

[ 'Soccer', 'Baseball', 'Tennis', 'Swimming', 'Swimming' ]
 => [ 'Soccer', 'Baseball', 'Tennis', 'Tennis', 'Swimming' ]

[ 'Soccer', 'Baseball', 'Tennis', 'Tennis', 'Swimming' ]
 => [ 'Soccer', 'Baseball', 'Baseball', 'Tennis', 'Swimming' ]

[ 'Soccer', 'Baseball', 'Baseball', 'Tennis', 'Swimming' ]
 => [ 'Soccer', 'Soccer', 'Baseball', 'Tennis', 'Swimming' ]

[ 'Soccer', 'Soccer', 'Baseball', 'Tennis', 'Swimming' ]
 => [ 'Sumo', 'Soccer', 'Baseball', 'Tennis', 'Swimming' ]

配列を pop
[ 'Sumo', 'Soccer', 'Baseball', 'Tennis', 'Swimming' ]
 => [ 'Sumo', 'Soccer', 'Baseball', 'Tennis' ]

配列を shift
[ 'Sumo', 'Soccer', 'Baseball', 'Tennis' ]
 => [ 'Soccer', 'Soccer', 'Baseball', 'Tennis' ]

[ 'Soccer', 'Soccer', 'Baseball', 'Tennis' ]
 => [ 'Soccer', 'Baseball', 'Baseball', 'Tennis' ]

[ 'Soccer', 'Baseball', 'Baseball', 'Tennis' ]
 => [ 'Soccer', 'Baseball', 'Tennis', 'Tennis' ]

[ 'Soccer', 'Baseball', 'Tennis', 'Tennis' ]
 => [ 'Soccer', 'Baseball', 'Tennis' ]
```

## 既知の問題点・懸念点
* 上記実行結果の通り、`unshift` および `shift` では１つの配列操作で複数回の変更検知が走ってしまいます。
    * これは配列標準のメソッドが、配列に対し上記のように複数の操作を行い、かつそれが `Proxy` を使うことにより露呈してしまうためです。
    * うまいこと 最初の配列 と 最後の配列 を抜き出して、１度だけ検知後処理の呼び出しができれば良いのですが・・・
* オブジェクト配列は未対応です
    * オブジェクト配列自体への追加・挿入等 → 検知OK
    * オブジェクト配列内の各要素に対する変更 → 検知NG
    * これも上記の実装をいろいろ組み合わせれば掘り下げることはできそうです（TODO）
* 配列の全メソッドに対して対応できているか確認しきれていません
    * 例えば `reduce` メソッドなどはなかなか厄介そうな気がします…

## まとめ
* `Object.defineProperty`・・・オブジェクトのすべてのプロパティの定義が可能。頑張れば配列の全メソッドも定義できるが…
    * `Proxy` で配列操作メソッドの内部実装を正確に割り出し `defineProperty` で実装する…なんて方法もアリ？
* `Proxy`・・・配列に対してもOK、ただし内部の動作を事細かに検知してしまう
* 上記の機能で実現できそう・・・でしたが、実際はいろいろと問題が残ってしまいました。
    * TODO: Angular や Vue.js がどのような仕組みでバインディングしているのかソースコードを見てみたい…
* 巷にあるフロントエンドフレームワーク、やっぱり偉大です。。。足を向けて寝られません。。。