來聊聊 Vue3 Composition API 變數宣告的另一種設計

前言

最近在跟朋友聊 Vue Composition API 的時候,順便翻了一下官方文件,剛好看到一個區塊滿有趣的就順便寫一篇文章來記錄哩。

Vue3 Composition API 變數宣告

基本上我們在 Vue3 Composition API 中宣告變數的方式有兩種,一種是使用 ref,另一種是使用 reactive,這兩種有什麼差別呢?底下是一個 Vue 範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
<p>{{ name }}</p>
<button @click="changeName">點我改名字</button>
</div>
</template>

<script setup>
import { ref } from 'vue'

const name = ref('我是誰!')

function changeName() {
name.value = 'Ray'
}
</script>

當你點擊「點我改名字」按鈕後,畫面上的文字會從「我是誰!」變成「Ray」。

reactive 的使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div>
<p>{{ name }}</p>
<button @click="changeName">點我改名字</button>
</div>
</template>

<script setup>
import { reactive } from 'vue'

const state = reactive({
name: '我是誰!'
})

function changeName() {
state.name = 'Ray'
}
</script>

這兩種宣告方式就是 Vue3 Composition API 常見的變數宣告方式,我相信身為 Vue 開發者的你應該都不陌生。

Note
如果想更深入了解 refreactive 的差異,可以參考遲早你會面對的問題,Vue3 Composition API 的 ref 與 reactive 差異是什麼?

那麼為什麼突然提到變數宣告呢?因為某一天朋友在抱怨…

「前陣子我們在維護專案的時候,不小心有一個同事在 .value 補了 = null,後來查才知道是同事的 VSCode 自動補全功能幫他補的,結果因為這行為導致了一個功能異常,真的是太可怕了!」

可能會有人認為這件事情可以透過 Code Review 來避免,但是這樣的事情真的是有可能發生,所以我就想到了 Vue 官方文件中有一個有趣的區塊,也就是「深入響應式系統」頁面底下有一個「與信號(Signal)的關係」…

信號(Signal)

其實前陣子 React 圈有在討論這個議題,但這邊我們並沒有打算深入探討 React 或是 Signal 這個議題,所以讓我們拉回到 Vue 的世界。

首先先讓我們來看一下範例:

See the Pen Vue Signal by Ray (@hsiangfeng) on CodePen.

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
<template>
<div>
<p>{{ name() }}</p>
<button @click="changeName">點我改名字</button>
</div>
</template>


<script setup>
import { shallowRef, triggerRef } from 'vue'

function createSignal(value, options) {
const r = shallowRef(value)
const get = () => r.value
const set = (v) => {
r.value = typeof v === 'function' ? v(r.value) : v
if (options?.equals === false) triggerRef(r)
}
return [get, set]
}


const [ name, setName ] = createSignal('你是誰?')

const changeName = () => {
setName('我是 Ray')
}
</script>

你會發現這邊跟我們以往使用 ref 或是 reactive 宣告變數的方式有點不一樣,我們是透過了一個自定義的 createSignal 函式來宣告變數,你會發現我們是透過 name() 來取得值,而不是 name,如果要更新畫面的話,我們則是透過 setName 來更新值。

有沒有一種好像似曾相識的感覺呢?沒錯,這跟 React 的 useState 有點類似

1
2
3
import { useState } from 'react'

const [ name, setName ] = useState('你是誰?')

但是這邊我們是透過 Vue3 Composition API 來實現的,所以你也可以這樣命名:

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
<template>
<div>
<p>{{ name() }}</p>
<button @click="changeName">點我改名字</button>
</div>
</template>


<script setup>
import { shallowRef, triggerRef } from 'vue'

function useState(value, options) {
const r = shallowRef(value)
const get = () => r.value
const set = (v) => {
r.value = typeof v === 'function' ? v(r.value) : v
if (options?.equals === false) triggerRef(r)
}
return [get, set]
}


const [ name, setName ] = useState('你是誰?')

const changeName = () => {
setName('我是 Ray')
}
</script>

See the Pen Vue Signal by Ray (@hsiangfeng) on CodePen.

一整個超像 React 了吧(笑)

那麼我們接下來分析一下官方文件提供的 createSignal 函式吧!

createSignal

首先讓我們來看一下 createSignal 的函式:

1
2
3
4
5
6
7
8
9
10
11
import { shallowRef, triggerRef } from 'vue'

function createSignal(value, options) {
const r = shallowRef(value)
const get = () => r.value
const set = (v) => {
r.value = typeof v === 'function' ? v(r.value) : v
if (options?.equals === false) triggerRef(r)
}
return [get, set]
}

首先我們可以看到一開始引入了 shallowReftriggerRef 這兩個 API

  • shallowRef:宣告建立一個 ref,但這個 API 有一點特別,它並不會進行深層的監聽,也就是說當你更新了物件的其中一個屬性時,並不會觸發畫面更新
  • triggerRef:則是用來強制觸發更新用,因為 shallowRef 並不會進行深層的監聽,所以當你更新了物件的其中一個屬性時,並不會觸發畫面更新,這時候你就可以透過 triggerRef 來強制觸發畫面更新

有點難懂這兩個 API 吧?讓我們直接來看一個範例,首先底下是一個使用 ref 的範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div>
<p>{{ state.name }}</p>
<button @click="changeName">點我改名字</button>
</div>
</template>

<script setup>
import { ref } from 'vue'

const state = ref({
name: '你是誰?'
})

function changeName() {
state.value.name = '我是 Ray'
}
</script>

See the Pen Vue Signal-2 by Ray (@hsiangfeng) on CodePen.

這一段理論上是沒有什麼問題的,但如果今天變成改用 shallowRef 就不一樣了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div>
<p>{{ state.name }}</p>
<button @click="changeName">點我改名字</button>
</div>
</template>

<script setup>
import { shallowRef, triggerRef } from 'vue'

const state = shallowRef({
name: '你是誰?'
})

function changeName() {
state.value.name = '我是 Ray'
}
</script>

See the Pen Vue Signal-3 by Ray (@hsiangfeng) on CodePen.

你會發現當你不管怎麼點擊「點我改名字」的按鈕,畫面永遠都不會更新,因為 shallowRef 並不會進行深層的監聽,所以當你更新了物件的其中一個屬性時,並不會觸發畫面更新,除非是替換整個物件:

1
2
3
4
5
6
const state = shallowRef({
name: '你是誰?'
})

state.value.name = '我是 Ray' // 不會觸發畫面更新
state.value = { name: '我是 Ray' } // 會觸發畫面更新

這時候你就可以透過 triggerRef 來強制觸發畫面更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>
<p>{{ state.name }}</p>
<button @click="changeName">點我改名字</button>
</div>
</template>

<script setup>
import { shallowRef, triggerRef } from 'vue'

const state = shallowRef({
name: '你是誰?'
})

function changeName() {
state.value.name = '我是 Ray'
triggerRef(state)
}
</script>

See the Pen Vue Signal-4 by Ray (@hsiangfeng) on CodePen.

這時候可能會有人想說,那我直接用 ref 不就好了?為什麼要這麼麻煩使用 shallowReftriggerRef 呢?其主要原因是在比較大的專案上很容易遇到資料結構複雜的情況,這時候就會需要使用到效能更好的 shallowRef 搭配 triggerRef 來進行監聽與更新,畢竟 ref 會進行深層的監聽,這樣可能會有效能上的問題。

接下來我會針對 createSignal 函式每一行程式碼進行解釋:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 引入淺層的 shallowRef 跟 強制觸發更新的 triggerRef API
import { shallowRef, triggerRef } from 'vue'

// 建立一個函式叫做 createSignal,並傳入兩個參數,分別是 value 跟 options
// options 是一個物件,裡面有一個 equals 的屬性
function createSignal(value, options) {
// 將 value 透過 shallowRef 進行監聽
const r = shallowRef(value)
// 取得 r 的值並回傳,這邊會是只有你在呼叫 get 時才會取得 r 的值,而非一開始就取得值
const get = () => r.value
// 設定 r 的值,並接收一個參數 v
const set = (v) => {
// 如果 v 是一個函式的話,就將 r 的值傳入函式中,否則就直接將 v 設定給 r
r.value = typeof v === 'function' ? v(r.value) : v
// 如果 options?.equals 是 false 的話,就強制觸發 r 的更新
if (options?.equals === false) triggerRef(r)
}

// 回傳一個陣列,裡面有兩個函式,分別是 get 跟 set
return [get, set]
}

其實整體來講官方給的範例是相當不錯的,也幫你將一些問題給避免掉了,例如如果建立變數時是一個物件的話,假設如下:

1
2
3
4
5
6
7
8
9
const [ name, setName ] = createSignal({
name: '你是誰?'
})

const changeName = () => {
setName((v) => {
v.name = 'QQ 先生'
})
}

你會發現是無法更新的,所以這時候你可以增加一個 equals 的屬性,來強制觸發更新,這樣就不用擔心物件的其中一個屬性更新時,畫面不會更新的問題哩~

1
2
3
4
5
6
7
8
9
const [ name, setName ] = createSignal({
name: '你是誰?'
}, { equals: false })

const changeName = () => {
setName((v) => {
v.name = 'QQ 先生'
})
}