依賴追蹤機制是 Vue 的核心之一,那麼依賴追蹤演算法如何工作呢?在 30 行內我們就能實現它?
Reactive 基礎
說起依賴追蹤,就不能不提資料繫結的概念。前端最常見的重複勞動之一就是把資料繫結到 HTML 模板上,這時資料繫結能夠實現資料更新時模板的自動更新。簡單的三行虛擬碼就能描述出這個流程的實際使用場景:
const data = { foo: 123 }
magic(data, dom) // 定義 Reactive 並繫結資料到 dom
data.foo = 456 // 資料更新時 dom 自動更新複製程式碼
這裡的 magic
能夠把普通的 JS 物件轉換為支援資料繫結的 Reactive 物件,在 Reactive 物件資料更新時,被繫結的模板也會進行更新。作為依賴追蹤的基礎,我們還是先用幾行實現一個最簡單的 Reactive 示例吧:
function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
get () {
return val
},
set (newValue) {
// 在此新增更新繫結資料相關程式碼
val = newValue
}
})
}複製程式碼
可以看到,Reactive 本身其實就是通過 Object.defineProperperty
新增了自定義 getter / setter 後的物件。這類物件能夠在讀寫其屬性值時,執行使用者自定的程式碼,從而在此實現被繫結資料的更新。有了這個能力後,我們就可以開始編寫依賴追蹤器了。
依賴追蹤原理
我們可以用 Excel 來理解依賴追蹤:Reactive 就是普通單元格中的原始資料,而 Computed 就是插入了公式的單元格。Reactive 的格子更新時,Computed 的格子會根據為它設定的求和公式(即依賴)來自動更新出相應的值。
所以,對 Computed 最樸素的定義,就是一個簡單的函式,形如這樣:
// 這裡的 data 是一個 Reactive
const isEmpty = () => data.values.length === 0複製程式碼
這初看之下沒有任何 Magic,不過這裡關鍵的細節區別在於:在指定 Excel 的公式時,我們需要手動選擇公式所依賴的單元格,但在一個 Computed 函式中,我們沒有傳入 Computed 的依賴!既然沒有傳入依賴,那麼這個 Computed 函式是怎麼在它所使用的 Reactive 更新時去更新自身的呢?這就是依賴追蹤演算法所需要解決的問題了。
我們知道,Reactive 的資料繫結,本質上是在 set Reactive 時去執行更新。而依賴追蹤則相反,需要在 Computed 中 get Reactive 時,去標記 Computed 對 Reactive 的依賴。
為了理解這個演算法,我們不妨先假設一個簡單的執行場景:假設 Computed 函式 C 依賴了 Reactive 物件 R1 和 R2,這時我們新增一個全域性的輔助物件 D 來為當前 Computed 函式收集依賴。從而,我們可以用文字描述出這個演算法的執行流程:
- Computed 函式 C 初次求值時,標記 D 指向 C
- 對 C 求值過程中,獲取了 R1 和 R2 這兩個 Reactive 的值,使得各 Reactive 的 getter 被觸發
- 為每個 Reactive 維護一個自己的依賴者 deps 陣列,將 D 新增至陣列內。從而,在 C 求值完成後,R1 和 R2 均完成對 C 的依賴收集
- 求值完成後,C 將 D 標記為空,返回求值結果
- 經過標記後的 R1 和 R2 更新時,所有新增至各自 deps 陣列中的 Computed 均在 reactive 的 setter 中觸發,一併更新
這個演算法的核心,就是為 Reactive 新增【依賴者】陣列,從而在 Computed 觸發 Reactive 時,新增該 Computed 至 Reactive 的依賴者中。這樣,在 Reactive 下次更新時,就能夠主動地觸發 Computed 的更新了。下面我們使用程式碼來實現這個文字流程。
實現 Computed
動手實現 Computed 前,我們不妨設計出實際使用場景下一個簡單的 API,然後從 API 介面出發來進行編碼實現。假設我們有一個 elder
物件,他具有 now
這個 Reactive 來標記當前年份,那麼我們可以定義出一個 Computed 來計算出他的年齡:
const elder = {}
defineReactive(elder, 'now', null)
defineComputed(elder, 'age', () => elder.now - 1926,
() => console.log('Now his age is', elder.age)
)
elder.now = 2016
console.log(elder.age)
elder.now = 2017
console.log(elder.age)複製程式碼
在使用方式上,可以發現我們先是定義 Reactive,再定義從 Reactive 衍生出的 Computed 函式。
接下來就是程式碼實現了。我們在前文的 defineReactive 函式基礎上,擴充出新的 defineComputed 函式。去除掉囉嗦的註釋後,是可以控制在 30 行內的?
// 標記當前正在求值的 computed 函式
let Dep = null
// 定義 computed,需傳入求值函式與 computed 更新時觸發的回撥
function defineComputed (obj, key, computeFn, updateCallback) {
// 封裝供 reactive 收集的更新回撥,以觸發 computed 的更新事件
const onDependencyUpdated = function () {
// 在此呼叫 computeFn 計算出的值用於觸發 computed 的更新事件
// 供後續可能的 watch 等模組使用
const value = computeFn()
updateCallback(value)
}
Object.defineProperty(obj, key, {
get () {
// 標記當前依賴,供 reactive 收集
Dep = onDependencyUpdated
// 呼叫求值函式,中途收集依賴
const value = computeFn()
// 完成求值後,清空標記
Dep = null
// 最終返回的 getter 結果
return value
},
// 計算屬性無法 set
set () {}
})
}
// 通過 getter 與 setter 定義出一個 reactive
function defineReactive (obj, key, val) {
// 在此標記哪些 computed 依賴了該 reactive
const deps = []
Object.defineProperty(obj, key, {
// 為 reactive 求值時,收集其依賴
get () {
if (Dep) deps.push(Dep)
// 返回 val 值作為 getter 求值結果
return val
},
// 為 reactive 賦值時,更新所有依賴它的計算屬性
set (newValue) {
// 在 setter 中更新值
val = newValue
// 更新值後觸發所有 computed 依賴更新
deps.forEach(changeFn => changeFn())
}
})
}複製程式碼
在上例中的程式碼實現中,我們除了實現了一個新的 defineComputed 函式外,還在 defineReactive 函式中進行了一定的修改。這主要體現在,我們在 Reactive 中:
- 新增了 deps 陣列
- 在 getter 中進行了依賴收集
- 在 setter 中計算出了所有依賴該 Reactive 的 Computed
這個在 getter 中收集依賴,而後在 setter 中觸發的模式,實際就是本系列中第一篇 MVC 框架介紹中,所涉及的 PubSub 釋出訂閱模式了。不同之處在於,在依賴收集器中,我們通過 Object.defineProperty 這一高階特性將 PubSub 模式進行了封裝,PubSub 中需要使用者顯式操作的【訂閱】過程被平滑地優化為了【通過 getter 自動化進行的依賴收集】。依賴收集完成後,就能在 Reactive 更新時實現依賴追蹤了。
OK,這就是依賴追蹤器的基礎實現了,本文的原始碼亦託管在 Github 上,可以拉取或直接複製到 Node 中執行?希望對感興趣的同學有所幫助。
本系列後續會繼續專注用簡單的程式碼解釋前端框架各類 Magic 的實現機制,安利往期文章: