メインコンテンツへ移動

実行時依存関係収集メカニズム

無料2017-07-09#JS#精确数据绑定#vue依赖收集#精确依赖收集#vue dep collection

Vuex mapStateから生まれた考察

一. 精緻なデータバインディング

精緻なデータバインディングとは、一度のデータの変化がビューに与える影響を正確に予知できることを指します。追加のチェック(サブツリーのダーティチェックやサブツリーのdiff)を行ってさらに確認する必要がありません。

アプリケーションの構造を以下の2つの層に分けて考えてみましょう:

ビュー層
---
データ層

データバインディングとは、データ層とビュー層の繋がりを確立することであり(双方向データバインディングのシナリオでは逆方向の繋がりも求められます)、つまりデータからビューへのマッピング関係 view = f(data) を見つけ出すことです。精緻なデータバインディングは細粒度であり、原子レベルのデータ更新は原子レベルのビュー更新に対応する必要があります。例えば:

<!-- 视图结构 -->
<div id="app">
    <span bind:class="counter % 2 === 0 ? 'even' : 'odd'">{{counter}}</span>
</div>
// 初始数据
app.data = {
    counter: 0,
    other: {
        /*...*/
    }
};
<!-- 初始视图 -->
<div id="app">
    <span class="even">0</span>
</div>

ビュー構造の中には data.counter に依存している箇所が2つあります。spanclass とテキストコンテンツです。data.counter が変化したとき、これら2つの箇所を直接再計算し、ビューの更新操作を行うべきです:

// 数据更新
data.counter++;
// 对应的视图更新操作
$span.className = eval("counter % 2 === 0 ? 'even' : 'odd'");
$span.textContent = eval("counter");
<!-- 更新后的视图 -->
<div id="app">
    <span class="odd">1</span>
</div>

このようなビュー更新は非常に正確です。データの変更を検知すると直ちに、そのデータに依存している各式の値を再評価し、新しい値をビュー層に同期します。このレベルの正確な更新を実現するには、あらかじめ細粒度で正確な依存関係を見つけ出しておく必要があります。イメージとしては以下の通りです:

data.counter このデータ項目に依存している箇所が2つあります:
    $span.className 関係式 f=counter % 2 === 0 ? 'even' : 'odd'
    $span.textContent 関係式 f=counter

このような正確な依存関係をあらかじめ見つけ出せなければ、正確な更新はできず、精緻なデータバインディングとは言えません。例えば、angular はコンポーネントレベルの $scope 配下のすべての属性を再計算し、前後で変化があったかどうかを比較することで、ビューのどの部分を更新すべきかを確定させる必要があります。react はコンポーネントレベルで下方向への再計算を行い、状態の diff を取ることで初めて適切なビュー更新操作を見つけ出し、それをパッチとして実際のDOMツリーに適用します。これらは、データが変化する前にデータとビューのマッピング関係が未知であるため、精緻なデータバインディングではありません。

データとビューの間の依存関係を確定させる方法を考えること、それが*依存関係の収集(Dependency Collection)*のプロセスであり、精緻なデータバインディングの前提であり基礎となります。

二. 依存関係の収集

依存関係の収集は、コンパイル時と実行時の2つの部分に分けられます。前者は静的チェック(コードスキャン)によって依存関係を発見し、後者はコード断片を実行して実行時のコンテキストに基づいて依存関係を確定させます。

コンパイル時の依存関係収集

コードをスキャンして依存関係を発見します。例えば、最も単純なパターンマッチング(あるいはより強力な構文木分析)です:

let view = '<span>{{counter}}</span>';

const REGS = {
    textContent: /<([^>\s]+).*>\s*{{([^}]*)}}\s*<\/\1>/gm
};

let deps = [];
for (let key in REGS) {
    let match = REGS[key].exec(view);
    if (match) {
        deps.push({
            data: match[2],
            view: match[1],
            rel: key
        });
    }
}

これにより、依存関係 deps が得られます:

[{
    data: "counter",
    rel: "textContent",
    view: "span"
}]

この方法は比較的単純ですが、式などの複雑なシナリオでは、正規表現マッチングだけで依存関係を収集するのは現実的ではありません。例えば:

<span bind:class="10 % 2 === 0 ? classA : classB">conditional class</span>

式の条件分岐をサポートするシナリオでは、コンパイル時に依存関係を確定させることは不可能です。そのため、通常はこのような機能のサポートを諦めるか、精緻なデータバインディングを諦めるかのどちらかになります。react は精緻なデータバインディングを諦める代わりに、JSXテンプレートで任意のJS式をサポートするという強力な機能を手に入れました。

しかし、実は第三の選択肢があります。両方のいいとこ取りができるのです。

実行時の依存関係収集

上記の条件付き class の例のように、静的チェックで依存関係を得られない場合は、実行時に実行環境を通じて確定させるしかありません。

上の例は以下と同等です:

<span bind:class="getClass()">conditional class</span>

app.getClass = () => 10 % 2 === 0 ? app.data.classA : app.data.classB;

span.className のデータ依存先が classA なのか classB なのかを知るには、式を評価する必要があります。つまり app.getClass() を実行します。span.classNameclassA に依存しているという情報が得られれば、classA が変化したときに、その依存関係に基づいて span.className を更新できます。

