一. 精確數據綁定
精確數據綁定是指一次數據變化對視圖的影響是可以精確預知的,不需要透過額外的檢查(子樹髒檢查、子樹 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 之類的解法無效
暫無評論,快來發表你的看法吧