运行时依赖收集机制

一.精确数据绑定

精确数据绑定是指一次数据变化对视图的影响是可以精确预知的,不需要通过额外的检查(子树脏检查、子树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>

视图结构中有2处依赖data.counter,分别是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树上。它们都不是精确数据绑定,因为数据与视图的映射关系在数据变化发生之前是未知的

想办法确定数据与视图之间的依赖关系,就是依赖收集的过程,是精确数据绑定的前提和基础

二.依赖收集

依赖收集分为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.className依赖classA这个信息后,classA发生变化时,才能根据依赖关系来更新span.className

那么问题是如何在运行时收集依赖

spanclass表达式getClass()求值过程中,访问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种解法:

  • flag拿到data里作为响应式数据

  • 更新依赖的数据(self.myValue = 'z'),触发重新求值

从运行时依赖收集的角度来看,在第一次计算myComputed时(计算初始视图时),得到依赖关系:

$div.textContent - myComputed - myValue

这个关系一经确定就无法再改变,那么除非myValue变了,否则不会对myComputed重新求值,所以有了改myValue触发重新求值的解法

另一方面,既然flag的变化会影响视图,那么干脆把flag也作为myComputed的数据依赖,这就是把flag拿到data里的原因

P.S.缓存确实有一份,在赋值时setter会做脏检查,如果新值与缓存值完全相同,就不触发依赖项的重新计算,所以self.myValue = self.myValue之类的解法无效

参考资料

运行时依赖收集机制》上有1条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code