响应式系统:ref / reactive / computed / watch

一句话理解响应式

数据变,依赖它的东西自动跟着变

Java 世界里最接近的东西:

Vue 3 响应式的内部实现是 ES6 Proxy——对象被 reactive() 包一层 Proxy,拦截所有属性读写。读的时候把当前正在执行的"副作用函数"记下来,写的时候把这些副作用重新跑一遍。

这就是整个响应式系统的心脏。下面四个 API,其实都是这个机制的不同封装。


1. ref:基本类型的响应式

import { ref } from 'vue'

const count = ref(0)       // 0 是初始值
const name = ref('张三')
const user = ref({ id: 1, name: '张三' })  // 对象也行,但通常用 reactive

// 访问 / 修改要用 .value
console.log(count.value)   // 0
count.value++              // 触发更新

为什么要 .value

因为 JS 的基本类型(number / string / boolean)是值传递,不像对象有"引用"。Vue 没法在"一个数字"上挂 Proxy,只能包一层对象 { value: 0 },再给这个对象挂响应式。

模板里自动解包,不用写 .value

<template>
  <p>{{ count }}</p>       <!-- 不用 count.value -->
  <button @click="count++">+1</button>  <!-- 也不用 -->
</template>

Java 对照

// ref 等价于一个"可观察的包装盒"
class Ref<T> {
    private T value;
    private List<Runnable> listeners = new ArrayList<>();

    public T getValue() {
        RunningEffect.current().ifPresent(e -> listeners.add(e));
        return value;
    }
    public void setValue(T v) {
        this.value = v;
        listeners.forEach(Runnable::run);
    }
}

这个 Ref<T> 就是 ref 的本质——带监听器的 getter/setter 盒子


2. reactive:对象/数组的响应式

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: '张三', age: 18 },
  tags: ['vue', 'java'],
})

// 直接读写,不用 .value
state.count++
state.user.name = '李四'
state.tags.push('typescript')

Java 对照:像给一个 Map<String, Object> 挂了 PropertyChangeSupport,所有 put / get 都被拦截。

ref vs reactive 怎么选?

场景
基本类型(number / string / boolean) ref
单个对象或数组 refreactive 都行
一组相关状态打包 reactive
需要整体替换 refreactive 整体替换会丢响应式)

项目里通用建议优先用 ref。理由:

  1. .value 让你一眼看出"这是响应式数据",而 reactive 看起来就是普通对象,读代码时容易混
  2. ref 可以存任何类型,心智负担小
  3. reactive 有坑:解构会丢响应式
const state = reactive({ count: 0 })
const { count } = state   // ❌ count 变成普通 number,失去响应式

解决办法是 toRefs(state),但一来就得用 toRefs 说明 reactive 本身不顺手。项目里基本全用 ref,除非真的是"一组强相关的字段"才用 reactive


3. computed:派生值,依赖变了才重算

import { ref, computed } from 'vue'

const firstName = ref('三')
const lastName = ref('张')

// 只读的 computed
const fullName = computed(() => lastName.value + firstName.value)

console.log(fullName.value)  // '张三'
firstName.value = '四'
console.log(fullName.value)  // '张四'(自动重算)

// 可写的 computed(少用)
const editableFullName = computed({
  get: () => lastName.value + firstName.value,
  set: (v) => {
    lastName.value = v[0]
    firstName.value = v.slice(1)
  },
})

核心特性

Java 对照

// Guava 的 Suppliers.memoize + Observable 的组合
Supplier<String> fullName = Suppliers.memoize(() -> lastName + firstName);
// 区别:Vue 的 computed 会在依赖变化时自动失效缓存,而 memoize 永远返回第一次的值

或者更贴切:Excel 单元格公式=A1+B1 这个公式,A1 / B1 改了自动重算,没人读的时候不算。

项目里的实例

src/pages/HomePage.vue 里:

const filteredNotes = computed(() => {
  const q = searchQuery.value.trim().toLowerCase()
  return allNotes.filter((n) => {
    if (selectedCategory.value !== '全部' && n.category !== selectedCategory.value) {
      return false
    }
    if (!q) return true
    return n.title.toLowerCase().includes(q) /* ... */
  })
})

searchQueryselectedCategory 任一变化时,filteredNotes 自动重算;两者都没变时,访问 filteredNotes.value 直接拿缓存。


4. watch:副作用式的监听

computed有返回值的派生,watch无返回值的副作用(发请求、存 localStorage、打日志等)。

import { ref, watch } from 'vue'

const query = ref('')

// 监听单个 ref
watch(query, (newValue, oldValue) => {
  console.log(`搜索词从 "${oldValue}" 变为 "${newValue}"`)
  // 典型场景:发 API 请求
  fetch('/api/search?q=' + newValue)
})

// 监听多个
watch([query, page], ([newQ, newP], [oldQ, oldP]) => {
  // ...
})

// 监听对象的某个属性(用 getter)
watch(
  () => user.name,
  (newName) => { /* ... */ }
)

// 深度监听
watch(user, (newUser) => { /* ... */ }, { deep: true })

// 立即执行一次
watch(query, callback, { immediate: true })

Java 对照

// Spring 的 @EventListener 或 PropertyChangeListener
propertyChangeSupport.addPropertyChangeListener("query", evt -> {
    System.out.println("query 变了: " + evt.getNewValue());
});

watchEffect:更省事的 watch

import { ref, watchEffect } from 'vue'

const query = ref('')
const page = ref(1)

// 自动收集依赖,不用手写监听列表
watchEffect(() => {
  console.log('search:', query.value, page.value)
  fetch(`/api/search?q=${query.value}&page=${page.value}`)
})

watch 的区别:

选择原则


5. computed vs watch 怎么选?

这是 Vue 新手最常问的问题。一句话判断:

能用 computed 就别用 watch

场景
根据 A 算出 B(有返回值) computed
A 变了去做别的事(无返回值) watch
派生值,且只读 computed
要发请求 / 改 DOM / 调 API watch

反例(滥用 watch):

// ❌ 用 watch 维护派生状态
const fullName = ref('')
watch([firstName, lastName], () => {
  fullName.value = lastName.value + firstName.value
})

// ✅ 用 computed 更简洁
const fullName = computed(() => lastName.value + firstName.value)

computed 自带缓存,watch 不带。能用 computed 一定用 computed


6. 响应式陷阱速查

陷阱 1:解构 reactive 丢响应式

const state = reactive({ count: 0 })
const { count } = state
count++              // ❌ 不触发更新(count 是快照)

解法toRefs(state)toRef(state, 'count')。或者一开始就用 ref

陷阱 2:在普通对象上用 ref 却忘了 .value

const count = ref(0)
count + 1            // ❌ 实际是 [object Object] + 1
count.value + 1      // ✅

ESLint 插件 eslint-plugin-vue 能帮你检查大部分此类错误。

陷阱 3:异步里的响应式更新

const data = ref(null)
onMounted(async () => {
  data.value = await fetch('/api').then(r => r.json())
  // 模板里 data.value.name 可能此时还是 null,要用 v-if="data" 守卫
})

Java 后端的"数据肯定在 Controller 拿到后才返回"在前端不成立。异步加载的数据永远要 v-if 守护

陷阱 4:直接替换 reactive 对象

let state = reactive({ count: 0 })
state = reactive({ count: 10 })   // ❌ 模板里还是显示 0

reactive 返回的是 Proxy,模板绑定的是那个 Proxy 的引用。你把变量指向新 Proxy,模板不知道。要么改属性,要么用 ref 包起来整体替换


7. 原理浅析:为什么要 Proxy

Vue 2 用 Object.defineProperty,只能拦截已存在的属性,新增属性要 Vue.set。Vue 3 换成 Proxy 后:

const raw = { count: 0 }
const state = new Proxy(raw, {
  get(target, key) {
    track(target, key)          // 记录"谁在读"
    return target[key]
  },
  set(target, key, value) {
    target[key] = value
    trigger(target, key)         // 通知所有"读过 key 的人"重跑
    return true
  },
})

Vue 内部维护一张依赖图:target → key → 一组 effecteffect 就是"计算属性 / watch 回调 / 组件渲染函数"这些需要重跑的东西。

看懂这一点,Vue 响应式再也不神秘。模板渲染、computedwatch 全是 effect,区别只是 Vue 给它们包了不同的 API。


小练习:在 in4vue 里观察

  1. 打开 src/pages/HomePage.vue,指出哪些是 ref、哪些是 computed,它们为什么这样选
  2. 在搜索框加个 watch(searchQuery, (v) => console.log('搜索:', v)),体会 watchcomputed 的不同
  3. searchQuery 改成 reactive({ value: '' }),模板里也改成 searchQuery.value,体验 refreactive 的写法区别(然后改回来,这个场景就是 ref 更合适)
  4. 打开 Vue Devtools 的 Components 面板,修改 selectedCategory 的值,观察页面实时响应——这就是响应式的真正面貌

延伸阅读