嘿,日安!

Day 20 - Reactive:reactive 極端案例

28th September 2025
Front-end
Vue3
IT鐵人賽2025
從零到一打造 Vue3 響應式系統
Last updated:29th September 2025
17 Minutes
3295 Words

我們完成 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:0
22
}
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 = 1
34
}, 1000)
35
</script>
36
</body>
37
38
</html>

default

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

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

1
function createReactiveObject(target) {
2
3
if (!isObject(target)) return target
4
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 res
15
}
4 collapsed lines
16
})
17
18
return proxy
19
}

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

1
/**
2
* 儲存 target 和響應式物件的關聯關係
3
* key:target / value:proxy
4
*/
5
6
const reactiveMap = new WeakMap()
7
8
function createReactiveObject(target) {
9
// reactive 只處理物件
10
if (!isObject(target)) return target
11
12
// 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
13
const existingProxy = reactiveMap.get(target)
14
if (existingProxy) {
15
return existingProxy
19 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 res
27
}
28
})
29
30
// 儲存 target 和響應式物件的關聯關係
31
reactiveMap.set(target, proxy)
32
33
return proxy
34
}

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

  • 記憶體浪費:重複建立無用的代理物件。
  • 依賴分裂:兩個不同的 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:0
9
}
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
* 用於檢查是否重複 reactive
4
*/
5
const reactiveSet = new Set()
6
7
function createReactiveObject(target) {
8
// reactive 只處理物件
9
if (!isObject(target)) return target
10
11
// 如果這個 target 儲存在 reactiveSet 中
12
// 表示 target 是一個響應式物件,直接返回已經建立好的 proxy
13
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 proxy
27
}
28
29
// 判斷 target 是否為響應式物件
30
// 只要在 reactiveSet 中存在,就表示是響應式物件
31
export 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:0
9
})
10
11
effect(() => {
12
console.log(state.a)
13
})
14
15
setTimeout(() => {
4 collapsed lines
16
state.a = 0
17
}, 1000)
18
</script>
19
</body>

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

default

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

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

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

引入到reactive.ts

reactive.ts
1
import { isObject, hasChanged } from '@vue/shared'
2
...
3
...
4
set(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 res
12
}
13
...

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

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

ref.ts
1
import { isObject } from '@vue/shared'
2
import { reactive } from './reactive'
3
4
enum ReactiveFlags {
5
IS_REF = '__v_isRef'
6
}
7
8
class RefImpl {
9
_value;
10
[ReactiveFlags.IS_REF] = true
11
12
subs: Link
13
subsTail: Link
14
constructor(value) {
20 collapsed lines
15
// 如果 value 是物件,則使用 reactive 轉換為響應式物件
16
this._value = isObject(value) ? reactive(value) : value
17
}
18
19
get value() {
20
if (activeSub) {
21
trackRef(this)
22
}
23
return this._value
24
}
25
26
set value(newValue) {
27
// 如果新值和舊值發生過變化,則更新
28
if(hasChanged(newValue, this._value)){
29
// 如果新值是物件,則使用 reactive 轉換為響應式物件
30
this._value = isObject(newValue) ? reactive(newValue) : newValue
31
triggerRef(this)
32
}
33
}
34
}

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

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

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

  • 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,就直接把值給他,不用.value
8
const a = ref(0)
9
const state = reactive({
10
a
11
})
12
13
effect(() => {
14
// 不用 state.a.value 也可以拿到值
15
console.log('reactive', state.a)
3 collapsed lines
16
})
17
</script>
18
</body>

default

  • 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
a
10
})
11
12
effect(() => {
13
console.log('reactive', state.a)
14
})
15
7 collapsed lines
16
setTimeout(() => {
17
//這樣 value 同步更新
18
state.a = 1
19
console.log('ref', a.value)
20
}, 1000)
21
</script>
22
</body>

default

  • 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
a
10
})
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>

default

實作

  • ref 傳入 reactive,解構 .value

    reactive.ts
    1
    ...
    2
    ...
    3
    get(target, key, receiver) {
    4
    // 收集依賴:綁定target的屬性與effect的關係
    5
    track(target, key)
    6
    const res = Reflect.get(target, key,receiver)
    7
    // 如果 res 是一個 ref,則返回 res.value
    8
    if(isRef(res)){
    9
    return res.value
    10
    }
    11
    return res
    12
    },
    13
    ...
    14
    ...
    1 collapsed line
    15
    }

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

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

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

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

再來修改 Proxy 物件 setter

1
set(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.value
9
* a.value = 1
10
*/
11
if(isRef(oldValue) && !isRef(newValue)){
12
oldValue.value = newValue
13
14
// 改了 ref 的值,會通知 sub 更新
15
// 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次
12 collapsed lines
16
return true
17
}
18
19
const res = Reflect.set(target, key, newValue, receiver)
20
21
if(hasChanged(newValue, oldValue)){
22
// 如果舊值不等於新值,則觸發更新
23
// 觸發更新:通知之前收集的依賴,重新執行effect
24
trigger(target, key)
25
}
26
return res
27
}

