我們只會在 Observer 類 和 defineReactive 函數中實例化 dep。在 getter 方法中依賴收集,在 setter 方法中派發更新通知 ...
觀察者模式定義了對象間一對多的依賴關係。即被觀察者狀態發生變動時,所有依賴於它的觀察者都會得到通知並自動更新。解決了主體對象和觀察者之間功能的耦合。
Vue中基於 Observer、Dep、Watcher 三個類實現了觀察者模式
- Observer類 負責數據劫持,訪問數據時,調用
dep.depend()
進行依賴收集;數據變更時,調用dep.notify()
通知觀察者更新視圖。我們的數據就是被觀察者 - Dep類 負責收集觀察者 watcher,以及通知觀察者 watcher 進行 update 更新操作
- Watcher類 為觀察者,負責訂閱 dep,併在訂閱時讓 dep 同步收集當前 watcher。當接收到 dep 的通知時,執行 update 重新渲染視圖
dep 和 watcher 是一個多對多的關係。每個組件都對應一個渲染 watcher,每個響應式屬性都有一個 dep 收集器。一個組件可以包含多個屬性(一個 watcher 對應多個 dep),一個屬性可以被多個組件使用(一個 dep 對應多個 watcher)
Dep
我們需要給每個屬性都增加一個 dep 收集器,目的就是收集 watcher。當響應式數據發生變化時,更新收集的所有 watcher
- 定義 subs 數組,當劫持到數據訪問時,執行
dep.depend()
,通知 watcher 訂閱 dep,然後在 watcher內部執行dep.addSub()
,通知 dep 收集 watcher - 當劫持到數據變更時,執行
dep.notify()
,通知所有的觀察者 watcher 進行 update 更新操作
Dep有一個靜態屬性 target,全局唯一,Dep.target 是當前正在執行的 watcher 實例,這是一個非常巧妙的設計!因為在同一時間只能有一個全局的 watcher
註意:
渲染/更新完畢後我們會立即清空 Dep.target,保證了只有在模版渲染/更新階段的取值操作才會進行依賴收集。之後我們手動進行數據訪問時,不會觸發依賴收集,因為此時 Dep.target 已經重置為 null
let id = 0
class Dep {
constructor() {
this.id = id++
// 依賴收集,收集當前屬性對應的觀察者 watcher
this.subs = []
}
// 通知 watcher 收集 dep
depend() {
Dep.target.addDep(this)
}
// 讓當前的 dep收集 watcher
addSub(watcher) {
this.subs.push(watcher)
}
// 通知subs 中的所有 watcher 去更新
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
// 當前渲染的 watcher,靜態變數
Dep.target = null
export default Dep
Watcher
不同組件有不同的 watcher。我們先只需關註渲染watcher。計算屬性watcer和監聽器watcher後面會單獨講!
watcher 負責訂閱 dep ,併在訂閱的同時執行dep.addSub()
,讓 dep 也收集 watcher。當接收到 dep 發佈的消息時(通過 dep.notify()
),執行 update 重新渲染
當我們初始化組件時,在 mountComponent 方法內會實例化一個渲染 watcher,其回調就是 vm._update(vm._render())
import Watcher from './observe/watcher'
// 初始化元素
export function mountComponent(vm, el) {
vm.$el = el
const updateComponent = () => {
vm._update(vm._render())
}
// true用於標識是一個渲染watcher
const watcher = new Watcher(vm, updateComponent, true)
}
當我們實例化渲染 watcher 的時候,在構造函數中會把回調賦給this.getter
,並調用this.get()
方法。
這時!!!我們會把當前的渲染 watcher 放到 Dep.target 上,併在執行完回調渲染視圖後,立即清空 Dep.target,保證了只有在模版渲染/更新階段的取值操作才會進行依賴收集
import Dep from './dep'
let id = 0
class Watcher {
constructor(vm, fn) {
this.id = id++
this.getter = fn
this.deps = [] // 收集當前 watcher 對應被觀察者屬性的 dep
this.depsId = new Set()
this.get()
}
// 收集 dep
addDep(dep) {
let id = dep.id
// 去重,一個組件 可對應 多個屬性 重覆的屬性不用再次記錄
if (!this.depsId.has(id)) {
this.deps.push(dep)
this.depsId.add(id)
dep.addSub(this) // watcher已經收集了去重後的 dep,同時讓 dep也收集 watcher
}
}
// 執行 watcher 回調
get() {
Dep.target = this // Dep.target 是一個靜態屬性
this.getter() // 執行vm._render時,會劫持到數據訪問,調用 dep.depend() 進行依賴收集
Dep.target = null // 渲染完畢置空,保證了只有在模版渲染階段的取值操作才會進行依賴收集
}
// 重新渲染
update() {
this.get()
}
}
我們是如何觸發依賴收集的呢?
在執行this.getter()
回調時,我們會調用vm._render()
,在_s()
方法中會去 vm 上取值,這時我們劫持到數據訪問走到 getter,進而執行dep.depend()
進行依賴收集
流程:vm._render()
->vm.$options.render.call(vm)
-> with(this){ return _c('div',null,_v(_s(name))) }
-> 會去作用域鏈 this 上取 name
在 MDN 中是這樣描述 with 的
JavaScript 查找某個未使用命名空間的變數時,會通過作用域鏈來查找,作用域鏈是跟執行代碼的 context 或者包含這個變數的函數有關。'with'語句將某個對象添加到作用域鏈的頂部,如果在 statement 中有某個未使用命名空間的變數,跟作用域鏈中的某個屬性同名,則這個變數將指向這個屬性值
Observer
我們只會在 Observer 類 和 defineReactive 函數中實例化 dep。在 getter 方法中執行dep.depend()
依賴收集,在 setter 方法中執行dep.notity()
派發更新通知
依賴收集
依賴收集的入口就是在Object.defineProperty
的 getter 中,我們重點關註2個地方,一個是在我們實例化 dep 的時機,另一個是為什麼遞歸依賴收集。我們先來看下代碼
class Observer {
constructor(data) {
// 給數組/對象的實例都增加一個 dep
this.dep = new Dep()
// data.__ob__ = this 給數據加了一個標識 如果數據上有__ob__ 則說明這個屬性被觀測過了
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false, // 將__ob__ 變成不可枚舉
})
if (Array.isArray(data)) {
// 重寫可以修改數組本身的方法 7個方法
data.__proto__ = newArrayProto
this.observeArray(data)
} else {
this.walk(data)
}
}
// 迴圈對象"重新定義屬性",對屬性依次劫持,性能差
walk(data) {
Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}
// 觀測數組
observeArray(data) {
data.forEach(item => observe(item))
}
}
// 深層次嵌套會遞歸處理,遞歸多了性能就差
function dependArray(value) {
for (let i = 0; i < value.length; i++) {
let current = value[i]
current.__ob__ && current.__ob__.dep.depend()
if (Array.isArray(current)) {
dependArray(current)
}
}
}
export function defineReactive(target, key, value) {
// 深度屬性劫持;給所有的數組/對象的實例都增加一個 dep,childOb.dep 用來收集依賴
let childOb = observe(value)
let dep = new Dep() // 每一個屬性都有自己的 dep
Object.defineProperty(target, key, {
get() {
// 保證了只有在模版渲染階段的取值操作才會進行依賴收集
if (Dep.target) {
dep.depend() // 依賴收集
if (childOb) {
childOb.dep.depend() // 讓數組/對象實例本身也實現依賴收集,$set原理
if (Array.isArray(value)) { // 數組需要遞歸處理
dependArray(value)
}
}
}
return value
},
set(newValue) { ... },
})
}
實例化 dep 的時機
我們只會在 Observer 類 和 defineReactive 函數中實例化 dep
- Observer類:在 Observer 類中實例化 dep,可以給每個數組/對象的實例都增加一個 dep
- defineReactive函數:在 defineReactive 方法中實例化 dep,可以讓每個被劫持的屬性都擁有一個 dep,這個 dep 是被閉包讀取的局部變數,會駐留到記憶體中且不會污染全局
我們為什麼要在 Observer 類中實例化 dep?
- Vue 無法檢測通過數組索引改變數組的操作,這不是 Object.defineProperty() api 的原因,而是尤大認為性能消耗與帶來的用戶體驗不成正比。對數組進行響應式檢測會帶來很大的性能消耗,因為數組項可能會大,比如10000條
- Object.defineProperty() 無法監聽數組的新增
如果想要在通過索引直接改變數組成員或對象新增屬性後,也可以派發更新。那我們必須要給數組/對象實例本身增加 dep 收集器,這樣就可以通過 xxx.__ob__.dep.notify()
手動觸發 watcher 更新了
這其實就是 vm.$set 的內部原理!!!
遞歸依賴收集
數組中的嵌套數組/對象沒辦法走到 Object.defineProperty,無法在 getter 方法中執行dep.depend()
依賴收集,所以需要遞歸收集
舉個慄子:data: {arr: ['a', 'b', ['c', 'd', 'e', ['f', 'g']], {name: 'libc'}]}
我們可以劫持 data.arr,並觸發 arr 實例上的 dep 依賴收集,然後迴圈觸發 arr 成員的 dep依賴收集。對於深層數組嵌套的['f', 'g']
,我們則需要遞歸觸發其實例上的 dep 依賴收集
派發更新
對於對象
在 setter 方法中執行dep.notity()
,通知所有的訂閱者,派發更新通知
註: 這個 dep 是在 defineReactive 函數中實例化的。 它是被閉包讀取的局部變數,會駐留到記憶體中且不會污染全局
Object.defineProperty(target, key, {
get() { ... },
set(newValue) {
if (newValue === value) return
// 修改後重新觀測。新值為對象的話,可以劫持其數據。並給所有的數組/對象的實例都增加一個 dep
observe(newValue)
value = newValue
// 通知 watcher 更新
dep.notify()
},
})
對於數組
在數組的重寫方法中執行xxx.__ob__.dep.notify()
,通知所有的訂閱者,派發更新通知
註: 這個 dep 是在 Observer 類中實例化的,我們給數組/對象的實例都增加一個 dep。可以通過響應式數據的__ob__獲取到實例,進而訪問實例上的屬性和方法
let oldArrayProto = Array.prototype // 獲取數組的原型
// newArrayProto.__proto__ = oldArrayProto
export let newArrayProto = Object.create(oldArrayProto)
// 找到所有的變異方法
let methods = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice'] // concat slice 都不會改變原數組
methods.forEach(method => {
// 這裡重寫了數組的方法
newArrayProto[method] = function (...args) {
// args reset參數收集,args為真正數組,arguments為偽數組
const result = oldArrayProto[method].call(this, ...args) // 內部調用原來的方法,函數的劫持,切片編程
// 我們需要對新增的數據再次進行劫持
let inserted
let ob = this.__ob__
switch (method) {
case 'push':
case 'unshift': // arr.unshift(1,2,3)
inserted = args
break
case 'splice': // arr.splice(0,1,{a:1},{a:1})
inserted = args.slice(2)
default:
break
}
if (inserted) {
// 對新增的內容再次進行觀測
ob.observeArray(inserted)
}
// 通知 watcher 更新渲染
ob.dep.notify()
return result
}
})
人間不正經生活手冊