【Vue2.x原始碼系列06】計算屬性computed原理

2023-04-19 12:02:39

上一章 Vue2非同步更新和nextTick原理,我們介紹了 JavaScript 執行機制是什麼?nextTick原始碼是如何實現的?以及Vue是如何非同步更新渲染的?

本章目標

  • 計算屬性是如何實現的?
  • 計算屬性快取原理 - 帶有dirty屬性的watcher
  • 洋蔥模型的應用

初始化

在 Vue初始化範例的過程中,如果使用者 options選項中存在計算屬性時,則初始化計算屬性

// 初始化狀態
export function initState(vm) {
  const opts = vm.$options // 獲取所有的選項

  // 初始化資料
  if (opts.data) { initData(vm) }

  // 初始化計算屬性
  if (opts.computed) { initComputed(vm) }
}

我們給每個計算屬性都建立了一個 Watcher範例,標識為lazy:true, 在初始化watcher時不會立即執行 get方法(計算屬性方法)

並將計算屬性watcher 都儲存到了 Vue範例上,讓我們可以在後續的 getter方法中通過 vm獲取到當前的計算屬性watcher

然後使用Object.defineProperty去劫持計算屬性

// 初始化計算屬性
function initComputed(vm) {
  const computed = vm.$options.computed
  const watchers = (vm._computedWatchers = {}) // 將每個計算屬性對應的watcher 都儲存到 vm上
  for (let key in computed) {
    let userDef = computed[key]

    // 相容不同寫法 函數方式 和 物件getter/setter方式
    let fn = typeof userDef === 'function' ? userDef : userDef.get

    // 給每個計算屬性都建立一個 watcher,並標識為 lazy,不會立即執行 get-fn
    watchers[key] = new Watcher(vm, fn, { lazy: true })

    // 劫持計算屬性getter/setter
    defineComputed(vm, key, userDef)
  }
}

// 劫持計算屬性
function defineComputed(target, key, userDef) {
  const setter = userDef.set || (() => {})

  Object.defineProperty(target, key, {
    get: createComputedGetter(key),
    set: setter,
  })
}

當我們劫持到計算屬性被存取時,根據 dirty 值去決定是否更新 watcher快取值

然後讓自己依賴的屬性(準確來說是訂閱的所有dep)都去收集上層watcher,即 Dep.target(可能是計算屬性watcher,也可能是渲染watcher)

