Vue2依賴收集原理

2023-04-03 12:02:25

觀察者模式定義了物件間一對多的依賴關係。即被觀察者狀態發生變動時,所有依賴於它的觀察者都會得到通知並自動更新。解決了主體物件和觀察者之間功能的耦合。

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

  1. 定義 subs 陣列,當劫持到資料存取時,執行 dep.depend(),通知 watcher 訂閱 dep,然後在 watcher內部執行dep.addSub(),通知 dep 收集 watcher
  2. 當劫持到資料變更時,執行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

  1. Observer類:在 Observer 類中範例化 dep,可以給每個陣列/物件的範例都增加一個 dep
  2. 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
  }
})