Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

実装

コード

/**
 * 値の変更を監視します
 * @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();

実行結果

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

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 では1つの配列操作で複数回の変更検知が走ってしまいます。
    • これは配列標準のメソッドが、配列に対し上記のように複数の操作を行い、かつそれが Proxy を使うことにより露呈してしまうためです。
    • うまいこと 最初の配列 と 最後の配列 を抜き出して、1度だけ検知後処理の呼び出しができれば良いのですが・・・
  • オブジェクト配列は未対応です
    • オブジェクト配列自体への追加・挿入等 → 検知OK
    • オブジェクト配列内の各要素に対する変更 → 検知NG
    • これも上記の実装をいろいろ組み合わせれば掘り下げることはできそうです(TODO)
  • 配列の全メソッドに対して対応できているか確認しきれていません
    • 例えば reduce メソッドなどはなかなか厄介そうな気がします…

まとめ

  • Object.defineProperty・・・オブジェクトのすべてのプロパティの定義が可能。頑張れば配列の全メソッドも定義できるが…
    • Proxy で配列操作メソッドの内部実装を正確に割り出し defineProperty で実装する…なんて方法もアリ?
  • Proxy・・・配列に対してもOK、ただし内部の動作を事細かに検知してしまう
  • 上記の機能で実現できそう・・・でしたが、実際はいろいろと問題が残ってしまいました。
    • TODO: Angular や Vue.js がどのような仕組みでバインディングしているのかソースコードを見てみたい…
  • 巷にあるフロントエンドフレームワーク、やっぱり偉大です。。。足を向けて寝られません。。。
ymgd-a
opst
情報技術と社員力でお客様を成功に導く Make IT Your Success
https://www.opst.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away