第六種情況:初始化巢狀 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: 0
10
}
11
})
12
13
effect(() => {
14
console.log(state.a.b)
15
})
6 collapsed lines
16
17
setTimeout(() => {
18
state.a.b = 1
19
}, 1000)
20
</script>
21
</body>

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

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

default

1
get(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.value
7
}
8
return res
9
},

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

1
export function trigger(target, key) {
2
console.log('trigger', target, key)
3
...
4
}
5
// 沒有反應

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

1
get(target, key, receiver) {
2
track(target, key)
3
const res = Reflect.get(target, key,receiver)
4
if(isRef(res)){
5
return res.value
6
}
7
8
if(isObject(res)){
9
/**
10
* 如果 res 是物件,則將其轉換為響應式物件
11
*/
12
return reactive(res)
13
}
14
return res
15
},

重構調整程式碼

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

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

baseHandlers.ts

1
import { hasChanged, isObject } from '@vue/shared'
2
import { track, trigger } from './dep'
3
import { isRef } from './ref'
4
import { reactive } from './reactive'
5
6
export 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.value
12
if(isRef(res)){
13
// target = {a:ref(0)}
14
return res.value
15
}
36 collapsed lines
16
17
if(isObject(res)){
18
/**
19
* 如果 res 是物件,則將其轉換為響應式物件
20
*/
21
return reactive(res)
22
}
23
return res
24
},
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.value
32
* a.value = 1
33
*/
34
if(isRef(oldValue) && !isRef(newValue)){
35
oldValue.value = newValue
36
37
// 改了 ref 的值,會通知 sub 更新
38
// 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次
39
return true
40
}
41
42
const res = Reflect.set(target, key, newValue, receiver)
43
44
if(hasChanged(newValue, oldValue)){
45
// 如果舊值不等於新值,則觸發更新
46
// 觸發更新:通知之前收集的依賴,重新執行effect
47
trigger(target, key)
48
}
49
return res
50
}
51
}

dep.ts

1
import { Link, link, propagate } from "./system"
2
import { activeSub } from "./effect"
3
4
class Dep {
5
subs: Link
6
subsTail: Link
7
constructor() { }
8
}
9
10
const targetMap = new WeakMap()
11
12
export function track(target, key) {
13
if (!activeSub) return
14
// 透過 targetMap 取得 target 的依賴
15
34 collapsed lines
16
let depsMap = targetMap.get(target)
17
18
// 首次收集依賴,之前沒有收集過,就新建一個
19
// key:obj / value:depsMap
20
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:Dep
30
if (!dep) {
31
dep = new Dep()
32
depsMap.set(key, dep)
33
}
34
35
link(dep, activeSub)
36
}
37
38
export function trigger(target, key) {
39
const depsMap = targetMap.get(target)
40
// 如果 depsMap 不存在,表示沒有收集過依賴,直接返回
41
if (!depsMap) return
42
43
const dep = depsMap.get(key)
44
// 如果依賴不存在,表示這個 key 沒有在effect中被使用過,直接返回
45
if (!dep) return
46
47
// 找到依賴,觸發更新
48
propagate(dep.subs)
49
}

reactive.ts

1
import { isObject } from '@vue/shared'
2
import { mutableHandlers } from './baseHandlers'
3
4
/**
5
* 儲存 target 和響應式物件的關聯關係
6
* key:target / value:proxy
7
*/
8
9
const reactiveMap = new WeakMap()
10
11
/**
12
* 保存使用所有使用 reactive 建立的響應式物件
13
* 用於檢查是否重複 reactive
14
*/
15
const reactiveSet = new Set()
38 collapsed lines
16
17
function createReactiveObject(target) {
18
// reactive 只處理物件
19
if (!isObject(target)) return target
20
21
// 如果這個 target 儲存在 reactiveSet 中
22
// 表示 target 是一個響應式物件,直接返回已經建立好的 proxy
23
if(reactiveSet.has(target)){
24
return reactiveMap.get(target)
25
}
26
27
// 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
28
const existingProxy = reactiveMap.get(target)
29
if (existingProxy) {
30
return existingProxy
31
}
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 proxy
43
}
44
45
export function reactive(target) {
46
return createReactiveObject(target)
47
}
48
49
// 判斷 target 是否為響應式物件
50
// 只要在 reactiveSet 中存在,就表示是響應式物件
51
export function isReactive(target) {
52
return reactiveSet.has(target)
53
}

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

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

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

Article title:Day 20 - Reactive:reactive 極端案例
Article author:日安
Release time:28th September 2025