Chapter 02

响应式系统深度解析

深入理解 Proxy 响应式原理,掌握 ref 与 reactive 的差异,以及 computed/watch 的正确使用

2.1 响应式系统的本质

响应式系统的核心目标是:当数据变化时,自动更新所有依赖这个数据的地方(比如模板、计算属性、侦听器)。Vue 3 的响应式系统基于 ES6 Proxy,与 Vue 2 的 Object.defineProperty 相比有根本性的改进。

Vue 2 的缺陷(defineProperty)

  • 无法检测属性新增删除(需要 this.$set
  • 无法检测数组下标赋值(arr[0] = val 无效)
  • 初始化时需要递归遍历整个对象树(性能开销)
  • 必须预先声明所有响应式属性

Vue 3 的 Proxy 方案

  • 拦截对象的所有操作:get/set/deleteProperty/has 等
  • 天然支持属性新增和删除
  • 惰性递归:只在访问嵌套属性时才创建响应式
  • 原生支持数组所有变更操作
// Vue 3 响应式核心原理(简化版)
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)   // 收集依赖:谁在读取这个属性?
      const value = Reflect.get(target, key)
      // 惰性递归:如果是对象才继续包装
      return typeof value === 'object' ? reactive(value) : value
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value)
      trigger(target, key)  // 触发更新:通知所有依赖重新执行
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      trigger(target, key)  // 删除属性也能触发更新
      return result
    }
  })
}

2.2 ref:基本值的响应式包装

ref() 接受一个值并返回一个响应式、可变的 ref 对象,通过 .value 属性访问内部值。

为什么需要 .value?

JavaScript 的基本类型(string、number、boolean)是值传递,无法被 Proxy 拦截。ref 将基本值包装在一个对象的 .value 属性里,从而使其可被 Proxy 追踪。在模板中 Vue 会自动解包,不需要写 .value

<script setup lang="ts">
import { ref } from 'vue'

// 基本类型 ref
const count = ref(0)           // Ref<number>
const name  = ref('')           // Ref<string>
const isOpen = ref(false)       // Ref<boolean>

// 对象类型 ref(内部会调用 reactive)
const user = ref({ name: 'Alice', age: 25 })

// 在 script 中必须写 .value
count.value++
user.value.name = 'Bob'

// 模板 DOM 引用
const inputRef = ref<HTMLInputElement | null>(null)

function focusInput() {
  inputRef.value?.focus()  // 可选链,挂载后才有值
}
</script>

<template>
  <!-- 模板中自动解包,无需 .value -->
  <p>{{ count }} — {{ user.name }}</p>
  <input ref="inputRef" @click="focusInput" />
</template>

2.3 reactive:对象的响应式代理

reactive() 直接代理一个对象,访问属性时无需 .value。但它只能代理对象(包括数组、Map、Set),不能代理基本类型。

<script setup lang="ts">
import { reactive } from 'vue'

const state = reactive({
  count: 0,
  todos: [] as string[],
  user: { name: 'Alice' }
})

// 直接访问属性,无需 .value
state.count++
state.todos.push('新任务')
state.user.name = 'Bob'

// ❌ 危险:解构会失去响应性!
const { count } = state   // count 变成了普通数字,不再响应式

// ✅ 正确:使用 toRefs 解构
import { toRefs } from 'vue'
const { count: countRef } = toRefs(state)  // 保持响应性
</script>

2.4 ref vs reactive:如何选择?

对比项refreactive
适用类型基本类型、对象、数组(通用)只能用于对象/数组/Map/Set
访问方式脚本中需 .value,模板自动解包直接访问属性,无 .value
解构天然可以解构后传递(保持 ref 身份)解构会丢失响应性
替换整体直接赋值 xxx.value = newVal 即可不能直接 state = newObj,需 Object.assign
官方推荐✅ 官方推荐默认使用 ref需要时使用,可读性更自然
官方推荐策略

Vue 3.x 官方文档推荐始终优先使用 ref。ref 的 API 在任何情况下都有效,而 reactive 有诸多限制(不能包装基本类型、解构丢失响应性等)。仅在你明确希望使用对象风格、且不需要解构时,才使用 reactive。

2.5 computed:派生状态

computed() 接受一个 getter 函数,返回一个只读的、惰性求值的响应式引用。仅当依赖变化时才重新计算,并会缓存结果。

<script setup lang="ts">
import { ref, computed } from 'vue'

const items = ref([
  { id: 1, name: '苹果', done: false },
  { id: 2, name: '香蕉', done: true },
])

