iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Vue.js

從零到一打造 Vue3 響應式系統系列 第 21

Day 21 - Computed:即時更新基礎實作

  • 分享至 

  • xImage
  •  

banner

今天我們要在保持既有鏈表架構不變的前提下,來實作 computed 的惰性計算 + 快取(dirty 旗標)與調度邏輯。

範例演示

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <title>Document</title>
  <style>
    body {
      padding: 150px;
    }
  </style>
</head>

<body>
  <div id="app"></div>
  <script type="module">
    import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    // import { ref, computed, effect } from '../dist/reactivity.esm.js'

    const count = ref(0)

    const c = computed(() => {
      return count.value + 1
    })

    effect(() => {
      console.log(c.value)
    })

    setTimeout(() => {
      console.log(count.value)
    }, 1000)

  </script>
</body>

</html>

先看官方程式碼的效果:

day21-01

可以看到控制台,它會先輸出1,再輸出2,中間的 computed 只在需要時重算(惰性)。

執行順序是:

初始化

  • 初始化變數:countc
  • 初始化 effect,立刻執行console.log(c.value)
  • 收集 computed 依賴,觸發計算函式 () => count.value + 1
  • 讀取了 count.value,函式回傳 0 + 1,結果是 1,輸出1

一秒之後

  • count.value = 1 被執行。
  • Vue 偵測到 count 的值從 0 變成了 1
  • count.value 被修改時,它會通知所有訂閱它的對象,這邊包含c
  • c 接收到通知後,重新計算自己的值,並接著通知所有訂閱 c 的對象(也就是 effect),最終觸發 effect 的重新執行。
  • effect 收到通知,於是自動重新執行它內部的函式:() => console.log(c.value)
    • effect 它再次讀取 c.value
    • 重新執行計算函式 () => count.value + 1
    • 此時,count.value 的值已經是 1
    • c 計算出的新值為 1 + 1 = 2,輸出2

那我們可以看這個過程中,computed 在這當中扮演的角色如下圖。

day21-02

設計核心

首先computed 具有雙重角色:

  • 訂閱者 (Sub),它會收集其執行函式(getter)中所訪問到的所有響應式依賴。
  • **依賴項 (Dep),**當 effect 訪問 computed.value 時,computed 會將這個 effect 收集起來,建立關聯。
  • computed 接收有可能是函式,也有可能是一個物件。
    • 判斷是否為函式,函式有 getter
    • 判斷是否為物件,物件有傳入的 getter 和 setter

怎麼樣是一個 Sub?怎麼樣是一個 Dep?可以查看之前定義的 interface。

/**
 * 依賴項
 */
export interface Dependency {
  // 訂閱者鏈表頭節點
  subs: Link | undefined
  // 訂閱者鏈表尾節點
  subsTail: Link | undefined
}
/**
 * 訂閱者
 */
export interface Sub {
  // 訂閱者鏈表頭節點
  deps: Link | undefined
  // 訂閱者鏈表尾節點
  depsTail: Link | undefined
  // 是否正在收集依賴
  tracking: boolean
}

Sub

  • deps 頭節點
  • depsTail 尾節點
  • 有是否正在收集依賴的標記

Dep

  • subs 頭節點
  • subsTail 尾節點
  • 一定是響應式,會是 ref 或是 reactive

實作

我們先在 @vue/shared,新增一個函式判斷式。

export function isFunction(value) {
  return typeof value === 'function'
}

由於 computed 接收有可能是函式,也有可能是一個物件,所以我們新增一個 computed.ts,導出一個 computed 函式,來判斷它是物件還是函式。

  • 傳入是函式:
    • 表示只有 getter(computed 唯讀)
  • 傳入是物件:
    • 表示有 getter 跟 setter
export function computed(getterOptions) {
  let getter
  let setter
  if (isFunction(getterOptions)) {
    getter = getterOptions
  } else {
    getter = getterOptions.get
    setter = getterOptions.set
  }

  // ComputedRefImpl 是 computed 實際的響應式實作類別,再將 getter 跟 setter 傳入
  return new ComputedRefImpl(getter, setter)
}

接著讓我們再來實作 ComputedRefImpl 類別,把 Dep 和 Sub 所需要的屬性加入:

