遲早你會面對的問題,Vue3 Composition API 的 ref 與 reactive 差異是什麼?

Vue3 Composition API 的 ref 與 reactive 差異是什麼

前言

在 Vue 3 的 Composition API 中,我們可以透過 refreactive 來建立一個響應式的資料,但是這兩者差異在哪裡呢?所以我就寫了這一篇文章來簡單探討一下這兩者的差異在哪邊。

Note
底下範例許多都是需要看 console 的,所以記得打開 console 哦!

先談 ref

首先以官方文件來講,官方文件其實有說到一句話

In Composition API, the recommended way to declare reactive state is using the ref() function

(中文翻譯:Composition API 中,官方建議使用 ref 來建立響應式的資料。)

1
2
3
4
5
6
7
8
9
import { ref } from 'vue'

const count = ref(0)

console.log(count) // { value: 0 }
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

基本上如果你是一個 Vue 初學者的話,你應該會很好奇「為什麼後面要補一個 .value?」,而不是直接使用 count++ 這樣的寫法呢?

當你試著直接將 count 輸出的時候,你會發現 count 回傳的不會是 0,而是一個物件

See the Pen ref-example-1 by Ray (@hsiangfeng) on CodePen.

物件

我們可以看到 Vue 回傳了以下:

1
2
3
4
5
6
7
{
__v_isRef: true,
__v_isShallow: false,
_rawValue: 0,
_value: 0,
dep: undefined
}

而這物件就是 Vue 幫我們處理過後的響應式資料,讓我們可以透過 .value 來取得資料,而這也是為什麼我們要使用 count.value 來取得資料的原因。

如果你是比較資深一點的工程師,可能會發現一點問題,但我們這邊先不講,讓我們先繼續往下看其他的東西。

再談 reactive

接著讓我們來看一下 reactive 的用法

1
2
3
4
5
6
7
8
import { reactive } from 'vue'

const state = reactive({
count: 0
})

console.log(state) // { count: 0 }
console.log(state.count) // 0

See the Pen ref-example-1 by Ray (@hsiangfeng) on CodePen.

這是 Vue 中另一種宣告響應式狀態的另一種方式。

這時候如果你打開 consolestate,你會發現 Vue 回傳的是一個 Proxy 物件

Proxy

這下很有趣了,前面我們再聊 ref 的時候,我們發現他回傳的是一個純粹的物件

物件

但使用 reactive 卻又回傳了一個 Proxy 物件,這時候你應該會冒出一個疑問

「我聽說 Vue3 底層都是使用 Proxy 來做響應式的處理,那為什麼 ref 會回傳一個物件,而 reactive 卻回傳一個 Proxy 物件呢?」

別著急,讓我們先繼續往下聊。

refreactive 的差異

首先 reactive 只能接受特定的值,也就是說你只能傳入物件或陣列,如果你傳入其他的值,Vue 會回傳一個警告訊息

value cannot be made reactive

Note
在 JavaScript 陣列也是物件的一種,所以你可以使用 reactive 來建立一個響應式的陣列。

reactive 第二個問題是,你不能替換整個物件,也就是說你不能這樣做

1
2
3
4
5
6
7
8
9
import { reactive } from 'vue'

let state = reactive({
myName: 'Ray'
})

state = {
myName: 'QQ'
}

See the Pen reactive-example-3 by Ray (@hsiangfeng) on CodePen.

這問題很簡單,因為當你這樣寫的時候,你是在將原本透過 Vue 所建立出來的響應式物件給替換成了普通的物件,因此就會失去響應式物件的特性。

這時候你可能會像這樣寫

1
2
3
4
5
6
7
8
9
import { reactive } from 'vue'

const state = reactive({
myName: 'Ray'
})

state = reactive({
myName: 'QQ'
})

See the Pen reactive-example-3 by Ray (@hsiangfeng) on CodePen.

雖然這樣寫確實可以保有響應式物件的概念,但卻也會的導致 Vue 原有建立的 { count: 0 } 響應式物件失去。

除此之外 reactive 也無法支援解構賦值,也就是說你不能這樣寫

1
2
3
4
5
6
7
import { reactive } from 'vue'

const state = reactive({
myName: 'Ray'
})

const { myName } = state

