我們完成 reactive
的基本實踐後,接下來有幾個有可能會發生的情況:
- 原始物件傳入 Reactive 物件
- Reactive 物件傳入 Reactive 物件
- Reactive 物件重複賦相同數值
- 巢狀物件傳入 Ref 物件
- 解構傳入 Reactive 物件的 Ref 物件,並同步數值。
- 初始化巢狀 Reactive 物件
第一個情況:原始物件傳入 Reactive 物件
這是最基本但也最直觀的一個案例。
如果我們把同一個原始物件多次傳入 reactive,目前的簡化版本會回傳不同的 Proxy 實例。
1<!DOCTYPE html>2<html lang="en">3
4<head>5 <meta charset="UTF-8" />6 <title>Document</title>7 <style>8 body {9 padding: 150px;10 }11 </style>12</head>13
14<body>15 <div id="app"></div>23 collapsed lines
16 <script type="module">17 // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'18 import { reactive, effect } from '../dist/reactivity.esm.js'19
20 const obj = {21 a:022 }23
24 const state = reactive(obj)25 const state2 = reactive(obj)26 console.log(state === state2)27
28 effect(() => {29 console.log(state.a)30 })31
32 setTimeout(() => {33 state.a = 134 }, 1000)35 </script>36</body>37
38</html>
當我們將同一個原始物件多次傳入 reactive
函式時,會發現返回的代理物件彼此不相等 (state !== state2
),這與官方的行為(返回相等的代理物件)不符。
為什麼 state !== state2
?原因在於我們目前的 createReactiveObject
函式,每次調用它都會無條件地 new Proxy()
一個新的代理物件。
1function createReactiveObject(target) {2
3 if (!isObject(target)) return target4
5 // 這邊每次都會新增新的代理物件6 const proxy = new Proxy(target, {7 get(target, key, receiver) {8 track(target, key)9 return Reflect.get(target, key,receiver)10 },11 set(target, key, newValue, receiver) {12 const res = Reflect.set(target, key, newValue, receiver)13 trigger(target, key)14 return res15 }4 collapsed lines
16 })17
18 return proxy19}
因此我們需要做一些處理,避免讓相同物件被重複代理的情況。
1/**2 * 儲存 target 和響應式物件的關聯關係3 * key:target / value:proxy4 */5
6const reactiveMap = new WeakMap()7
8function createReactiveObject(target) {9 // reactive 只處理物件10 if (!isObject(target)) return target11
12 // 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy13 const existingProxy = reactiveMap.get(target)14 if (existingProxy) {15 return existingProxy19 collapsed lines
16 }17
18 const proxy = new Proxy(target, {19 get(target, key, receiver) {20 track(target, key)21 return Reflect.get(target, key,receiver)22 },23 set(target, key, newValue, receiver) {24 const res = Reflect.set(target, key, newValue, receiver)25 trigger(target, key)26 return res27 }28 })29
30 // 儲存 target 和響應式物件的關聯關係31 reactiveMap.set(target, proxy)32
33 return proxy34}
如果沒有快取機制,會導致以下問題:
- 記憶體浪費:重複建立無用的代理物件。
- 依賴分裂:兩個不同的 proxy 操作同一個 target,但彼此的依賴追蹤卻不一致,可能導致更新失效或重複觸發。
第二個情況:Reactive 物件傳入 Reactive 物件
1<body>2 <div id="app"></div>3 <script type="module">4 // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive, effect } from '../dist/reactivity.esm.js'6
7 const obj = {8 a:09 }10
11 const state = reactive(obj)12 const state2 = reactive(state)13 console.log(state === state2)14 </script>15</body>
在官方的實現中,預期結果為 true
,因此我們需要進行處理,以確保返回快取的代理物件。
但是官方實際做法是他在代理物件 get 訪問某一個特殊屬性,他就會返回快取的代理物件,我們想一下其他方法:其實可以透過引入 reactiveSet
,解決了重複代理的問題。
1/**2 * 保存使用所有使用 reactive 建立的響應式物件3 * 用於檢查是否重複 reactive4 */5const reactiveSet = new Set()6
7function createReactiveObject(target) {8 // reactive 只處理物件9 if (!isObject(target)) return target10
11 // 如果這個 target 儲存在 reactiveSet 中12 // 表示 target 是一個響應式物件,直接返回已經建立好的 proxy13 if(reactiveSet.has(target)){14 return reactiveMap.get(target)15 }18 collapsed lines
16 ...17 ...18})19
20 // 儲存 target 和響應式物件的關聯關係21 reactiveMap.set(target, proxy)22
23 // 儲存使用 reactive 建立的響應式物件24 reactiveSet.add(proxy)25
26 return proxy27}28
29// 判斷 target 是否為響應式物件30// 只要在 reactiveSet 中存在,就表示是響應式物件31export function isReactive(target) {32 return reactiveSet.has(target)33}
這裡的重點是避免重複代理。如果傳入的已經是 proxy,就應該直接返回它。
Vue 官方是透過 Proxy 內部的 get
handler 監聽特殊屬性(例如 __v_isReactive
)來辨識,
但我們也可以用一個 reactiveSet 來記錄所有已建立的代理,簡化判斷邏輯。
這個設計說明一個核心原則:響應式系統必須能分辨 target 與 proxy 的身份,否則會陷入無窮的代理鏈。
第三個情況:Reactive 物件重複賦相同數值
1<body>2 <div id="app"></div>3 <script type="module">4 // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive, effect } from '../dist/reactivity.esm.js'6
7 const state = reactive({8 a:09 })10
11 effect(() => {12 console.log(state.a)13 })14
15 setTimeout(() => {4 collapsed lines
16 state.a = 017 }, 1000)18 </script>19</body>
實作上述程式碼,會發現賦予相同數值,控制台會重複輸出兩次,但官方的只有一次。 官方 Vue 的行為是不會重複觸發,因為它會檢查新舊值是否相同。
所以我們現在要做的是,當設定的新值與舊值相同時,我們應該避免觸發不必要的更新通知。
先在 @vue/shared
新增一個輔助函式,來判斷數值是否改變過:
1export function isObject(value) {2 return typeof value === 'object' && value !== null3}4// 判斷新值和舊值是否發生過變化,如果變化就返回 true,沒變化就返回 false5export function hasChanged(newValue, oldValue) {6 return !Object.is(newValue, oldValue)7}
引入到reactive.ts
:
1import { isObject, hasChanged } from '@vue/shared'2...3...4set(target, key, newValue, receiver) {5 const oldValue = target[key]6 const res = Reflect.set(target, key, newValue, receiver)7 if(hasChanged(newValue, oldValue)){8 // 如果舊值不等於新值,則觸發更新9 trigger(target, key)10 }11 return res12}13...
第四個情況:巢狀物件傳入 Ref 物件
為了避免多層巢狀物件傳入 ref
的情況,我們判斷傳入 ref
的型別,如果它是物件就使用 reactive
物件。
1import { isObject } from '@vue/shared'2import { reactive } from './reactive'3
4enum ReactiveFlags {5 IS_REF = '__v_isRef'6}7
8class RefImpl {9 _value;10 [ReactiveFlags.IS_REF] = true11
12 subs: Link13 subsTail: Link14 constructor(value) {20 collapsed lines
15 // 如果 value 是物件,則使用 reactive 轉換為響應式物件16 this._value = isObject(value) ? reactive(value) : value17 }18
19 get value() {20 if (activeSub) {21 trackRef(this)22 }23 return this._value24 }25
26 set value(newValue) {27 // 如果新值和舊值發生過變化,則更新28 if(hasChanged(newValue, this._value)){29 // 如果新值是物件,則使用 reactive 轉換為響應式物件30 this._value = isObject(newValue) ? reactive(newValue) : newValue31 triggerRef(this)32 }33 }34}
如果 ref
的值是物件,為了讓它繼續有響應式追蹤,所以我們需要在內部把它轉換成 reactive。
第五種情況:解構傳入 Reactive 物件的 Ref 物件,並同步數值。
為了正確處理 ref
與 reactive
的整合,所以我們要做三件事:
- ref 傳入 reactive,要可以直接拿到值,不需要
.value
1<body>2 <div id="app"></div>3 <script type="module">4 // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive,ref, effect } from '../dist/reactivity.esm.js'6
7 // 如果 target.a 是一個 ref,就直接把值給他,不用.value8 const a = ref(0)9 const state = reactive({10 a11 })12
13 effect(() => {14 // 不用 state.a.value 也可以拿到值15 console.log('reactive', state.a)3 collapsed lines
16 })17 </script>18</body>
- ref 傳入 reactive,當 reative 更新數值,ref 數值也要同步更新
1<body>2 <div id="app"></div>3 <script type="module">4 // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive,ref, effect } from '../dist/reactivity.esm.js'6
7 const a = ref(0)8 const state = reactive({9 a10 })11
12 effect(() => {13 console.log('reactive', state.a)14 })15
7 collapsed lines
16 setTimeout(() => {17 //這樣 value 同步更新18 state.a = 119 console.log('ref', a.value)20 }, 1000)21 </script>22</body>
ref
傳入reative
,如果reative
更新一個新的ref
,原本ref
變數不同步更新。
1<body>2 <div id="app"></div>3 <script type="module">4 // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive,ref, effect } from '../dist/reactivity.esm.js'6
7 const a = ref(0)8 const state = reactive({9 a10 })11
12 effect(() => {13 console.log('reactive', state.a)14 })15
7 collapsed lines
16 setTimeout(() => {17 //這樣 value 不同步更新18 state.a = ref(1)19 console.log('ref', a.value)20 }, 1000)21 </script>22</body>
實作
-
ref
傳入reactive
,解構.value
reactive.ts 1...2...3get(target, key, receiver) {4// 收集依賴:綁定target的屬性與effect的關係5track(target, key)6const res = Reflect.get(target, key,receiver)7// 如果 res 是一個 ref,則返回 res.value8if(isRef(res)){9return res.value10}11return res12},13...14...1 collapsed line15}這樣解構之後,的確可以直接取值,不需要
.value
,但是 a 裡面的value
卻還是沒有更新。 -
確認新舊數值是否發生變化,決定是否觸發更新 ref。
首先在@vue/shared
,導出一個輔助函式來判斷是否發生變化:
1// 判斷新值和舊值是否發生過變化,如果變化就返回 true,沒變化就返回 false2export function hasChanged(newValue, oldValue) {3 return !Object.is(newValue, oldValue)4}
再來修改 Proxy
物件 setter
:
1set(target, key, newValue, receiver) {2
3 const oldValue = target[key]4
5 /**6 * const a = ref(0)7 * target = { a }8 * 更新 target.a = 1 時,他就等於更新了 a.value9 * a.value = 110 */11 if(isRef(oldValue) && !isRef(newValue)){12 oldValue.value = newValue13
14 // 改了 ref 的值,會通知 sub 更新15 // 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次12 collapsed lines
16 return true17 }18
19 const res = Reflect.set(target, key, newValue, receiver)20
21 if(hasChanged(newValue, oldValue)){22 // 如果舊值不等於新值,則觸發更新23 // 觸發更新:通知之前收集的依賴,重新執行effect24 trigger(target, key)25 }26 return res27}
第六種情況:初始化巢狀 Reactive 物件
1<body>2 <div id="app"></div>3 <script type="module">4 // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { ref, reactive, effect } from '../dist/reactivity.esm.js'6
7 const state = reactive({8 a: {9 b: 010 }11 })12
13 effect(() => {14 console.log(state.a.b)15 })6 collapsed lines
16
17 setTimeout(() => {18 state.a.b = 119 }, 1000)20 </script>21</body>
執行上述程式碼後,我們會發現 effect
沒有在 state.a.b
被修改後重新觸發。
因為屬性 a 的物件(紅色)不屬於響應式,但最外層物件(橘色)是屬於響應式。
1get(target, key, receiver) {2 track(target, key)3 const res = Reflect.get(target, key,receiver)4 console.log(res)5 if(isRef(res)){6 return res.value7 }8 return res9 },
我們在trigger
函式console
,發現它沒有反應,因為只要不是響應式物件,就無法觸發更新。
1export function trigger(target, key) {2 console.log('trigger', target, key)3 ...4}5// 沒有反應
所以我們更新一下,如果發現這個屬性的值本身也是一個物件,我們就把它也轉換成響應式物件。
1get(target, key, receiver) {2 track(target, key)3 const res = Reflect.get(target, key,receiver)4 if(isRef(res)){5 return res.value6 }7
8 if(isObject(res)){9 /**10 * 如果 res 是物件,則將其轉換為響應式物件11 */12 return reactive(res)13 }14 return res15 },
重構調整程式碼
為了提升效能並遵循單一職責原則,我們應該將 Proxy
的處理邏輯(handlers)抽離成一個獨立的物件。
若不抽離,每次調用 createReactiveObject
都會重新建立一個 handlers
物件,造成不必要的耗損。抽離後,所有代理物件便可以共用同一份 handlers
。
baseHandlers.ts
1import { hasChanged, isObject } from '@vue/shared'2import { track, trigger } from './dep'3import { isRef } from './ref'4import { reactive } from './reactive'5
6export const mutableHandlers = {7 get(target, key, receiver) {8 // 收集依賴:綁定target的屬性與effect的關係9 track(target, key)10 const res = Reflect.get(target, key,receiver)11 // 如果 res 是一個 ref,則返回 res.value12 if(isRef(res)){13 // target = {a:ref(0)}14 return res.value15 }36 collapsed lines
16
17 if(isObject(res)){18 /**19 * 如果 res 是物件,則將其轉換為響應式物件20 */21 return reactive(res)22 }23 return res24 },25 set(target, key, newValue, receiver) {26 const oldValue = target[key]27
28 /**29 * const a = ref(0)30 * target = { a }31 * 更新 target.a = 1 時,他就等於更新了 a.value32 * a.value = 133 */34 if(isRef(oldValue) && !isRef(newValue)){35 oldValue.value = newValue36
37 // 改了 ref 的值,會通知 sub 更新38 // 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次39 return true40 }41
42 const res = Reflect.set(target, key, newValue, receiver)43
44 if(hasChanged(newValue, oldValue)){45 // 如果舊值不等於新值,則觸發更新46 // 觸發更新:通知之前收集的依賴,重新執行effect47 trigger(target, key)48 }49 return res50 }51}
dep.ts
1import { Link, link, propagate } from "./system"2import { activeSub } from "./effect"3
4class Dep {5 subs: Link6 subsTail: Link7 constructor() { }8}9
10const targetMap = new WeakMap()11
12export function track(target, key) {13 if (!activeSub) return14 // 透過 targetMap 取得 target 的依賴15
34 collapsed lines
16 let depsMap = targetMap.get(target)17
18 // 首次收集依賴,之前沒有收集過,就新建一個19 // key:obj / value:depsMap20
21 if (!depsMap) {22 depsMap = new Map()23 targetMap.set(target, depsMap)24 }25
26 let dep = depsMap.get(key)27
28 //收集依賴:第一次建立物件依賴關聯,並且保存到depsMap中29 // key:key / value:Dep30 if (!dep) {31 dep = new Dep()32 depsMap.set(key, dep)33 }34
35 link(dep, activeSub)36}37
38export function trigger(target, key) {39 const depsMap = targetMap.get(target)40 // 如果 depsMap 不存在,表示沒有收集過依賴,直接返回41 if (!depsMap) return42
43 const dep = depsMap.get(key)44 // 如果依賴不存在,表示這個 key 沒有在effect中被使用過,直接返回45 if (!dep) return46
47 // 找到依賴,觸發更新48 propagate(dep.subs)49}
reactive.ts
1import { isObject } from '@vue/shared'2import { mutableHandlers } from './baseHandlers'3
4/**5 * 儲存 target 和響應式物件的關聯關係6 * key:target / value:proxy7 */8
9const reactiveMap = new WeakMap()10
11/**12 * 保存使用所有使用 reactive 建立的響應式物件13 * 用於檢查是否重複 reactive14 */15const reactiveSet = new Set()38 collapsed lines
16
17function createReactiveObject(target) {18 // reactive 只處理物件19 if (!isObject(target)) return target20
21 // 如果這個 target 儲存在 reactiveSet 中22 // 表示 target 是一個響應式物件,直接返回已經建立好的 proxy23 if(reactiveSet.has(target)){24 return reactiveMap.get(target)25 }26
27 // 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy28 const existingProxy = reactiveMap.get(target)29 if (existingProxy) {30 return existingProxy31 }32
33 // 建立 target 的代理物件34 const proxy = new Proxy(target, mutableHandlers)35
36 // 儲存 target 和響應式物件的關聯關係37 reactiveMap.set(target, proxy)38
39 // 儲存使用 reactive 建立的響應式物件40 reactiveSet.add(proxy)41
42 return proxy43}44
45export function reactive(target) {46 return createReactiveObject(target)47}48
49// 判斷 target 是否為響應式物件50// 只要在 reactiveSet 中存在,就表示是響應式物件51export function isReactive(target) {52 return reactiveSet.has(target)53}
這六個情境案例,分別是:
- 快取機制:避免重複代理與依賴分裂。
- 身份辨識:區分原始物件、代理物件與
ref
。 - 效能優化:避免不必要的觸發。
- API 體驗:隱藏
.value
,提升開發者直覺。 - Lazy 策略:動態轉換巢狀物件,提升初始化效能。
- 工程化:抽離
handlers
,讓程式碼更具可維護性。
這些設計選擇的背後,都是在效能、易用性、與一致性之間的權衡。 理解這些極端案例,不只是能寫出響應式系統,更能了解 Vue 3 背後的設計思維。