class ComputedRefImpl implements Dependency, Sub {
  // computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true
  [ReactiveFlags.IS_REF] = true

  // 保存 fn 返回值
  _value

  // 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn
  subs: Link
  subsTail: Link

  // 如果是 Sub,要知道哪些 Dep 被收集
  deps: Link
  depsTail: Link
  tracking = false

  constructor(
    public fn, //getter,但原始碼是fn,為了保持跟 effect 一致
    private setter
  ) { }
  get value() {
    this.update()
    return this._value
  }
  set value(newValue) {
    // 如果他有傳入 setter,表示是物件傳入
    if (this.setter) {
      this.setter(newValue)
    } else {
      console.warn('computed is readonly')
    }
  }

  update(){
    this._value = this.fn()
  }
}

我們執行這段程式碼,表面上看,它似乎能正確計算出結果:

day21-03

但其實目前 get value() 每次讀取都直接 update(),都沒有導入快取/dirty 與,在多次讀值或多個 effect 下會一直重複計算。

我們剛剛提到 computed 他有雙重角色,那麼我們要如何讓 computed 同時做 DepSub 的角色呢?

回顧我們先前的邏輯,就可以知道:

當 Computed 作為 Dep

day21-04

我們先在 get value() 裡建立與當前 activeSub 的關聯(link(this, activeSub)),同時改成只有在 dirty 時才 update,避免每次讀值都重算。

class ComputedRefImpl implements Dependency, Sub {
 ...
 ...
  get value() {
    this.update()
    if(activeSub){
      link(this,activeSub)
    }
    console.log('computed',this)
    return this._value
  }
  ...
  ...
}

接下來 console 看看是不是正常收集到 Fn

day21-05

看來有正確儲存 fn ,表示我們建立好關聯關係。

我們現在已經完成下方紅色區塊連結的地方:

day21-06

當 Computed 作為 Sub

day21-07

我們需要在 fn 執行期間,收集訪問的響應式,因此我們看一下之前寫的 effect 的邏輯。

computed 的 getter 執行時仍需收集依賴。沿用先前的 setActiveSub / startTrack / endTrack 機制,不需要改寫 effect 架構。

我們只在 ComputedRefImpl.update() 內部包一層收集區段就好。

export function setActiveSub(sub) {
  activeSub = sub
}

export class ReactiveEffect {
...
run() {
    const prevSub = activeSub
    setActiveSub(this)
    startTrack(this)

    try {

      return this.fn()

    } finally {
      endTrack(this)
      setActiveSub(prevSub)
    }
  }
...
...
}

我們透過 setActiveSub 來重新賦值給 activeSub 變數,再引入 computed.ts

import { activeSub, setActiveSub } from './effect'
...
...
update(){

    // 為了在 fn 執行期間,收集訪問的響應式
    const prevSub = activeSub
    setActiveSub(this)
    startTrack(this)

    try {
      this._value =  this.fn()

    } finally {
      endTrack(this)
      setActiveSub(prevSub)
      console.log(this)
    }
  }
...
...

在 console 控制台上,我們可以看到 dep 也被成功儲存。

day21-08

這樣看來,下方紅色圈起來的地方也已經完成。

day21-09

報錯

但你應該還會發現有一個錯誤。

day21-10

原因是 Ref 在 setTimeout 觸發更新會執行 setter

...
...
set value(newValue) {
    if(hasChanged(newValue, this._value)){
      this._value = isObject(newValue) ? reactive(newValue) : newValue
      triggerRef(this)
    }
}
...

然而執行到propagate函式

export function propagate(subs) {
  let link = subs
  let queuedEffect = []

  while (link) {
    const sub = link.sub

    // 只有不在執行中的才加入隊列
    if(!sub.tracking){
      queuedEffect.push(sub)
    }
    link = link.nextSub
  }

  queuedEffect.forEach(effect =>effect.notify())
}

propagate 函式預期所有 sub 都有一個 run() 方法,但我們的 ComputedRefImpl 類別沒有這個方法。

我們目前已經分別完成兩部份的鏈表,分別是:

  • computed 成為 count 的訂閱者 (Sub)
  • computed 成為 effect 的依賴項目 (Dep)

day21-11

day21-12