ref 呢?ref 就比較沒有這些問題,畢竟他直接支援你傳入任何值

1
2
3
4
5
6
7
8
9
10
11
12
13
import { ref } from 'vue'

const num = ref(0)
const str = ref('hello')
const bool = ref(true)
const obj = ref({ count: 0 })
const arr = ref([1, 2, 3])

console.log(num.value) // 0
console.log(str.value) // 'hello'
console.log(bool.value) // true
console.log(obj.value) // { count: 0 }
console.log(arr.value) // [1, 2, 3]

若有需要重新賦予一個新物件時,也可以這樣寫

1
2
3
4
5
import { ref } from 'vue'

const state = ref({ myName: 'Ray' })

state.value = { myName: 'Ray' }

而非

1
2
3
4
5
6
7
8
9
import { ref } from 'vue'

const state = ref({ myName: 'Ray' })

state = { myName: 'Ray' }

// 或是

state = ref({ myName: 'Ray' })

透過 ref,我們可以確保從頭到尾都是操作同一個響應式物件,而不會像 reactive 一樣,因為重新賦予一個新物件而導致原本的響應式物件失去。

但每次使用 ref 的時候我們都必須要用 .value 來取得資料,這樣寫起來會比較麻煩,所以其實你也可以使用 reactive 來組合自動解構(但是這樣寫起來會比較麻煩,我也沒這樣寫過)

1
2
3
4
5
6
import { reactive, ref } from 'vue'

const myName = ref('Ray')
const state = reactive({ myName })

console.log(state.myName) // 0

這也是為什麼官方文件會比較推薦你直接使用 ref 來建立響應式物件。

為什麼 ref 會回傳一個物件?

對於 refreactive 兩者之間的差異有一定的認知之後,接著我要來回頭補一個坑,也就是前面所提到的問題

「為什麼 ref 會回傳一個物件?」

這我們要談就必須要拉一下 Vue3 的底層了,讓我們來看一下 Vue3 的底層是怎麼實作這一段的

1
2
3
4
5
6
function createRef(rawValue: unknown, shallow: boolean) {
if (isRef(rawValue)) {
return rawValue
}
return new RefImpl(rawValue, shallow)
}

這是 Vue3 中 ref 的實作,我們可以看到他會先判斷傳入的值是否為 ref,如果是的話就直接回傳,如果不是的話就會建立一個新的 RefImpl 物件。

接著追朔 RefImpl 的實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class RefImpl<T> {
private _value: T
private _rawValue: T

public dep?: Dep = undefined
public readonly __v_isRef = true

constructor(
value: T,
public readonly __v_isShallow: boolean,
) {
this._rawValue = __v_isShallow ? value : toRaw(value)
this._value = __v_isShallow ? value : toReactive(value)
}

get value() {
trackRefValue(this)
return this._value
}

set value(newVal) {
const useDirectValue =
this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
newVal = useDirectValue ? newVal : toRaw(newVal)
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
this._value = useDirectValue ? newVal : toReactive(newVal)
triggerRefValue(this, DirtyLevels.Dirty, newVal)
}
}
}

這是 RefImpl 的實作,我們可以看到他會建立一個 _value_rawValue,而 _value 就是我們透過 .value 取得的值,而 _rawValue 則是原始的值,因此 ref 實際上的實作是透過 RefImpl 的 gettersetter 來實現的響應式,而非透過 Proxy,但也不代表這一段是使用 Object.defineProperty 來實作,只是整體概念與 Object.defineProperty 類似。

另外,當 ref 建立時,如果你傳入的不是基本型別,而是物件/陣列時,Vue 會使用 reactive 轉換成 Proxy 物件

See the Pen reactive-example-4 by Ray (@hsiangfeng) on CodePen.

Proxy

reactive 呢?讓我們來看一下 reactive 的實作

1
2
3
4
5
6
7
8
9
10
11
12
13
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (isReadonly(target)) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap,
)
}

我們可以看到 reactive 的實作是透過 createReactiveObject 來建立一個響應式物件,而 createReactiveObject 的實作如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<Target, any>,
) {
if (!isObject(target)) {
if (__DEV__) {
console.warn(`value cannot be made reactive: ${String(target)}`)
}
return target
}
// target is already a Proxy, return it.
// exception: calling readonly() on a reactive object
if (
target[ReactiveFlags.RAW] &&
!(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
return target
}
// target already has corresponding Proxy
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// only specific value types can be observed.
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
return target
}
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
)
proxyMap.set(target, proxy)
return proxy
}

