最近接觸了一些面試者,當我問起“Vue 如何實現資料雙向繫結”時,會脫口而出“資料劫持”,然後呢?然後就沒有然後了╮(╯_╰)╭。確實,“資料劫持”是基礎,但遠不是面試官想聽到的答案,不如花個十分鐘看看本文,下次照著回答就好了
“雙向繫結” 本身
要解答問題,首先要理解問題: 資料雙向繫結 是一種模式,web語境下一般指資料從dom到JS物件之間的自動同步。DOM 與 JS 被隔離在兩個不同的執行時上,互相之間需要通過命令式的 DOM介面 溝通:DOM 需要正確觸發事件,將資訊傳輸給JS程式;而JS也需要在狀態變更後,有意識地呼叫適當的介面,改變DOM內容。這種方式會引起兩個問題:
- 狀態的管理與展現是完全剝離開的兩套不同邏輯,需要刻意保持同步,這是很高的開發成本
- DOM 規範定義了不少介面,而且有相容性問題,這是很高的學習成本
雙向繫結通過各種各樣的設計,將資料從 DOM 到 JS 或者從 JS 到 DOM 的同步過程,封裝在框架本身,上層程式碼脫離了對底層介面的依賴,只需要關注狀態管理邏輯。
Object.defineProperty
我們要討論的第一個問題是,如何檢測 JS 物件屬性發生的變更?最簡單粗暴的方法是“髒檢查”,舊版本的Angular就是用的這種方法,在各種可能引發狀態變更的事件後,啟動一次髒檢查。這種方法很直觀,但實現上需要考慮很多問題:
- 髒檢查過程中可能會觸發新的資料變更,也就進入死迴圈了
- 髒檢查的實現必須保留新舊兩份資料的副本
- 髒檢查必須預知所有可能觸發狀態變更的時機,也就意味著需要對部分原生介面進行包裹(包括
setTimeout
、requestNextAnimationFrame
等) - ...
Vue 則採用超程式設計介面 Object.defineProperty
實現的。 在元件初始化,會呼叫該介面,將物件屬性包裝為get
、set
函式,將程式碼“埋入”屬性是“獲取”、“修改”行為中。看個簡單例子,直觀感受下:
const person = {};
// 嘿嘿,誰都改不了我的名字
Object.defineProperty(person, 'name', {
get() {
return 'van';
},
set(v) {
console.log('they want change my name');
}
});
console.log(person.name);
// van
person.name = 'tec';
// they want change my name
console.log(person.name);
// van
複製程式碼
依賴管理方案
Object.defineProperty
只是解決了狀態變更後,如何觸發通知的問題,那要通知誰呢?誰會關心那些屬性發生了變化呢?在 Vue 中,使用 Dep 解耦了依賴者與被依賴者之間關係的確定過程。簡單來說:
-
第一步,通過 Observer 提供的介面,遍歷狀態物件,給物件的每個屬性、子屬性都繫結了一個專用的
Dep
物件。這裡的狀態物件主要指元件當中的data
屬性。 -
第二步,建立三中型別的watcher:
- 呼叫 initComputed 將
computed
屬性轉化為watcher
例項 - 呼叫 initWatch 方法,將
watch
配置轉化為watcher
例項 - 呼叫 mountComponent 方法,為
render
函式繫結watcher
例項
- 呼叫 initComputed 將
-
第三步,狀態變更後,觸發
dep.notify()
函式,該函式再進一步觸發 Watcher 物件update
函式,執行watcher的重新計算。
對應下圖:
注意,Vue 元件中的 render
函式,我們可以單純將其視為一種特殊的 computed
函式,在它所對應的 Watcher
物件發生變化時,觸發執行render,生成新的 virutal-dom 結構,再交由 Vue 做diff,更新檢視。
結語
本文到這裡就結束了,更多內容可以嘗試看看原始碼,程式碼裡面的設計模式非常值得學習。
Vue 使用資料劫持作為底層支撐,又設計了一套精妙的依賴管理方案解耦依賴。但資料劫持方案也有其難以解決的痛點:
- 只能應用於簡單物件
- 對陣列無效,所以需要包裝陣列方法
- 屬性劫持的出發點是“變”,所以vue無法很好接入“immutable”模式