iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Vue.js

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

Day 20 - Reactive:reactive 極端案例

  • 分享至 

  • xImage
  •  

banner

我們完成 reactive 的基本實踐後,接下來有幾個有可能會發生的情況:

  • 原始物件傳入 Reactive 物件
  • Reactive 物件傳入 Reactive 物件
  • Reactive 物件重複賦相同數值
  • 巢狀物件傳入 Ref 物件
  • 解構傳入 Reactive 物件的 Ref 物件,並同步數值。
  • 初始化巢狀 Reactive 物件

第一個情況:原始物件傳入 Reactive 物件

這是最基本但也最直觀的一個案例。

如果我們把同一個原始物件多次傳入 reactive,目前的簡化版本會回傳不同的 Proxy 實例。

<!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 { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, effect } from '../dist/reactivity.esm.js'

    const obj = {
      a:0
    }

    const state = reactive(obj)
    const state2 = reactive(obj)
    console.log(state === state2)

    effect(() => {
      console.log(state.a)
    })

    setTimeout(() => {
      state.a = 1
    }, 1000)
  </script>
</body>

</html>

day20-01

當我們將同一個原始物件多次傳入 reactive 函式時,會發現返回的代理物件彼此不相等 (state !== state2),這與官方的行為(返回相等的代理物件)不符。

為什麼 state !== state2 ?原因在於我們目前的 createReactiveObject 函式,每次調用它都會無條件地 new Proxy() 一個新的代理物件。

function createReactiveObject(target) {

  if (!isObject(target)) return target

  // 這邊每次都會新增新的代理物件
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key,receiver)
    },
    set(target, key, newValue, receiver) {
      const res = Reflect.set(target, key, newValue, receiver)
      trigger(target, key)
      return res
    }
  })

  return proxy
}

因此我們需要做一些處理,避免讓相同物件被重複代理的情況。

/**
 * 儲存 target 和響應式物件的關聯關係
 * key:target / value:proxy
 */

const reactiveMap = new WeakMap()

function createReactiveObject(target) {
  // reactive 只處理物件
  if (!isObject(target)) return target

  // 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
  const existingProxy = reactiveMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key,receiver)
    },
    set(target, key, newValue, receiver) {
      const res = Reflect.set(target, key, newValue, receiver)
      trigger(target, key)
      return res
    }
  })

  // 儲存 target 和響應式物件的關聯關係
  reactiveMap.set(target, proxy)

  return proxy
}

如果沒有快取機制,會導致以下問題:

  • 記憶體浪費:重複建立無用的代理物件。
  • 依賴分裂:兩個不同的 proxy 操作同一個 target,但彼此的依賴追蹤卻不一致,可能導致更新失效或重複觸發。

第二個情況:Reactive 物件傳入 Reactive 物件

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

    const obj = {
	    a:0
		}
		
		const state = reactive(obj)
		const state2 = reactive(state)
		console.log(state === state2)
  </script>
</body>

在官方的實現中,預期結果為 true,因此我們需要進行處理,以確保返回快取的代理物件。

但是官方實際做法是他在代理物件 get 訪問某一個特殊屬性,他就會返回快取的代理物件,我們想一下其他方法:其實可以透過引入 reactiveSet,解決了重複代理的問題。


/**
 * 保存使用所有使用 reactive 建立的響應式物件
 * 用於檢查是否重複 reactive
 */
const reactiveSet = new Set()

function createReactiveObject(target) {
  // reactive 只處理物件
  if (!isObject(target)) return target

  // 如果這個 target 儲存在 reactiveSet 中
  // 表示 target 是一個響應式物件,直接返回已經建立好的 proxy
  if(reactiveSet.has(target)){
    return reactiveMap.get(target)
  }
  ...
  ...
})

  // 儲存 target 和響應式物件的關聯關係
  reactiveMap.set(target, proxy)

  // 儲存使用 reactive 建立的響應式物件
  reactiveSet.add(proxy)

  return proxy
}

// 判斷 target 是否為響應式物件
// 只要在 reactiveSet 中存在,就表示是響應式物件
export function isReactive(target) {
  return reactiveSet.has(target)
}