我們可以看到 reactive 的實作是透過 Proxy 來實作的,而 ref 則是透過 RefImpl 來實作的,這也是為什麼 ref 會回傳一個物件,而 reactive 卻回傳一個 Proxy 物件的原因。

Watch 深層監聽

了解這兩者實作差異有什麼幫助呢?其實你會發現 watch 是會隨著你傳入不同的 refreactive 而有不同的結果,舉例來講,官方文件的範例是這樣:

1
2
3
4
5
6
7
const obj = reactive({ count: 0 })

watch(obj, (newValue, oldValue) => {
console.log(newValue.count, oldValue.count)
})

obj.count++

但很難看出來吧?所以讓我們改一下範例:

1
2
3
4
5
6
7
8
9
10
11
12
import { reactive, watch } from 'vue'

const obj = reactive({
data: {
myName: 'Ray',
}
})

watch(obj, (newValue, oldValue) => {
console.log(newValue, oldValue)
alert('我被觸發囉!')
})

當我們修改了 myName 之後會被立刻觸發 watch,而這就是深層監聽的概念

See the Pen watch-example-6 by Ray (@hsiangfeng) on CodePen.

但如果我們將 reactive 改成 ref 的話,你會發現這個範例是不會觸發的

1
2
3
4
5
6
7
8
9
10
11
12
import { ref,watch } from 'vue'

const obj = ref({
data: {
myName: 'Ray',
}
})

watch(obj, (newValue, oldValue) => {
console.log(newValue, oldValue)
alert('我被觸發囉!')
})

See the Pen watch-example-6 by Ray (@hsiangfeng) on CodePen.

這也是為什麼當你使用 watch 的時候,當你傳入的如果是一個 reactive 時,你可以不用 deep(深度監聽),但如果你傳入的是 ref 時,你就必須要使用 deep(深度監聽)的原因。

那為什麼會這樣呢?我們也可以翻一下 watch 的實作,有一段是這樣…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// watch
function doWatch(){
// 省略其他程式碼...
if (isRef(source)) {
getter = () => source.value
forceTrigger = isShallow(source)
} else if (isReactive(source)) {
getter = () => reactiveGetter(source)
forceTrigger = true
} else if (isArray(source)) {
isMultiSource = true
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
getter = () =>
source.map(s => {
if (isRef(s)) {
return s.value
} else if (isReactive(s)) {
return reactiveGetter(s)
} else if (isFunction(s)) {
return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
} else {
__DEV__ && warnInvalidSource(s)
}
})
}
// 省略其他程式碼
}

我們可以看到當我們傳入的 reactive 時,Vue 會把 deep 設為 true,這就是為什麼我們可以不用傳入 deep 哩。

結論

那麼最後下個結論好了,實戰上到底該用 ref 還是 reactive 呢?其實我個人認為這很看團隊開發習慣,但如果是以 Vue 官方文件來講,其實是建議你用 ref,畢竟 ref 就已經幫你做了滿多事情,而我自己的開發習慣也比較多使用 ref

另一種更簡單的辨別方式,就是如果你會需要重新覆蓋資料的話,那就用 ref,如果你不會需要重新覆蓋資料的話,那就用 reactive 這樣也可以。

最後最後的結論重點…

基本上,當你傳入的是基本資料型別時,ref 會用 RefImpl 來實作,當你是物件/陣列時,ref 會用 reactive 來實作,這件事情就可以稍微知道一下,或許哪天面試考官會問你這問題(笑)。

Liker 讚賞

這篇文章如果對你有幫助,你可以花 30 秒登入 LikeCoin 並點擊下方拍手按鈕(最多五下)免費支持與牡蠣鼓勵我。
或者你可以也可以請我「喝一杯咖啡(Donate)」。

Buy Me A Coffee Buy Me A Coffee

Google AD

撰寫一篇文章其實真的很花時間,如果你願意「關閉 Adblock (廣告阻擋器)」來支持我的話,我會非常感謝你 ヽ(・∀・)ノ