跳到主要內容
黯羽輕揚每天積累一點點

執行階段相依性收集機制

免費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>

視圖結構中有 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 之類的解法無效

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論