// 劫持計算屬性的存取
function createComputedGetter(key) {
  return function () {
    const watcher = this._computedWatchers[key] // this就是 defineProperty 劫持的targer。獲取到計算屬性對應的watcher

    // 如果是髒的,就去執行使用者傳入的函數
    if (watcher.dirty) {
      watcher.evaluate() // 重新求值後 dirty變為false,下次就不求值了,走快取值
    }

    // 當前計算屬性watcher 出棧後,還有渲染watcher 或者其他計算屬性watcher,我們應該讓當前計算屬性watcher 訂閱的 dep,也去收集上一層的watcher 即Dep.target(可能是計算屬性watcher,也可能是渲染watcher)
    if (Dep.target) {
      watcher.depend()
    }

    // 返回watcher上的值
    return watcher.value
  }

Dep

  • Dep.target:當前渲染的 watcher,靜態變數
  • stack:存放 watcher 的棧。 利用 pushTarget、popTarget 這兩個方法做入棧出棧操作
// 當前渲染的 watcher
Dep.target = null

// 存放 watcher 的棧
let stack = []
// 當前 watcher 入棧, Dep.target 指向 當前 watcher
export function pushTarget(watcher) {
  stack.push(watcher)
  Dep.target = watcher
}
// 棧中最後一個 watcher 出棧,Dep.target指向棧中 最後一個 watcher,若棧為空,則為 undefined
export function popTarget() {
  stack.pop()
  Dep.target = stack[stack.length - 1]
}

計算屬性Watcher

在初始化Vue範例時,我們會給每個計算屬性都建立一個對應watcher(我們稱之為計算屬性watcher,除此之外還有 渲染watcher偵聽器watcher ),他有一個 value 屬性用於快取計算屬性方法的返回值。

預設標識 lazy: true,懶的,代表計算屬性watcher,建立時不會立即執行 get方法

預設標識 dirty: true,髒的,當我們劫持到計算屬性存取時,如果是髒的,我們會通過watcher.evaluate重新計算 watcher 的 value值 並將其標識為乾淨的;如果是乾淨的,則直接取 watcher 快取值

depend 方法,會讓計算屬性watcher 訂閱的dep去收集上層watcher,可能是渲染watcher,也可能是計算屬性watcher(計算屬性巢狀的情況),實現洋蔥模型的核心方法

update 方法,當計算屬性依賴的物件發生變化時,會觸發dep.notify派發更新 並 呼叫 update 方法,只需更新 dirty 為 true即可。我們會在後續的渲染watcher 更新時,劫持到計算屬性的存取操作,並通過 watcher.evaluate重新計算其 value值

class Watcher {
  constructor(vm, fn, options) {
    // 計算屬性watcher 用到的屬性
    this.vm = vm
    this.lazy = options.lazy // 懶的,不會立即執行get方法
    this.dirty = this.lazy // 髒的,決定重新讀取get返回值 還是 讀取快取值

    this.value = this.lazy ? undefined : this.get() // 儲存 get返回值
  }

  // 重新渲染
  update() {
    console.log('watcher-update')
    if (this.lazy) {
      // 計算屬性依賴的值發生改變,觸發 setter 通知 watcher 更新,將計算屬性watcher 標識為髒值即可
      // 後面還會觸發渲染watcher,會走 evaluate 重新讀取返回值
      this.dirty = true
    } else {
      queueWatcher(this) // 把當前的watcher 暫存起來,非同步佇列渲染
      // this.get(); // 重新渲染
    }
  }

  // 計算屬性watcher為髒時,執行 evaluate,並將其標識為乾淨的
  evaluate() {
    this.value = this.get() // 重新獲取到使用者函數的返回值
    this.dirty = false
  }
  
  // 用於洋蔥模型中計算屬性watcher 訂閱的dep去 depend收集上層watcher 即Dep.target(可能是計算屬性watcher,也可能是渲染watcher)
  depend() {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

快取原理

計算屬性是基於它們的響應式依賴進行快取的。只在相關響應式依賴發生改變時它們才會重新求值。 快取原理如下:

在初始化計算屬性時,我們使用Object.defineProperty劫持了計算屬性,並做了一些 getter/setter操作

計算屬性watcher 有一個 dirty髒值屬性,預設為 true

當我們劫持到計算屬性被存取時,如果 dirty 為 true,則執行 evaluate 更新 watcher 的 value值 並 將 dirty 標識為 false;如果為 false,則直接取 watcher 的快取值

當計算屬性依賴的屬性變化時,會通知 watcher 呼叫 update方法,此時我們將 dirty 標識為 true。這樣再次取值時會重新進行計算

洋蔥模型

在初始化Vue範例時,我們會給每個計算屬性都建立一個對應的懶的watcher,不會立即呼叫計算屬性方法

當我們存取計算屬性時,會通過watcher.evaluate()讓其直接依賴的屬性去收集當前的計算屬性watcher,並且還會通過watcher.depend()讓其訂閱的所有 dep都去收集上層watcher,可能是渲染watcher,也可能是計算屬性watcher(如果存在計算屬性巢狀計算屬性的話)。這樣依賴的屬性發生變化也可以讓檢視進行更新

讓我們一起來分析下計算屬性巢狀的例子

<p>{{fullName}}</p>

computed: {
  fullAge() {
    return '今年' + this.age
  },
  fullName() {
    console.log('run')
    return this.firstName + ' ' + this.lastName  + ' ' + this.fullAge
  },
}
  1. 初始化元件時,渲染watcher 入棧
    • stack:[渲染watcher]
  2. 當執行 render方法並初次存取 fullName時,執行computed watcher1.evaluate()watcher1入棧
    • stack:[渲染watcher, watcher1]
  3. 當執行watcher1的 get方法時,其直接依賴的 firstName 和 lastName 會去收集當前的 watcher1;然後又存取 fullAge 並執行computed watcher2.evaluate()watcher2入棧
    • watcher1:[firstName, lastName]
    • stack:[渲染watcher, watcher1, watcher2]
  4. 執行watcher2的 get方法時,其直接依賴的 age 會去收集當前的 watcher2
    • watcher2:[age]
  5. watcher2出棧,並執行watcher2.depend(),讓watcher2訂閱的 dep再去收集當前watcher1
    • stack:[渲染watcher, watcher1]
    • watcher1:[firstName, lastName, age]
  6. watcher1出棧,執行watcher1.depend(),讓watcher1訂閱的 dep再去收集當前的渲染watcher
    • stack:[渲染watcher]
    • 渲染watcher:[firstName, lastName, age]