では、問題はどのようにして実行時に依存関係を収集するかです。

spanclassgetClass() を評価する過程で、data.classA にアクセスしたとき、datagetter がトリガーされます。この時の実行コンテキストは app.getClass ですから、data.classAspanclass 属性に関係しており、その関係が f=app.getClass であることが分かります。

シナリオのシミュレーションは以下の通りです:

// view
let spanClassName = {
    value: '',
    computedKey: 'getClass'
};

// data
let app = {
    data: {
        classA: 'a',
        classB: 'b'
    },
    getClass() {
        return 10 % 2 === 0 ? app.data.classA : app.data.classB;
    }
};

まず、データ属性に getter&setter を付与し、Subjectとして機能させます:

// attach getter&setter to app.data
for (let key in app.data) {
    let value = app.data[key];
    Object.defineProperty(app.data, key, {
        enumerable: true,
        configurable: true,
        get() {
            console.log(`${key} was accessed`);
            if (deps.length === 0) {
                console.log(`dep collected`);
                deps.push({
                    data: key,
                    view: view,
                    rel: computedKey
                });
            }
            return value;
        },
        set(newVal) {
            value = newVal;
            console.log(`${key} changed to ${value}`);
            deps.forEach(dep => {
                if (dep.data === key) {
                    console.log(`reeval ${dep.rel} and update view`);
                    dep.view.value = app[dep.rel]();
                }
            })
        }
    })
}

次にビューを初期化し、式を評価すると同時に getter をトリガーして依存関係を収集します:

// init view
let deps = [];

let view = spanClassName;
let computedKey = view.computedKey;
let initValue = app[computedKey]();
view.value = initValue;
console.log(view);

このとき、以下の出力が得られ、実行時に依存関係の収集に成功したことが分かります:

classA was accessed
dep collected
Object {value: "a", computedKey: "getClass"}

続いてデータを修正すると、setter が再評価を発行し、ビューを更新します:

// update data
app.data.classA = 'newA';
// view updated automaticly
console.log(spanClassName);

以下のログが得られ、ビューの自動更新が成功したことが分かります:

classA changed to newA
reeval getClass and update view
classA was accessed
Object {value: "newA", computedKey: "getClass"}

この過程で classB に対するチェックや評価は行われていません。データの更新 -> ビューの更新 のプロセスに無駄な操作がなく、非常に正確です。

このような動的な依存関係収集メカニズムに頼ることで、テンプレートは任意のJS式をサポートできるようになり、かつ精緻なデータバインディングを実現できます。

P.S. もちろん、上記のコードはコア部分のみを実装したものです。実行時の依存関係収集メカニズムでは、少なくとも以下の点も考慮する必要があります:

  • 子依存関係(ある算出プロパティが別の算出プロパティに依存している場合)
  • 依存関係の維持(動的な追加/破棄)

同一時刻には必ず一つの実行コンテキスト(グローバルな target と見なせます)しか存在しませんが、子依存関係のシナリオでは入れ子になった実行コンテキストが存在するため、コンテキストスタック(targetStack)を手動で維持し、算出プロパティの評価に入る前にプッシュし、評価が終わったらポップする必要があります。

三. 依存関係の収集とキャッシュ

Vueの非常にクラシックな例があります:

<div id="app">
    <div>{{myComputed}}</div>
</div>

let flag = 1;
var runs = 0;
var vm = new Vue({
    el: "#app",
    data: {
        myValue: 'x',
        myOtherValue: 'y'
    },
    computed: {
        myComputed: function() {
            runs++;
            console.log("This function was called " + runs + " times");
            
            // update flag
            let self = this;
            setTimeout(function() {
                flag = 2;
                console.log('flag changed to ' + flag);
                // self.myValue = 'z';
            }, 2000)

            if (flag == 1)
                return this['my' + 'Value']
            else
                return this['my' + 'Other' + 'Value']
        }
    }
})

2秒後に flag = 2 としても、myComputed は自動的に再評価されず、ビューも変化しません。

内部で myComputed がキャッシュされており、flag を変えてもキャッシュされた値が使われ続けているように見えますが、実際には実行時の依存関係収集メカニズムによって決定されており、キャッシュメカニズムとは関係ありません。簡単に2つの解決策が見つかります:

  • flagdata の中に持っていき、リアクティブなデータにする
  • 依存しているデータを更新し(self.myValue = 'z')、再評価をトリガーする

実行時の依存関係収集の観点から見ると、myComputed を最初に計算したとき(初期ビューの計算時)、以下の依存関係が得られます:

$div.textContent - myComputed - myValue

この関係は一度確定すると変えることができません。そのため、myValue が変わらない限り、myComputed が再評価されることはありません。これが、myValue を変えて再評価をトリガーする解決策の理由です。

一方で、flag の変化がビューに影響を与えるのであれば、いっそのこと flagmyComputed のデータ依存関係に含めてしまえばいいのです。これが flagdata に移す理由です。

P.S. キャッシュは確かに存在します。代入時に setter がダーティチェックを行い、新しい値がキャッシュされた値と完全に同じであれば、依存項目の再計算をトリガーしません。そのため、self.myValue = self.myValue といった解決策は無効です。

参考資料

コメント

コメントはまだありません

コメントを書く