LoginSignup
18
13

More than 5 years have passed since last update.

JSで値変更を検知したい-3(配列・Proxy編)

Last updated at Posted at 2019-02-25

はじめに

実装方針

  • Proxy という仕組みが ES6 から導入されています
  • 掻い摘むと オブジェクトの 様々な挙動 をカスタマイズできる仕組みです
    • 使い方は defineProperty と似ており、監視対象のオブジェクト(ターゲット)に対し、カスタマイズしたい動作(各種 ハンドラ)を定義します。
    • defineProperty との違いは 新しくインスタンス生成した Proxy オブジェクトに対し、操作をしなければならない 点です。

実装1:まずは Proxy の挙動をチェック

  • 以下のようなコードで push(), pop() した際の挙動を見てみましょう

コード

/**
 * 配列オブジェクトの動作を監視します
 * @param {Array} array 監視したい配列
 */
function watchArray(array) {
    // 戻り値として Proxy オブジェクトを返す
    return new Proxy(array, {
        // プロパティ削除時の動作をカスタマイズ
        deleteProperty: (target, property) => {
            console.log('\n** deleteProperty **');
            console.log(' - target: ', target);
            console.log(' - property: ', property);
            return Reflect.deleteProperty(target, property);
        },
        // プロパティ設定時の動作をカスタマイズ
        set: (target, property, val, receiver) => {
            console.log('\n** set **');
            console.log(' - target: ', target);
            console.log(' - val: ', val);
            console.log(' - property: ', property);
            console.log(' - receiver: ', receiver);
            return Reflect.set(target, property, val, receiver);
        }
    });
}

// 適当なオブジェクトを定義
const obj = {
    name: '太郎',
    clubs: ['Baseball', 'Tennis']
};

// 配列プロパティについて監視(NOTE: 戻り値の Proxyオブジェクト に対し操作する)
const watchedObj = watchArray(obj.clubs);

// 実際に変更してみる
console.log('\n■ pop します。');
watchedObj.pop();

console.log('\n■ push します。');
watchedObj.push('Soccer');

console.log('\n■ 値を変更 します。');
watchedObj[0] = 'Swimming';

実行結果

  • 以下の通り出力されました。
■ pop します。

** deleteProperty **
 - target:  [ 'Baseball', 'Tennis' ]
 - property:  1

** set **
 - target:  [ 'Baseball', <1 empty item> ]
 - val:  1
 - property:  length
 - receiver:  [ 'Baseball', <1 empty item> ]

■ push します。

** set **
 - target:  [ 'Baseball' ]
 - val:  Soccer
 - property:  1
 - receiver:  [ 'Baseball' ]

** set **
 - target:  [ 'Baseball', 'Soccer' ]
 - val:  2
 - property:  length
 - receiver:  [ 'Baseball', 'Soccer' ]

■ 値を変更 します。

** set **
 - target:  [ 'Baseball', 'Soccer' ]
 - val:  Swimming
 - property:  0
 - receiver:  [ 'Baseball', 'Soccer' ]

ポイント

  • 配列オブジェクトを指定し Proxy インスタンスを生成し、各種操作は 戻り値の Proxy オブジェクトに対して行います。
  • Reflect クラス は Proxy クラスと同一のメソッド(ただし静的)を持ちます。
    • 上記コードでは 既定の動作とその結果を返す 目的で使われます。
  • 上記 実行結果 を見ることで、内部的にどのように配列オブジェクトに対する操作が行われているかトレースできます。
    • 配列に限らず、任意のオブジェクトに対し Proxy を使えば JavaScript API が内部でどのような動きをしているか もトレースできるかも・・・です
  • 以下の通り、上記の動作をまとめてみました

pop() の動作

  1. 配列の 1 というプロパティに対し deleteProperty が実行される(=インデックスが1 の要素を削除)
  2. 配列の length プロパティに対し set が実行される

    • 値として 1 をセットしています
    • 削除後の配列オブジェクトは [ 'Baseball', <1 empty item> ] となっています。

      • どうやら配列は内部的に delete 演算子で 削除されるため empty item といった結果となる模様?これは以下と同様の結果となります。
      const a = [1, 2, 3];
      // delete 演算子を用いたプロパティ(配列特定要素)削除
      delete a[1];
      console.log(a); // [ 1, <1 empty item>, 3 ]
      

push() の動作

  1. 配列に対し set が実行される(配列の 1 というプロパティ(=インデックスが1 の要素)を削除)
  2. 配列の length プロパティに対し set が実行される

値変更の動作

  1. 配列の 1 というプロパティに対し set が実行される

実装2:配列を監視する

  • 実装1で Proxy を使用した場合の配列操作がなんとなく理解できました
  • 次は実際に配列を監視してみましょう

コード

/**
 * 配列オブジェクトの動作を監視します
 * @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;
        },
    });
}

// 適当なオブジェクトを定義
const obj = {
    name: '太郎',
    clubs: ['Baseball', 'Tennis']
};

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

// 配列プロパティについて監視(NOTE: 戻り値の Proxyオブジェクト に対し操作する)
const watchedObj = watchArray(obj.clubs, onChange);

// 実際に変更してみる
console.log('\n■ pop します。');
watchedObj.pop();

console.log('\n■ push します。');
watchedObj.push('Soccer');

console.log('\n■ 値を変更 します。');
watchedObj[0] = 'Swimming';

実行結果

  • 以下の通り 配列の各種操作に対して 変更前変更後 の配列が取得できています
■ pop します。
[ 'Baseball', 'Tennis' ]
 => [ 'Baseball' ]


■ push します。
[ 'Baseball' ]
 => [ 'Baseball', 'Soccer' ]


■ 値を変更 します。
[ 'Baseball', 'Soccer' ]
 => [ 'Swimming', 'Soccer' ]

問題点

Proxy のパフォーマンス問題

  • かなり前に検証し、詳細は記憶の彼方… のためここに具体的なことが書けませんが、相当数のトラップを貼った際に動作がもっさりしていた記憶があります。。。
  • 参考情報:

shift, unshift 他のメソッド

  • 一番の問題、他のメソッドでも本当にうまくいくのでしょうか。
  • 上記実装2の最後の方を 以下の通り変更します。
console.log('\n■ unshift します。');
watchedObj.unshift('Sumo');
  • 実行結果は以下の通りです。
■ unshift します。
[ 'Swimming', 'Soccer' ]
 => [ 'Swimming', 'Soccer', 'Soccer' ]

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

[ 'Swimming', 'Swimming', 'Soccer' ]
 => [ 'Sumo', 'Swimming', 'Soccer' ]
  • unshift というメソッドは 配列の先頭に値を挿入するメソッドです。
  • 上記の実行結果から、内部的には、おそらく以下のような動作となっていることがわかります。
    1. 末尾の要素をコピーし、末尾の次に追加する
    2. 末尾から先頭に向かって順番に、一つ手前(先頭方向)の要素を現在の要素に上書きコピーする
    3. 先頭要素に、unshift の引数で指定された要素にする
  • 1回の配列操作で何回も変更が検知される ・・・という なかなか痛い問題点 はありますが、検知自体はできています
  • この問題に対する解決方法は 悩み中ですが、物量が多くなったので 次回 は一旦まとめに入ります。
18
13
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
18
13