一.精确数据绑定
精确数据绑定是指一次数据变化对视图的影响是可以精确预知的,不需要通过额外的检查(子树脏检查、子树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
,分别是span
的class
和文本内容,那么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
那么问题是如何在运行时收集依赖?
对span
的class
表达式getClass()
求值过程中,访问data.classA
时,会触发data
的getter
,此时执行上下文是app.getClass
,那么就得到了data.classA
与span
的class
属性有关,并且关系为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
之类的解法无效
学习