這裡的重點是避免重複代理。如果傳入的已經是 proxy,就應該直接返回它。

Vue 官方是透過 Proxy 內部的 get handler 監聽特殊屬性(例如 __v_isReactive)來辨識,
但我們也可以用一個 reactiveSet 來記錄所有已建立的代理,簡化判斷邏輯。

這個設計說明一個核心原則:響應式系統必須能分辨 target 與 proxy 的身份,否則會陷入無窮的代理鏈。

第三個情況:Reactive 物件重複賦相同數值

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

    const state = reactive({
      a:0
    })

    effect(() => {
      console.log(state.a)
    })

    setTimeout(() => {
      state.a = 0
    }, 1000)
  </script>
</body>

實作上述程式碼,會發現賦予相同數值,控制台會重複輸出兩次,但官方的只有一次。
官方 Vue 的行為是不會重複觸發,因為它會檢查新舊值是否相同。

day20-02

所以我們現在要做的是,當設定的新值與舊值相同時,我們應該避免觸發不必要的更新通知。

先在 @vue/shared 新增一個輔助函式,來判斷數值是否改變過:

//shared.ts
export function isObject(value) {
  return typeof value === 'object' && value !== null
}
// 判斷新值和舊值是否發生過變化,如果變化就返回 true,沒變化就返回 false
export function hasChanged(newValue, oldValue) {
  return !Object.is(newValue, oldValue)
}

引入到reactive.ts

//reactive.ts
import { isObject, hasChanged } from '@vue/shared'
...
...
set(target, key, newValue, receiver) {
    const oldValue = target[key]
    const res = Reflect.set(target, key, newValue, receiver)
    if(hasChanged(newValue, oldValue)){
      // 如果舊值不等於新值,則觸發更新
      trigger(target, key)
    }
    return res
}
...

第四個情況:巢狀物件傳入 Ref 物件

為了避免多層巢狀物件傳入 ref 的情況,我們判斷傳入 ref 的型別,如果它是物件就使用 reactive物件。

//ref.ts
import { isObject } from '@vue/shared'
import { reactive } from './reactive'

enum ReactiveFlags {
  IS_REF = '__v_isRef'
}

class RefImpl {
  _value;
  [ReactiveFlags.IS_REF] = true

  subs: Link
  subsTail: Link
  constructor(value) {
    // 如果 value 是物件,則使用 reactive 轉換為響應式物件
    this._value = isObject(value) ? reactive(value) : value
  }

  get value() {
    if (activeSub) {
      trackRef(this)
    }
    return this._value
  }

  set value(newValue) {
    // 如果新值和舊值發生過變化,則更新
    if(hasChanged(newValue, this._value)){
      // 如果新值是物件,則使用 reactive 轉換為響應式物件
      this._value = isObject(newValue) ? reactive(newValue) : newValue
      triggerRef(this)
    }
  }
}

如果 ref 的值是物件,為了讓它繼續有響應式追蹤,所以我們需要在內部把它轉換成 reactive。

第五種情況:解構傳入 Reactive 物件的 Ref 物件,並同步數值。

為了正確處理 refreactive 的整合,所以我們要做三件事:

  • ref 傳入 reactive,要可以直接拿到值,不需要.value
<body>
  <div id="app"></div>
  <script type="module">
    // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive,ref, effect } from '../dist/reactivity.esm.js'

    // 如果 target.a 是一個 ref,就直接把值給他,不用.value
    const a = ref(0)
    const state = reactive({
      a
    })

    effect(() => {
      // 不用 state.a.value 也可以拿到值
      console.log('reactive', state.a)
    })
  </script>
</body>

day20-03

  • ref 傳入 reactive,當 reative 更新數值,ref 數值也要同步更新
<body>
  <div id="app"></div>
  <script type="module">
    // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive,ref, effect } from '../dist/reactivity.esm.js'
    
    const a = ref(0)
    const state = reactive({
      a
    })

    effect(() => {
      console.log('reactive', state.a)
    })

    setTimeout(() => {
      //這樣 value 同步更新
      state.a = 1
      console.log('ref', a.value)
    }, 1000)
  </script>
</body>

