响应式系统: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 直接拿缓存。
现场感受 computed 的缓存
把无关 ref 的按钮反复点几下,看 method 一直在跑、computed 不变:
<script setup>
import { ref, computed } from 'vue'
const text = ref('hello')
const tick = ref(0)
let computedRuns = 0
let methodRuns = 0
const upper = computed(() => {
computedRuns++
return text.value.toUpperCase()
})
function upperMethod() {
methodRuns++
return text.value.toUpperCase()
}
</script>
<template>
<div class="box">
<input v-model="text" />
<p>computed: {{ upper }} · 跑了 {{ computedRuns }} 次</p>
<p>method: {{ upperMethod() }} · 跑了 {{ methodRuns }} 次</p>
<button @click="tick++">改无关 ref({{ tick }})</button>
</div>
</template>
<style scoped>
.box { padding: 20px; font-family: system-ui; line-height: 1.7; }
input { padding: 4px 8px; }
button { margin-top: 4px; padding: 4px 12px; }
</style>
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。
8. 原理深挖:activeEffect 栈 = "谁在读"的核心
上面的 track(target, key) 怎么知道"谁在读"?答案是一个全局变量 activeEffect:
let activeEffect = undefined
class ReactiveEffect {
constructor(fn) { this.fn = fn }
run() {
activeEffect = this // ★ 跑之前把自己设为当前 effect
try {
return this.fn() // 函数里所有 get 都会调 track,把 this 收进依赖
} finally {
activeEffect = undefined // ★ 跑完清掉
}
}
}
function track(target, key) {
if (!activeEffect) return
// 把 activeEffect 加进 target.key 的依赖集合
depsMap.get(target).get(key).add(activeEffect)
}
为什么 setup 里同步写的 watch / computed 能自动收依赖——执行 effect.run() 时把自己挂到 activeEffect,函数里碰到 proxy.foo 就触发 get → track(target, 'foo'),依赖关系建立。
栈式管理(嵌套 effect):
const stack = []
function setActiveEffect(e) {
stack.push(activeEffect)
activeEffect = e
}
function unsetActiveEffect() {
activeEffect = stack.pop()
}
为什么要栈?effect 会嵌套——比如组件 render 里调了一个 computed,computed 内部又依赖另一个 ref。如果不栈式管理,computed 跑完把 activeEffect 清成 undefined,回到 render 时再访问 ref 就收不到依赖了。
Vue 3.4 之前用 effectStack 数组,3.4 之后做了优化用的是闭包 prevActiveEffect,但思路是一样的栈结构。
9. 原理深挖:targetMap 的三层结构
Vue 内部的依赖图长这样(伪 TypeScript):
type Dep = Set<ReactiveEffect> // 第三层:谁依赖了这个 key
type KeyToDepMap = Map<any, Dep> // 第二层:key → Dep
type TargetMap = WeakMap<object, KeyToDepMap> // 第一层:对象 → 第二层
const targetMap: TargetMap = new WeakMap()
实际数据:
WeakMap {
state对象A => Map {
'count' => Set { renderEffectA, computedEffectB },
'name' => Set { renderEffectA }
},
state对象B => Map {
'list' => Set { renderEffectC }
}
}
为什么外层用 WeakMap:键是被代理的对象,弱引用——对象被 GC 时整个依赖关系自动清理,无需手动 dispose。Java 里类似 WeakHashMap 的玩法。
trigger 时的查找:
function trigger(target, key) {
const depsMap = targetMap.get(target) // 第一层
if (!depsMap) return // 这个对象从来没被读过
const dep = depsMap.get(key) // 第二层
if (!dep) return // 这个 key 没被读过
for (const effect of dep) { // 第三层
if (effect.scheduler) effect.scheduler() // 有调度器就走调度器
else effect.run()
}
}
关键洞察:依赖收集是懒的——只有真的被读过的属性才在依赖图里。reactive({ a:1, b:2, c:3 }) 但代码只用了 .a,那 b / c 改了完全不触发任何 effect。这是 Vue 3 性能远优于 Vue 2 的原因之一。
10. 原理深挖:RefImpl = 给基本类型挂上 Proxy 的等价物
第 1 节说 ref 包了一层对象。具体长这样(简化版 Vue 源码):
class RefImpl {
constructor(value) {
this._rawValue = value
this._value = isObject(value) ? reactive(value) : value
this.dep = new Set() // 这个 ref 自己的 dep
}
get value() {
trackRefValue(this) // 收集依赖:activeEffect → this.dep
return this._value
}
set value(newVal) {
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
this._value = isObject(newVal) ? reactive(newVal) : newVal
triggerRefValue(this) // 触发:跑 this.dep 里所有 effect
}
}
}
function ref(v) { return new RefImpl(v) }
几个关键点:
.value是 getter/setter——所以读写都能拦截。这就是为什么必须写.value:访问 ref 对象本身没拦截能力,访问.value才进 getter- 传入对象时自动包 reactive——
ref({ a: 1 })内部_value是reactive({ a: 1 }),所以r.value.a也是响应式 - 比较用
hasChanged——本质是Object.is,相同值赋值不触发更新(避免无意义重渲染) - dep 直接挂在 ref 实例上——不像 reactive 要走 targetMap 三层,因为 ref 只有一个 key(value)
模板自动解包:
// 编译模板时遇到顶层 ref,自动加 .value
{{ count }} 编译成 {{ _ctx.count }}
// 而 setup 返回 ref 时 Vue 给它套了个 Proxy 解包包装
所以模板里不写 .value 是编译期 + 运行时配合的结果,不是黑魔法。
11. 原理深挖:computed 的 lazy + dirty 双标志位
computed 自带"惰性 + 缓存 + 依赖失效自动重算",靠两个标志位实现:
class ComputedRefImpl {
constructor(getter) {
this._dirty = true // 脏标志位:要不要重算
this._value = undefined
this.effect = new ReactiveEffect(getter, () => {
// ★ 调度器:依赖变化时不立即重算,只是把 dirty 标 true
if (!this._dirty) {
this._dirty = true
triggerRefValue(this) // 通知"读 computed 的人"重跑
}
})
}
get value() {
trackRefValue(this)
if (this._dirty) {
this._dirty = false
this._value = this.effect.run() // 只在脏了的时候重新跑 getter
}
return this._value
}
}
完整流程(以 fullName = computed(() => first.value + last.value) 为例):
1. 创建 computed → _dirty = true
2. 模板访问 fullName.value
→ 进 getter
→ _dirty 为 true,跑 getter,结果存 _value
→ _dirty = false
3. 模板再次访问 fullName.value
→ _dirty 是 false,直接返回 _value(缓存命中)
4. 改 first.value = 'X'
→ 触发 first 的 dep(包含 fullName 的 effect)
→ 跑 fullName.effect 的调度器
→ 调度器只设 _dirty = true,不重算
→ 通过 triggerRefValue 通知"依赖 fullName 的 effect"(比如组件 render)
5. 组件 render 重跑,访问 fullName.value
→ _dirty 是 true,重新跑 getter
这就是"惰性 + 缓存"的实现——依赖变了只标脏不重算,等真的有人读才算。如果 computed 没人读(如你用 v-if 把它从模板里隐藏),即使依赖一直变也永远不会跑 getter。
Java 对照:和 Guava 的 Suppliers.memoize 思路像,但 memoize 没失效机制,结果永久缓存。Vue 的 computed = memoize + 自动 invalidate。
12. 原理深挖:watch 的 scheduler 实现
watch 也是基于 ReactiveEffect,区别在 scheduler 不一样:
function watch(source, callback, options = {}) {
let oldValue
const job = () => {
const newValue = effect.run()
callback(newValue, oldValue)
oldValue = newValue
}
// 调度器:选择什么时机跑 job
const scheduler =
options.flush === 'sync' ? job :
options.flush === 'post' ? () => queuePostFlushCb(job) :
() => queueJob(job) // 默认 'pre',下个微任务
const effect = new ReactiveEffect(
() => isRef(source) ? source.value : source(),
scheduler
)
oldValue = effect.run() // 收集依赖 + 拿初始值
}
flush 三个选项的区别:
| flush | 时机 | 用途 |
|---|---|---|
'pre'(默认) |
下个微任务,组件 render 之前 | 改数据后的常规副作用 |
'post' |
组件 render 完成、DOM 更新后 | 需要读最新 DOM 时(量尺寸) |
'sync' |
同步立即触发 | 几乎不用,会失去批量更新优势 |
为什么默认 'pre':同一个 tick 内多次改值只触发一次回调(去重队列),但比组件渲染早一步——这样 watch 回调里再次改其他响应式数据,也能合并到同一次渲染。
调度队列的去重(queueJob 的核心):
const queue = []
let isFlushing = false
function queueJob(job) {
if (!queue.includes(job)) queue.push(job) // 去重
if (!isFlushing) {
isFlushing = true
Promise.resolve().then(flushJobs) // ★ 微任务里执行
}
}
function flushJobs() {
queue.sort((a, b) => a.id - b.id) // 父组件先于子组件(id 自增)
for (const job of queue) job()
queue.length = 0
isFlushing = false
}
这就是 nextTick 的本质——nextTick(fn) 等价于 Promise.resolve().then(fn),正好排在 flushJobs 之后跑。所以"改数据 → nextTick 拿到新 DOM" 是因为 nextTick 排在 flush 队列之后。
13. 原理深挖:reactive 的几个 Proxy 拦截器
reactive(obj) 实际套了一组 handler,不止 get/set:
const mutableHandlers = {
get(target, key, receiver) {
track(target, key)
const res = Reflect.get(target, key, receiver)
if (isRef(res)) return res.value // ★ 自动解包嵌套 ref
if (isObject(res)) return reactive(res) // ★ 深响应式(懒,访问时才包)
return res
},
set(target, key, value) { ... trigger() ... },
has(target, key) { track(target, key, 'has'); ... }, // in 操作符
deleteProperty(target, key) { ... trigger() ... },
ownKeys(target) { track(target, ITERATE_KEY); ... } // for...in / Object.keys
}
关键点:
- 深响应式是懒的——
reactive({ a: { b: 1 } })时不立刻递归包a,只有访问state.a才实时包一层 reactive。这避免了对未访问字段的预先开销 - Reflect 而不是 target[key]——为了正确处理 getter 里的 this 指向,让 receiver(Proxy)成为 this,使 getter 内访问别的字段也能被 track
shallowReactive用另一组 handler——只拦第一层,深层不再 reactive。适合大对象或第三方实例(ECharts、Monaco)- collection handler 单独一套——Map/Set/WeakMap 有自己的方法(.get / .set / .has),需要专门拦截这些方法而不是属性访问
为什么 reactive 数组改 length 也响应:Proxy 拦截 set 时连 length 都拦得到,Vue 2 的 defineProperty 拦不到数组下标和 length——这是 Vue 3 的明显胜出。
14. 原理深挖:响应式 ↔ 渲染的完整流程
把所有原理串起来——以"点一下按钮 count 加一"为例:
0. 组件挂载
├─ setup() 跑一次,setupState = { count: ref(0), inc: fn }
├─ render() 包成一个 ReactiveEffect.run()
│ ├─ activeEffect = render effect
│ ├─ 跑渲染函数
│ │ └─ 访问 count.value
│ │ └─ trackRefValue → count.dep.add(render effect)
│ ├─ 拿到 vnode 树
│ └─ patch 到 DOM
└─ activeEffect = undefined
1. 用户点按钮,触发 onClick
2. inc() 跑:count.value++
├─ 进 RefImpl.set value
├─ _rawValue 从 0 改成 1
└─ triggerRefValue(this)
└─ 遍历 count.dep
└─ 对 render effect 调 scheduler
└─ queueJob(updateComponent) // 不立即跑,进队列
3. 同步代码跑完,进微任务阶段
4. flushJobs 启动
├─ 对 queue 排序(父先子后)
└─ 跑每个 job
└─ updateComponent
├─ 重跑 render effect → 新 vnode 树
├─ patch(oldVNode, newVNode)
│ └─ 按 patchFlag 找到动态文本节点
│ └─ el.textContent = '1' // 真 DOM 更新
└─ 触发 onUpdated 钩子
5. nextTick(fn) 注册的 fn 在这之后跑——能拿到更新后的 DOM
理解了这条流程,所有响应式相关的"为什么"都能自答:
- 为什么改一次值,多个依赖只触发一次?——queueJob 去重 + 微任务批处理
- 为什么 watch 默认拿不到新 DOM?——
flush: 'pre'在 render 之前 - 为什么 nextTick 能拿到?——它排在 flush 队列之后
- 为什么 setup 里 watch 不写 onUnmounted 也自动清理?——effectScope 收集了 effect,组件卸载时一起 stop
15. 现场观察:手写一个最小响应式
把上面所有原理压成 50 行可运行代码,丢进 Console 跑:
let activeEffect
function effect(fn) {
const e = () => { activeEffect = e; fn(); activeEffect = null }
e()
}
const targetMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, depsMap = new Map())
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, dep = new Set())
dep.add(activeEffect)
}
function trigger(target, key) {
const dep = targetMap.get(target)?.get(key)
if (dep) dep.forEach(e => e())
}
function reactive(obj) {
return new Proxy(obj, {
get(t, k) { track(t, k); return t[k] },
set(t, k, v) { t[k] = v; trigger(t, k); return true }
})
}
// 试试看
const state = reactive({ count: 0 })
effect(() => console.log('count is', state.count)) // 立刻打印 "count is 0"
state.count++ // "count is 1"
state.count++ // "count is 2"
这就是 Vue 响应式系统的全部核心——剩下的都是工程化优化(调度队列、深响应、ref 包装、computed 的脏标记、effectScope 自动清理)。
小练习:在 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的值,观察页面实时响应——这就是响应式的真正面貌