Vue 響應式原理

響應式(Reactivity)是 Vue 最核心的特性之一。當你修改響應式資料時,Vue 會自動追蹤變化並更新相關的 DOM,讓你不需要手動操作 DOM。

什麼是響應式?

簡單來說,響應式就是「資料改變時,畫面自動更新」:

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

const count = ref(0)

function increment() {
  count.value++  // 修改資料
  // 畫面會自動更新,不需要手動操作 DOM
}
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

Vue 3 響應式系統原理

Vue 3 使用 JavaScript Proxy 來實現響應式系統,這比 Vue 2 使用的 Object.defineProperty 更強大、更完整。

Proxy 的優勢

特性Vue 2 (Object.defineProperty)Vue 3 (Proxy)
監聽物件屬性新增/刪除❌ 不支援✅ 支援
監聽陣列索引變化❌ 不支援✅ 支援
監聽 Map/Set❌ 不支援✅ 支援
效能遞迴遍歷所有屬性惰性處理

簡化的響應式原理

// 這是簡化後的概念說明

// Vue 使用 Proxy 攔截物件的讀取和寫入操作
const handler = {
  get(target, key) {
    track(target, key)  // 追蹤依賴
    return target[key]
  },
  set(target, key, value) {
    target[key] = value
    trigger(target, key)  // 觸發更新
    return true
  }
}

const proxy = new Proxy(data, handler)

當你讀取響應式資料時,Vue 會「追蹤」這個讀取操作,記錄下誰在使用這個資料;當你修改資料時,Vue 會「觸發」更新,通知所有使用這個資料的地方重新執行。

響應式 API

Vue 3 提供了幾個核心的響應式 API:

ref()

ref() 用於建立一個響應式的值,可以是任何型別:

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

// 基本型別
const count = ref(0)
const message = ref('Hello')

// 在 JavaScript 中需要用 .value 存取
console.log(count.value)  // 0
count.value++

// 在模板中不需要 .value
</script>

<template>
  <p>{{ count }}</p>
  <p>{{ message }}</p>
</template>

reactive()

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

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

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

// 不需要 .value
state.count++
state.user.name = 'Jane'
</script>

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

更多 refreactive 的用法請參考 ref 與 reactive

響應式的限制

只對物件型別有效

reactive() 只對物件型別(Object、Array、Map、Set)有效,不能用於基本型別(string、number、boolean):

import { reactive } from 'vue'

// ❌ 不行!基本型別無法使用 reactive
const count = reactive(0)

// ✅ 使用 ref 包裝基本型別
import { ref } from 'vue'
const count = ref(0)

不能替換整個物件

替換整個響應式物件會失去響應性:

import { reactive } from 'vue'

let state = reactive({ count: 0 })

// ❌ 這會失去響應性!
state = reactive({ count: 1 })
// 之前的 state 已經和模板脫鉤了

正確做法是修改物件的屬性:

// ✅ 修改屬性
state.count = 1

// ✅ 或使用 Object.assign
Object.assign(state, { count: 1, name: 'new' })

解構會失去響應性

import { reactive, toRefs } from 'vue'

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

// ❌ 解構後失去響應性
let { count, name } = state
count++  // 這不會更新畫面

// ✅ 使用 toRefs 保持響應性
const { count, name } = toRefs(state)
count.value++  // 這會更新畫面

深層響應式

預設情況下,ref()reactive() 都會深層轉換物件內的所有巢狀屬性:

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

const obj = ref({
  nested: {
    count: 0
  },
  arr: ['foo', 'bar']
})

// 深層屬性也是響應式的
obj.value.nested.count++
obj.value.arr.push('baz')

const state = reactive({
  deep: {
    deeper: {
      value: 'hello'
    }
  }
})

// 深層修改也會觸發更新
state.deep.deeper.value = 'world'
</script>

淺層響應式

如果你不需要深層響應式,可以使用 shallowRef()shallowReactive()

import { shallowRef, shallowReactive } from 'vue'

// 只有 .value 的變化是響應式的
const shallow = shallowRef({
  nested: { count: 0 }
})

shallow.value.nested.count++  // ❌ 不會觸發更新
shallow.value = { nested: { count: 1 } }  // ✅ 會觸發更新

// 只有根層屬性是響應式的
const state = shallowReactive({
  foo: 1,
  nested: { bar: 2 }
})

state.foo++  // ✅ 響應式
state.nested.bar++  // ❌ 不是響應式

淺層響應式通常用於效能優化,當你有很大的物件但只需要追蹤根層變化時。

DOM 更新時機

當你修改響應式資料時,DOM 更新是非同步的。Vue 會等到「下一個 tick」才執行 DOM 更新,以確保無論你修改了多少資料,每個元件只需要更新一次。

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

const count = ref(0)

async function increment() {
  count.value++

  // DOM 還沒更新
  console.log(document.getElementById('count').textContent)  // 舊值

  // 等待 DOM 更新完成
  await nextTick()

  // DOM 已更新
  console.log(document.getElementById('count').textContent)  // 新值
}
</script>

<template>
  <p id="count">{{ count }}</p>
  <button @click="increment">+1</button>
</template>

偵錯響應式

Vue 提供了一些工具來偵錯響應式系統:

isRef() 和 isReactive()

import { ref, reactive, isRef, isReactive } from 'vue'

const count = ref(0)
const state = reactive({ count: 0 })

console.log(isRef(count))       // true
console.log(isRef(state))       // false
console.log(isReactive(state))  // true
console.log(isReactive(count))  // false

toRaw()

取得響應式物件的原始物件:

import { reactive, toRaw } from 'vue'

const state = reactive({ count: 0 })
const raw = toRaw(state)

console.log(raw === state)  // false
console.log(raw)  // { count: 0 } - 普通物件

這在你需要傳遞資料給外部函式庫,或不想觸發響應式追蹤時很有用。

markRaw()

標記物件永遠不要轉成響應式:

import { reactive, markRaw } from 'vue'

const foo = markRaw({ count: 0 })
const state = reactive({ foo })

// foo 不會被轉成響應式
console.log(isReactive(state.foo))  // false

這在你有大型物件但不需要響應式追蹤時很有用,例如第三方類別的實例。

響應式與 computed

computed 會自動追蹤它所依賴的響應式資料:

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

const firstName = ref('John')
const lastName = ref('Doe')

// fullName 會追蹤 firstName 和 lastName
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

// 當 firstName 或 lastName 改變時,fullName 會自動重新計算
firstName.value = 'Jane'
console.log(fullName.value)  // 'Jane Doe'
</script>

更多 computed 的用法請參考 Computed 計算屬性

響應式與 watch

watch 可以監聽響應式資料的變化:

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

const count = ref(0)

watch(count, (newValue, oldValue) => {
  console.log(`count 從 ${oldValue} 變成 ${newValue}`)
})
</script>

更多 watch 的用法請參考 Watch 監聽器

效能考量

  1. 避免不必要的響應式:不需要追蹤變化的資料不要用 refreactive
  2. 使用 shallowRef/shallowReactive:大型物件只需追蹤根層變化時
  3. 使用 markRaw:第三方物件或不變的資料
  4. 避免在響應式物件中存放大量資料:考慮只存放必要的 ID,而不是整個物件
// ❌ 不好:存放大量資料在響應式物件中
const items = ref(hugeArrayOfObjects)

// ✅ 好:只存放 ID,需要時再查詢
const selectedIds = ref([1, 2, 3])
const itemsMap = markRaw(new Map(hugeArrayOfObjects.map(item => [item.id, item])))