現在,我們需要將這兩段依賴鏈路串接起來,形成完整的更新流程。

解決問題

day21-13

執行觸發更新時:

  • ref 觸發更新
  • 通過 Sub 找到 computed
  • computed 執行更新
  • computed 再通過 computed 本身的 sub 鏈表
  • 找到所有的 sub 重新執行

因此我們現在要做的就是:

  1. 處理 computed 更新
  2. 讓 computed 通過 sub 鏈表,通知其他 sub 更新。

還記得我們原本在 computed 怎麼執行更新?

day21-14

之前我們在 ComputedRefImpl 中已經定義了 update 方法,可以用它來更新 computed 的值。

export function processComputedUpdate(sub) {
  // 通知 computed 更新
  sub.update()
  // 通知 sub 鏈表的其他 sub 更新
  propagate(sub.subs)
}

export function propagate(subs) {
  let link = subs
  let queuedEffect = []

  while (link) {
    const sub = link.sub

    if(!sub.tracking){
      // 如果 link.sub有 update 方法,表是傳入的是 computed
      if('update' in sub){
        processComputedUpdate(sub)
      }else{
        queuedEffect.push(sub)
      }
    }
    link = link.nextSub
  }

  queuedEffect.forEach(effect =>effect.notify())
}

所以我們可以透過傳入的 sub 是否有 update 方法來判斷他是不是 computed,如果傳入的是 computed,那除了觸發更新函式之外,還需要通知 sub 鏈表上的所有 sub 更新。

我們執行這段程式碼,表面上看,它似乎能正確計算出結果:

但如果 index.html 你這樣寫:

const count = ref(0)
  const c = computed(() => {
  console.count('computed')
  return count.value + 1
})

effect(() => {
  console.log(c.value)
})

setTimeout(() => {
  count.value = 1
}, 1000)

你會發現它其實是觸發三次。

day21-15

如果用官方的範例,發現它其實執行兩次而已。

day21-16

這個問題的根源在於 get value() 的實作:每次訪問 .value 都會直接觸發 update() 方法,因此完全沒有實現緩存。

 get value() {
    this.update()
    ...
    ...
  }

今天我們加上快取與 dirty,並以 notify() 充當調度器:上游變更只標髒、下游讀取才重算。下篇我們再補上更進一步的同一 tick 多次讀值只算一次、以及多層 computed 鏈的範例,確認效能與語意

computed 完整程式碼:

import { ReactiveFlags } from './ref'
import { Dependency, Sub, Link, link, startTrack, endTrack } from './system'
import { isFunction } from '@vue/shared'
import { activeSub, setActiveSub } from './effect'

class ComputedRefImpl implements Dependency, Sub {
  // computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true
  [ReactiveFlags.IS_REF] = true
  // 保存 fn 返回值
  _value
  // 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn
  subs: Link
  subsTail: Link

  // 如果是 Sub,要知道哪些 Dep 被收集
  deps: Link
  depsTail: Link
  tracking = false
  constructor(
    public fn, //getter,源碼是fn,保持跟 effect 一致
    private setter
  ) { }
  get value() {
    this.update()
    
    if(activeSub){
      link(this,activeSub)
    }
    return this._value
  }
  set value(newValue) {
    if (this.setter) {
      this.setter(newValue)
    } else {
      console.warn('computed is readonly')
    }
  }

  update(){
    /**
     * 收集依賴
     * 為了在 fn 執行期間,收集訪問的響應式
     */

    const prevSub = activeSub
    setActiveSub(this)
    startTrack(this)

    try {

      this._value =  this.fn()

    } finally {
      endTrack(this)
      setActiveSub(prevSub)
    }
  }
}

export function computed(getterOptions) {
  let getter
  let setter
  if (isFunction(getterOptions)) {
    getter = getterOptions
  } else {
    // 傳入是物件,物件有 get 和 set
    getter = getterOptions.get
    setter = getterOptions.set
  }

  return new ComputedRefImpl(getter, setter)
}

同步更新《嘿,日安!》技術部落格


上一篇
Day 20 - Reactive:reactive 極端案例
下一篇
Day 22 - Computed:深入緩存機制實作
系列文
從零到一打造 Vue3 響應式系統22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言