我們完成 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>
當我們將同一個原始物件多次傳入 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
}
如果沒有快取機制,會導致以下問題:
<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 的身份,否則會陷入無窮的代理鏈。
<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 的行為是不會重複觸發,因為它會檢查新舊值是否相同。
所以我們現在要做的是,當設定的新值與舊值相同時,我們應該避免觸發不必要的更新通知。
先在 @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
的型別,如果它是物件就使用 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。
為了正確處理 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>
<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>
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>
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
}
<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 的物件(紅色)不屬於響應式,但最外層物件(橘色)是屬於響應式。
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
。.value
,提升開發者直覺。handlers
,讓程式碼更具可維護性。這些設計選擇的背後,都是在效能、易用性、與一致性之間的權衡。
理解這些極端案例,不只是能寫出響應式系統,更能了解 Vue 3 背後的設計思維。
同步更新《嘿,日安!》技術部落格