Skip to main content

Runtime Dependency Collection Mechanism

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

Thoughts triggered by Vuex mapState

I. Precise Data Binding

Precise data binding means that the impact of a single data change on the view can be accurately predicted without the need for additional checks (like subtree dirty checking or subtree diffing) to further confirm it.

Let's divide the application structure into two layers:

View Layer
---
Data Layer

Data binding is the establishment of a connection between the data layer and the view layer (two-way data binding also requires a reverse connection), which means finding the mapping relationship from data to view: view = f(data). Precise data binding is fine-grained; an atomic-level data update should correspond to an atomic-level view update. For example:

<!-- View Structure -->
<div id="app">
    <span bind:class="counter % 2 === 0 ? 'even' : 'odd'">{{counter}}</span>
</div>
// Initial Data
app.data = {
    counter: 0,
    other: {
        /*...*/
    }
};
<!-- Initial View -->
<div id="app">
    <span class="even">0</span>
</div>

There are two places in the view structure that depend on data.counter: the class of the span and its text content. When data.counter changes, these two places should be recalculated directly, and the view update operation should be performed:

// Data Update
data.counter++;
// Corresponding view update operations
$span.className = eval("counter % 2 === 0 ? 'even' : 'odd'");
$span.textContent = eval("counter");
<!-- Updated View -->
<div id="app">
    <span class="odd">1</span>
</div>

Such view updates are very accurate; once the data changes, all expressions depending on that data are immediately re-evaluated, and the new values are synchronized to the view layer. To achieve this level of accurate updating, one must find the fine-grained precise dependency relationships in advance, similar to:

data.counter has 2 places depending on this data item, namely
    $span.className with relationship f = counter % 2 === 0 ? 'even' : 'odd'
    $span.textContent with relationship f = counter

If such precise dependency relationships cannot be found in advance, precise updates cannot be achieved, and it's not considered precise data binding. For example, angular needs to recalculate all properties under the component-level $scope and compare them with the previous values to determine which part of the view needs to be updated; react requires downward recalculation at the component level and performing state diffing to find the appropriate view update operations before applying them as patches to the real DOM tree. Neither of these is precise data binding because the mapping between data and view is unknown before the data change occurs.

Finding a way to determine the dependency relationship between data and view is the process of dependency collection, which is the prerequisite and foundation of precise data binding.

II. Dependency Collection

Dependency collection is divided into two parts: compile-time and runtime. The former discovers dependencies through static checking (code scanning), while the latter determines dependency relationships through the execution of code snippets based on the runtime context.

Compile-time Dependency Collection

Discovering dependencies by scanning code, such as the simplest pattern matching (or more powerful syntax tree analysis):

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
        });
    }
}

This gives us the dependency relationships deps:

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

This approach is relatively simple, but for complex scenarios like expressions, collecting dependencies through regex matching becomes somewhat unrealistic. For example:

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

In conditional scenarios supporting expressions, the dependency relationship cannot be determined at compile-time. Therefore, one typically either gives up supporting such features or gives up precise data binding. react chooses to give up precise data binding in exchange for the powerful feature of JSX templates supporting arbitrary JS expressions.

Actually, there is a third choice: you can have your cake and eat it too.

Runtime Dependency Collection

For examples like the conditional class above, where dependencies cannot be obtained through static checking, they can only be determined at runtime through the execution environment.

The example above is equivalent to:

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

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

To know whether the data dependency for span.className is classA or classB, the expression must be evaluated, i.e., executing app.getClass(). Once the information that span.className depends on classA is obtained, span.className can be updated based on the dependency relationship when classA changes.

So the question is, how to collect dependencies at runtime?

During the evaluation of the span class expression getClass(), when data.classA is accessed, the getter of data will be triggered. At this time, the execution context is app.getClass, so it is determined that data.classA is related to the class attribute of the span, and the relationship is f=app.getClass.

A simulated scenario is as follows:

// 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;
    }
};

First, attach getter&setter to the data properties to act as the 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]();
                }
            })
        }
    })
}

Then initialize the view and evaluate the expression, while triggering the getter to collect dependencies:

// init view
let deps = [];

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

This will produce the following output, showing that the dependency was successfully collected at runtime:

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

Then modify the data, and the setter will initiate a re-evaluation and update the view:

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

We get the following log, showing that the view was updated automatically and successfully:

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

During this process, classB was not checked or evaluated. The data update -> view update process involves no redundant operations; it is very precise.

Relying on such a dynamic dependency collection mechanism, templates can support arbitrary JS expressions while achieving precise data binding.

P.S. Of course, the implementation above is only the core part. A runtime dependency collection mechanism must also consider:

  • Sub-dependencies (one computed property depending on another computed property)

  • Dependency maintenance (dynamic addition/destruction)

At any given moment, there is only one execution context (which can be used as a global target), but scenarios with sub-dependencies involve nested execution contexts, so a context stack (targetStack) needs to be manually maintained, pushing onto the stack before computed property evaluation and popping after completion.

III. Dependency Collection and Caching

There is a classic Vue example:

<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']
        }
    }
})

After 2 seconds, flag is set to 2, but myComputed is not automatically re-evaluated, and the view does not change.

It seems like myComputed is cached internally, and even after flag is changed, the cached value is still used. In fact, this is determined by the runtime dependency collection mechanism and has nothing to do with the caching mechanism. Two solutions can be easily identified:

  • Move flag into data to make it reactive data.

  • Update the dependent data (self.myValue = 'z') to trigger a re-evaluation.

From the perspective of runtime dependency collection, when myComputed is first calculated (when calculating the initial view), the following dependency relationship is established:

$div.textContent - myComputed - myValue

Once established, this relationship cannot be changed. Therefore, unless myValue changes, myComputed will not be re-evaluated. Hence the solution of modifying myValue to trigger a re-evaluation.

On the other hand, since a change in flag will affect the view, we might as well make flag a data dependency of myComputed. This is the reason for moving flag into data.

P.S. There is indeed a cache; during assignment, the setter will perform a dirty check. If the new value is identical to the cached value, the recalculation of dependencies will not be triggered. Therefore, solutions like self.myValue = self.myValue are ineffective.

References

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment