Vue ref 與 reactive

refreactive 是 Vue 3 Composition API 中最常用的兩個響應式 API。了解它們的差異和使用時機是寫好 Vue 3 程式碼的關鍵。

ref

ref() 用於建立一個響應式的參考值(reference),可以包裝任何型別的值:

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

// 基本型別
const count = ref(0)
const message = ref('Hello Vue!')
const isActive = ref(true)

// 物件
const user = ref({
  name: 'John',
  age: 30
})

// 陣列
const items = ref(['apple', 'banana', 'orange'])
</script>

存取 ref 的值

在 JavaScript 中,需要透過 .value 來存取和修改 ref 的值:

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

const count = ref(0)

// 讀取
console.log(count.value)  // 0

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

// 對於物件 ref
const user = ref({ name: 'John' })
user.value.name = 'Jane'  // 修改物件屬性
user.value = { name: 'Bob' }  // 替換整個物件
</script>

在模板中,Vue 會自動解包 ref,不需要使用 .value

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

const count = ref(0)
const user = ref({ name: 'John' })
</script>

<template>
  <!-- 模板中不需要 .value -->
  <p>{{ count }}</p>
  <p>{{ user.name }}</p>
  <button @click="count++">+1</button>
</template>

ref 用於 DOM 元素

ref 也可以用來取得 DOM 元素的參考:

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

const inputRef = ref(null)

onMounted(() => {
  // 元件掛載後,inputRef.value 會是 DOM 元素
  inputRef.value.focus()
})
</script>

<template>
  <input ref="inputRef" type="text">
</template>

更多 Template Refs 的用法請參考 Template Refs

reactive

reactive() 用於建立一個響應式的物件:

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

const state = reactive({
  count: 0,
  user: {
    name: 'John',
    age: 30
  },
  items: ['apple', 'banana']
})
</script>

存取 reactive 的值

reactive 不需要 .value,直接存取屬性即可:

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

const state = reactive({
  count: 0,
  user: { name: 'John' }
})

// 直接存取和修改
console.log(state.count)  // 0
state.count++
state.user.name = 'Jane'
</script>

<template>
  <p>{{ state.count }}</p>
  <p>{{ state.user.name }}</p>
</template>

reactive 的限制

  1. 只能用於物件型別
import { reactive } from 'vue'

// ❌ 不能用於基本型別
const count = reactive(0)  // 不會是響應式

// ✅ 包裝在物件中
const state = reactive({ count: 0 })
  1. 不能替換整個物件
let state = reactive({ count: 0 })

// ❌ 會失去響應性
state = reactive({ count: 1 })

// ✅ 修改屬性
state.count = 1
  1. 解構會失去響應性
const state = reactive({ count: 0, name: 'Vue' })

// ❌ 解構後的變數不是響應式的
let { count, name } = state
count++  // 不會觸發更新

ref vs reactive 比較

特性refreactive
支援的型別任何型別只有物件型別
存取方式需要 .value直接存取
替換整個值✅ 可以❌ 會失去響應性
解構需要注意會失去響應性
模板中自動解包N/A

何時使用 ref?

  • 基本型別的值(string、number、boolean)
  • 需要替換整個值的情況
  • DOM 元素參考
  • 單一的獨立值
// ✅ 適合使用 ref
const count = ref(0)
const isLoading = ref(false)
const selectedId = ref(null)
const inputRef = ref(null)

何時使用 reactive?

  • 一組相關的資料
  • 類似於元件的 data 物件
  • 不需要替換整個物件的情況
// ✅ 適合使用 reactive
const form = reactive({
  username: '',
  email: '',
  password: ''
})

const mouse = reactive({
  x: 0,
  y: 0
})

實務建議

許多 Vue 開發者傾向統一使用 ref,因為:

  1. 不需要記住什麼型別用什麼 API
  2. 明確的 .value 讓程式碼更清楚哪些是響應式資料
  3. 可以任意替換整個值
<script setup>
import { ref } from 'vue'

// 統一使用 ref
const count = ref(0)
const user = ref({ name: 'John', age: 30 })
const items = ref(['a', 'b', 'c'])

// 可以任意替換
user.value = { name: 'Jane', age: 25 }
items.value = ['x', 'y', 'z']
</script>

響應式工具函式

toRef

從 reactive 物件中取出單一屬性並保持響應性:

import { reactive, toRef } from 'vue'

const state = reactive({
  foo: 1,
  bar: 2
})

// 建立一個連結到 state.foo 的 ref
const fooRef = toRef(state, 'foo')

fooRef.value++
console.log(state.foo)  // 2

state.foo++
console.log(fooRef.value)  // 3

也可以用於 props:

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

const props = defineProps(['foo'])

// 建立一個響應式的 ref,會追蹤 props.foo 的變化
const fooRef = toRef(props, 'foo')
</script>

toRefs

將 reactive 物件的所有屬性轉為 ref:

import { reactive, toRefs } from 'vue'

const state = reactive({
  foo: 1,
  bar: 2
})

// 解構但保持響應性
const { foo, bar } = toRefs(state)

foo.value++
console.log(state.foo)  // 2

這在 composable 函式中很常用:

// useCounter.js
import { reactive, toRefs } from 'vue'

export function useCounter() {
  const state = reactive({
    count: 0,
    doubled: computed(() => state.count * 2)
  })

  function increment() {
    state.count++
  }

  // 返回時用 toRefs,讓使用者可以解構
  return {
    ...toRefs(state),
    increment
  }
}

// 使用
const { count, doubled, increment } = useCounter()
// count 和 doubled 仍然是響應式的

toValue(Vue 3.3+)

將 ref 或 getter 函式轉為普通值:

import { ref, toValue } from 'vue'

const count = ref(1)
const getter = () => 2

toValue(count)   // 1
toValue(getter)  // 2
toValue(3)       // 3

這在寫 composable 時很有用,讓參數可以接受 ref、getter 或普通值:

import { toValue } from 'vue'

// 參數可以是 ref、getter 或普通值
function useFeature(input) {
  // 統一取值
  const value = toValue(input)
}

unref

unreftoValue 的舊版本,只能處理 ref:

import { ref, unref } from 'vue'

const count = ref(1)

unref(count)  // 1
unref(1)      // 1

// 等同於
val = isRef(val) ? val.value : val

實際範例

表單狀態管理

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

const form = reactive({
  username: '',
  email: '',
  password: '',
  confirmPassword: ''
})

const errors = reactive({
  username: '',
  email: '',
  password: ''
})

const isValid = computed(() => {
  return form.username.length >= 3 &&
         form.email.includes('@') &&
         form.password.length >= 6 &&
         form.password === form.confirmPassword
})

function validate() {
  errors.username = form.username.length < 3 ? '使用者名稱至少 3 個字元' : ''
  errors.email = !form.email.includes('@') ? '請輸入有效的 Email' : ''
  errors.password = form.password.length < 6 ? '密碼至少 6 個字元' : ''
}

function submit() {
  validate()
  if (isValid.value) {
    console.log('提交表單', form)
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <div>
      <input v-model="form.username" placeholder="使用者名稱">
      <span class="error">{{ errors.username }}</span>
    </div>
    <div>
      <input v-model="form.email" type="email" placeholder="Email">
      <span class="error">{{ errors.email }}</span>
    </div>
    <div>
      <input v-model="form.password" type="password" placeholder="密碼">
      <span class="error">{{ errors.password }}</span>
    </div>
    <div>
      <input v-model="form.confirmPassword" type="password" placeholder="確認密碼">
    </div>
    <button type="submit" :disabled="!isValid">送出</button>
  </form>
</template>

計數器(比較兩種寫法)

使用 ref

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

const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value++
}

function decrement() {
  count.value--
}

function reset() {
  count.value = 0
}
</script>

使用 reactive

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

const state = reactive({
  count: 0
})

const doubled = computed(() => state.count * 2)

function increment() {
  state.count++
}

function decrement() {
  state.count--
}

function reset() {
  state.count = 0
}
</script>

混合使用

實務上,通常會根據資料的特性混合使用:

<script setup>
import { ref, reactive, computed } from 'vue'

// 單一值用 ref
const isLoading = ref(false)
const error = ref(null)
const selectedId = ref(null)

// 相關資料用 reactive
const filters = reactive({
  search: '',
  category: 'all',
  sortBy: 'date'
})

// 列表資料用 ref(可能需要替換整個陣列)
const items = ref([])

// computed 取決於使用的資料
const filteredItems = computed(() => {
  return items.value.filter(item => {
    if (filters.category !== 'all' && item.category !== filters.category) {
      return false
    }
    if (filters.search && !item.name.includes(filters.search)) {
      return false
    }
    return true
  })
})

async function fetchItems() {
  isLoading.value = true
  error.value = null

  try {
    const response = await fetch('/api/items')
    items.value = await response.json()
  } catch (e) {
    error.value = e.message
  } finally {
    isLoading.value = false
  }
}
</script>