收藏好這篇,別再只說“資料劫持”了

範文傑發表於2018-05-08

最近接觸了一些面試者,當我問起“Vue 如何實現資料雙向繫結”時,會脫口而出“資料劫持”,然後呢?然後就沒有然後了╮(╯_╰)╭。確實,“資料劫持”是基礎,但遠不是面試官想聽到的答案,不如花個十分鐘看看本文,下次照著回答就好了

“雙向繫結” 本身

要解答問題,首先要理解問題: 資料雙向繫結 是一種模式,web語境下一般指資料從dom到JS物件之間的自動同步。DOM 與 JS 被隔離在兩個不同的執行時上,互相之間需要通過命令式的 DOM介面 溝通:DOM 需要正確觸發事件,將資訊傳輸給JS程式;而JS也需要在狀態變更後,有意識地呼叫適當的介面,改變DOM內容。這種方式會引起兩個問題:

  1. 狀態的管理與展現是完全剝離開的兩套不同邏輯,需要刻意保持同步,這是很高的開發成本
  2. DOM 規範定義了不少介面,而且有相容性問題,這是很高的學習成本

雙向繫結通過各種各樣的設計,將資料從 DOM 到 JS 或者從 JS 到 DOM 的同步過程,封裝在框架本身,上層程式碼脫離了對底層介面的依賴,只需要關注狀態管理邏輯。

Object.defineProperty

我們要討論的第一個問題是,如何檢測 JS 物件屬性發生的變更?最簡單粗暴的方法是“髒檢查”,舊版本的Angular就是用的這種方法,在各種可能引發狀態變更的事件後,啟動一次髒檢查。這種方法很直觀,但實現上需要考慮很多問題:

  1. 髒檢查過程中可能會觸發新的資料變更,也就進入死迴圈了
  2. 髒檢查的實現必須保留新舊兩份資料的副本
  3. 髒檢查必須預知所有可能觸發狀態變更的時機,也就意味著需要對部分原生介面進行包裹(包括 setTimeoutrequestNextAnimationFrame等)
  4. ...

Vue 則採用超程式設計介面 Object.defineProperty 實現的。 在元件初始化,會呼叫該介面,將物件屬性包裝為getset函式,將程式碼“埋入”屬性是“獲取”、“修改”行為中。看個簡單例子,直觀感受下:

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:

    1. 呼叫 initComputedcomputed 屬性轉化為 watcher 例項
    2. 呼叫 initWatch 方法,將 watch 配置轉化為 watcher 例項
    3. 呼叫 mountComponent 方法,為 render 函式繫結 watcher 例項
  • 第三步,狀態變更後,觸發 dep.notify() 函式,該函式再進一步觸發 Watcher 物件 update 函式,執行watcher的重新計算。

對應下圖:

data.png

注意,Vue 元件中的 render 函式,我們可以單純將其視為一種特殊的 computed 函式,在它所對應的 Watcher 物件發生變化時,觸發執行render,生成新的 virutal-dom 結構,再交由 Vue 做diff,更新檢視。

結語

本文到這裡就結束了,更多內容可以嘗試看看原始碼,程式碼裡面的設計模式非常值得學習。

Vue 使用資料劫持作為底層支撐,又設計了一套精妙的依賴管理方案解耦依賴。但資料劫持方案也有其難以解決的痛點:

  1. 只能應用於簡單物件
  2. 對陣列無效,所以需要包裝陣列方法
  3. 屬性劫持的出發點是“變”,所以vue無法很好接入“immutable”模式

相關文章