// 只读 computed(getter)
const doneItems = computed(() =>
  items.value.filter(i => i.done)
)

// 可写 computed(getter + setter)
const firstName = ref('三')
const lastName  = ref('张')
const fullName = computed({
  get: () => `${lastName.value}${firstName.value}`,
  set(val: string) {
    // 写入时拆分
    lastName.value  = val[0]
    firstName.value = val.slice(1)
  }
})

fullName.value = '李四'  // 触发 setter
</script>
computed 不要有副作用

computed 的 getter 应是纯函数,不要在里面发起网络请求、修改 DOM 或改变其他响应式状态。这些操作应放在 watch/watchEffect 中。

2.6 watch 与 watchEffect

侦听器用于在数据变化时执行副作用(发请求、操作 DOM、写日志等)。Vue 3 提供了两种侦听器:

watchEffect
立即执行一次,自动追踪其内部访问的所有响应式依赖。依赖变化时重新执行。类似 React 的 useEffect,但无需声明依赖数组。
watch
精确侦听指定的数据源,只有该数据源变化才触发。提供新值和旧值,默认懒执行(首次不执行)。
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'

const userId = ref(1)
const userData = ref(null)

// watchEffect:立即执行,自动追踪依赖
watchEffect(async () => {
  // 访问了 userId.value → 自动追踪
  const data = await fetchUser(userId.value)
  userData.value = data
})

// watch:精确侦听,提供新旧值
watch(userId, async (newId, oldId) => {
  console.log(`userId 从 ${oldId} 变为 ${newId}`)
  userData.value = await fetchUser(newId)
})

// watch 多个数据源
const page = ref(1)
const filter = ref('')
watch([page, filter], ([newPage, newFilter]) => {
  fetchList({ page: newPage, filter: newFilter })
})

// watch 深层对象(需要 deep 选项)
const form = ref({ name: '', email: '' })
watch(form, (newForm) => {
  saveDraft(newForm)
}, { deep: true })

// 清理副作用(组件卸载或重新执行前调用)
watchEffect((onCleanup) => {
  const timer = setInterval(() => console.log('tick'), 1000)
  onCleanup(() => clearInterval(timer))  // 自动清理
})
</script>

2.7 toRef 与 toRefs

当需要将 reactive 对象的某个属性传递给其他地方,同时保持响应性时,使用 toRef / toRefs

<script setup lang="ts">
import { reactive, toRef, toRefs } from 'vue'

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

// toRef:提取单个属性为 ref(仍然关联原始对象)
const countRef = toRef(state, 'count')
countRef.value++       // state.count 也变为 1
state.count++          // countRef.value 也变为 2

// toRefs:将 reactive 对象的所有属性转为 refs
// 常用于从 composable 中返回响应式数据
const { count, name } = toRefs(state)
// 现在 count 和 name 都是 ref,可安全解构
</script>

2.8 响应式数组操作

Vue 3 对所有变更数组的方法(push、pop、splice 等)都能正确追踪。但有些模式需要注意:

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

const list = ref([1, 2, 3])

// ✅ 这些方法都会触发更新
list.value.push(4)
list.value.pop()
list.value.splice(0, 1, 99)
list.value.sort()
list.value.reverse()

// ✅ 下标赋值也可以(Vue 3 修复了 Vue 2 的缺陷)
list.value[0] = 100

// ✅ 替换整个数组
list.value = list.value.filter(n => n > 1)

// ❌ 注意:filter/map/reduce 返回新数组,不会修改原数组
// 必须重新赋值给 list.value 才能触发更新
</script>

2.9 shallowRef 与 shallowReactive

对于大型对象或不需要深度响应的场景,可以使用浅层响应式 API 提升性能:

import { shallowRef, shallowReactive, triggerRef } from 'vue'

// shallowRef:只有 .value 本身是响应式的
// 内层属性变化不会触发更新
const bigData = shallowRef({ items: [/* 10000 条数据 */] })

// 修改内层后需手动触发
bigData.value.items.push(newItem)
triggerRef(bigData)  // 手动强制更新

// 或直接替换整个对象(推荐)
bigData.value = { items: [...bigData.value.items, newItem] }
响应式系统小结

Vue 3 的响应式系统在底层基于 effect(副作用)track(依赖收集)trigger(触发更新) 三个核心操作。computed 是一个特殊的 effect,watch/watchEffect 也是基于 effect 实现的。理解这个模型有助于排查响应性丢失的问题。