day20-04

  • ref 傳入 reative,如果 reative 更新一個新的 ref,原本 ref 變數不同步更新。
<body>
  <div id="app"></div>
  <script type="module">
    // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive,ref, effect } from '../dist/reactivity.esm.js'

    const a = ref(0)
    const state = reactive({
      a
    })

    effect(() => {
      console.log('reactive', state.a)
    })

    setTimeout(() => {
      //這樣 value 不同步更新
      state.a = ref(1)
      console.log('ref', a.value)
    }, 1000)
  </script>
</body>

day20-05

實作

  • ref 傳入 reactive,解構 .value

    //reactive.ts
    ...
    ...
      get(target, key, receiver) {
        // 收集依賴:綁定target的屬性與effect的關係
        track(target, key)
        const res = Reflect.get(target, key,receiver)
        // 如果 res 是一個 ref,則返回 res.value
        if(isRef(res)){
          return res.value
        }
        return res
      },
      ...
      ...
    }
    

    這樣解構之後,的確可以直接取值,不需要 .value,但是 a 裡面的 value 卻還是沒有更新。

  • 確認新舊數值是否發生變化,決定是否觸發更新 ref。

首先在@vue/shared,導出一個輔助函式來判斷是否發生變化:

// 判斷新值和舊值是否發生過變化,如果變化就返回 true,沒變化就返回 false
export function hasChanged(newValue, oldValue) {
  return !Object.is(newValue, oldValue)
}

再來修改 Proxy 物件 setter

set(target, key, newValue, receiver) {
  
  const oldValue = target[key]
  
  /**
   * const a = ref(0)
   * target = { a }
   * 更新 target.a = 1 時,他就等於更新了 a.value
   * a.value = 1
   */
  if(isRef(oldValue) && !isRef(newValue)){
    oldValue.value = newValue

    // 改了 ref 的值,會通知 sub 更新
    // 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次
    return true
  }
  
  const res = Reflect.set(target, key, newValue, receiver)
  
  if(hasChanged(newValue, oldValue)){
    // 如果舊值不等於新值,則觸發更新
    // 觸發更新:通知之前收集的依賴,重新執行effect
    trigger(target, key)
  }
  return res
}

第六種情況:初始化巢狀 Reactive 物件

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

    const state = reactive({
      a: {
        b: 0
      }
    })

    effect(() => {
      console.log(state.a.b)
    })

    setTimeout(() => {
      state.a.b = 1
    }, 1000)
  </script>
</body>

執行上述程式碼後,我們會發現 effect 沒有在 state.a.b 被修改後重新觸發。

因為屬性 a 的物件(紅色)不屬於響應式,但最外層物件(橘色)是屬於響應式。

day20-06

get(target, key, receiver) {
    track(target, key)
    const res = Reflect.get(target, key,receiver)
    console.log(res)
    if(isRef(res)){
      return res.value
    }
    return res
  },

我們在trigger函式console,發現它沒有反應,因為只要不是響應式物件,就無法觸發更新。

export function trigger(target, key) {
  console.log('trigger', target, key)
  ...
}
// 沒有反應

所以我們更新一下,如果發現這個屬性的值本身也是一個物件,我們就把它也轉換成響應式物件。

get(target, key, receiver) {
    track(target, key)
    const res = Reflect.get(target, key,receiver)
    if(isRef(res)){
      return res.value
    }

    if(isObject(res)){
      /**
       * 如果 res 是物件,則將其轉換為響應式物件
       */
      return reactive(res)
    }
    return res
  },

重構調整程式碼

為了提升效能並遵循單一職責原則,我們應該將 Proxy 的處理邏輯(handlers)抽離成一個獨立的物件。

若不抽離,每次調用 createReactiveObject 都會重新建立一個 handlers 物件,造成不必要的耗損。抽離後,所有代理物件便可以共用同一份 handlers

baseHandlers.ts

import { hasChanged, isObject } from '@vue/shared'
import { track, trigger } from './dep'
import { isRef } from './ref'
import { reactive } from './reactive'

