响应式系统:ref / reactive / computed / watch
一句话理解响应式
数据变,依赖它的东西自动跟着变。
Java 世界里最接近的东西:
PropertyChangeListener:属性变,监听器回调- JavaBeans 的
@Observable(或 Spring 的@EventListener) - 电子表格:改一个单元格,依赖它的公式自动重算
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 |
| 单个对象或数组 | ref 或 reactive 都行 |
| 一组相关状态打包 | reactive |
| 需要整体替换 | ref(reactive 整体替换会丢响应式) |
项目里通用建议:优先用 ref。理由:
.value让你一眼看出"这是响应式数据",而reactive看起来就是普通对象,读代码时容易混ref可以存任何类型,心智负担小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)
},
})
核心特性:
- 惰性:只有被读取时才计算
- 缓存:依赖没变,返回上次的值,不会重复算
- 自动追踪依赖:函数里用了哪些
ref / reactive,自动成为依赖
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) /* ... */
})
})
searchQuery 或 selectedCategory 任一变化时,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 的区别:
watch:先声明监听源,变化后触发。能拿到oldValuewatchEffect:立即执行一次,函数里用到的响应式数据自动成为依赖。拿不到oldValue
选择原则:
- 需要对比新旧值 →
watch - 只想"数据变了就重跑" →
watchEffect
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 → 一组 effect。effect 就是"计算属性 / watch 回调 / 组件渲染函数"这些需要重跑的东西。
看懂这一点,Vue 响应式再也不神秘。模板渲染、computed、watch 全是 effect,区别只是 Vue 给它们包了不同的 API。
小练习:在 in4vue 里观察
- 打开
src/pages/HomePage.vue,指出哪些是ref、哪些是computed,它们为什么这样选 - 在搜索框加个
watch(searchQuery, (v) => console.log('搜索:', v)),体会watch和computed的不同 - 把
searchQuery改成reactive({ value: '' }),模板里也改成searchQuery.value,体验ref和reactive的写法区别(然后改回来,这个场景就是ref更合适) - 打开 Vue Devtools 的 Components 面板,修改
selectedCategory的值,观察页面实时响应——这就是响应式的真正面貌