export const mutableHandlers = {
  get(target, key, receiver) {
    // 收集依賴:綁定target的屬性與effect的關係
    track(target, key)
    const res = Reflect.get(target, key,receiver)
    // 如果 res 是一個 ref,則返回 res.value
    if(isRef(res)){
      // target = {a:ref(0)}
      return res.value
    }

    if(isObject(res)){
      /**
       * 如果 res 是物件,則將其轉換為響應式物件
       */
      return reactive(res)
    }
    return res
  },
  set(target, key, newValue, receiver) {
    const oldValue = target[key]
    
    /**
     * const a = ref(0)
     * target = { a }
     * 更新 target.a = 1 時,他就等於更新了 a.value
     * a.value = 1
     */
    if(isRef(oldValue) && !isRef(newValue)){
      oldValue.value = newValue

      // 改了 ref 的值,會通知 sub 更新
      // 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次
      return true
    }
    
    const res = Reflect.set(target, key, newValue, receiver)
    
    if(hasChanged(newValue, oldValue)){
      // 如果舊值不等於新值,則觸發更新
      // 觸發更新:通知之前收集的依賴,重新執行effect
      trigger(target, key)
    }
    return res
  }
}

dep.ts

import { Link, link, propagate } from "./system"
import { activeSub } from "./effect"

class Dep {
  subs: Link
  subsTail: Link
  constructor() { }
}

const targetMap = new WeakMap()

export function track(target, key) {
  if (!activeSub) return
  // 透過 targetMap 取得 target 的依賴

  let depsMap = targetMap.get(target)

  // 首次收集依賴,之前沒有收集過,就新建一個
  // key:obj / value:depsMap

  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let dep = depsMap.get(key)

  //收集依賴:第一次建立物件依賴關聯,並且保存到depsMap中
  // key:key / value:Dep
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }

  link(dep, activeSub)
}

export function trigger(target, key) {
  const depsMap = targetMap.get(target)
  // 如果 depsMap 不存在,表示沒有收集過依賴,直接返回
  if (!depsMap) return

  const dep = depsMap.get(key)
  // 如果依賴不存在,表示這個 key 沒有在effect中被使用過,直接返回
  if (!dep) return

  // 找到依賴,觸發更新
  propagate(dep.subs)
}

reactive.ts

import { isObject } from '@vue/shared'
import { mutableHandlers } from './baseHandlers'

/**
 * 儲存 target 和響應式物件的關聯關係
 * key:target / value:proxy
 */

const reactiveMap = new WeakMap()

/**
 * 保存使用所有使用 reactive 建立的響應式物件
 * 用於檢查是否重複 reactive
 */
const reactiveSet = new Set()

function createReactiveObject(target) {
  // reactive 只處理物件
  if (!isObject(target)) return target

  // 如果這個 target 儲存在 reactiveSet 中
  // 表示 target 是一個響應式物件,直接返回已經建立好的 proxy
  if(reactiveSet.has(target)){
    return reactiveMap.get(target)
  }

  // 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
  const existingProxy = reactiveMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

  // 建立 target 的代理物件
  const proxy = new Proxy(target, mutableHandlers)

  // 儲存 target 和響應式物件的關聯關係
  reactiveMap.set(target, proxy)

  // 儲存使用 reactive 建立的響應式物件
  reactiveSet.add(proxy)

  return proxy
}

export function reactive(target) {
  return createReactiveObject(target)
}

// 判斷 target 是否為響應式物件
// 只要在 reactiveSet 中存在,就表示是響應式物件
export function isReactive(target) {
  return reactiveSet.has(target)
}

這六個情境案例,分別是:

  • 快取機制:避免重複代理與依賴分裂。
  • 身份辨識:區分原始物件、代理物件與 ref
  • 效能優化:避免不必要的觸發。
  • API 體驗:隱藏 .value,提升開發者直覺。
  • Lazy 策略:動態轉換巢狀物件,提升初始化效能。
  • 工程化:抽離 handlers,讓程式碼更具可維護性。

這些設計選擇的背後,都是在效能、易用性、與一致性之間的權衡。
理解這些極端案例,不只是能寫出響應式系統,更能了解 Vue 3 背後的設計思維。


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


上一篇
Day 19 - Reactive:reactive 的基礎實作
系列文
從零到一打造 Vue3 響應式系統20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言