Vue 3 响应式的十一个边界:数据改了,视图为什么不动
先分清两类问题
"数据改了,视图没动"这句话下面藏着两种完全不同的毛病,混在一起查就会一直查不出来。
第一类是改错了对象。你手上那个变量已经不是响应式代理了,改它当然没人理你。Vue 3 的响应式建立在 Proxy 上,只有代理对象的属性访问会被拦截;一旦某个环节把原始对象漏了出来,响应式链接就在那里断掉了,而且断得悄无声息。
第二类是依赖没被收集。对象是代理没错,改也确实改了,但当时那个正在运行的副作用(渲染函数、watchEffect、computed)压根没读到这个属性,所以它不在依赖表里,改它不会触发任何重跑。
分清楚这两类,再去看下面这些边界,你会发现它们全都能归到其中一类,不是零散的知识点。
底层其实只有两个动作
在逐个看边界之前,先花两分钟把机制压缩到最小。Vue 的响应式,说到底就是两个动作加一张表。
track(收集):某个副作用正在执行时,如果读了一个响应式属性,就把"这个副作用"记到这个属性的依赖集合里。
trigger(触发):某个响应式属性被写入时,把它依赖集合里的所有副作用重新跑一遍。
一个能跑的最小实现大概长这样:
let activeEffect = null
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(effect => effect())
}
function reactive(obj) {
return new Proxy(obj, {
get(t, k, r) { track(t, k); return Reflect.get(t, k, r) },
set(t, k, v, r) { const ok = Reflect.set(t, k, v, r); trigger(t, k); return ok }
})
}
function effect(fn) {
activeEffect = fn
fn() // 执行期间读到的属性都会把 fn 收进依赖集合
activeEffect = null // 关键:执行结束,收集窗口关闭
}
这个三十行的东西和 Vue 的真实实现差得远(没有依赖清理、没有调度器、没有嵌套 effect 的栈),但它把两件事解释得很清楚:
第一,依赖是被"读"出来的,不是被"声明"出来的。所以只要某次执行没读到某个属性,这个属性就不在那个副作用的依赖表里,后面改它自然没反应。边界六那个 await 问题,本质就是"收集窗口在执行到 await 时已经关了"。
第二,只有经过 Proxy 的读写才有 track/trigger。原始对象上的读写绕过整个机制,边界一到四都是这个原因。
activeEffect 只在 fn() 同步执行期间有效,这一行是理解全部"响应式丢失"问题的钥匙。
边界一:解构的那一刻,链接就断了
官方文档写得很直接:把一个响应式对象的原始类型属性解构成局部变量,响应性连接就丢了。
const state = reactive({ count: 0 })
// 这里的 count 已经和 state.count 断开连接
let { count } = state
count++ // state.count 仍然是 0
原因是 count 拿到的是当时那个数字,一个原始值。它和 state 之间没有任何引用关系,赋值给一个变量之后,两者就是两个独立的数字了。
但这里有个特别容易搞混的地方:不是所有解构都断链。看这段:
const state = reactive({ user: { name: 'a' }, count: 0 })
const { user, count } = state
user.name = 'b' // 有效,因为 user 还是那个代理对象
user = { name: 'c' } // 无效,只是换掉了本地变量指向
count++ // 无效,原始类型
user 解构出来的是一个对象,而 reactive 是深层转换的,state.user 本来就已经是代理,所以解构拿到的是同一个代理,改它的属性照样触发更新。丢的是"重新赋值"这个动作。
记住这个区分,排查时能少走很多弯路:解构丢的是"重新赋值的能力",不是"读写的能力"。
标准修法是 toRef / toRefs,它们把属性包装成 ref,ref 的 .value 是一个 getter/setter,读写都回到原对象上:
const { count, user } = toRefs(state)
count.value++ // 有效
或者在组合式函数里干脆别解构,接收整个对象。再或者,一开始就用 ref 声明——这也是官方现在的建议:把 ref() 作为声明响应式状态的主要 API。
边界二:整体替换 reactive 对象,等于自己剪断绳子
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // 上面那个代理没人引用了
这行代码没有任何报错,state 也确实是响应式的,但模板里读到的是首次渲染时闭包捕获的那个旧代理,新代理从来没被渲染函数读过。所以页面卡在 0。
这个问题在"用接口返回数据整体刷新表单"的场景里特别常见:
// 错误
formData = res.data
// 正确
Object.assign(formData, res.data)
Object.assign 是对代理逐属性赋值,每次 set 都会走拦截器,依赖能正常触发。如果要连"多余的旧字段要清掉"一起处理,就先 Object.keys(formData).forEach(k => delete formData[k]) 再 assign,或者干脆用 ref 包一层,改 .value 是合法的整体替换:
const formData = ref({})
formData.value = res.data // 这个可以
这也是 ref 相比 reactive 的一个实际优势:整体替换在 ref 上是标准操作,在 reactive 上是事故。
边界三:数组和 Map 里的 ref,不会自动解包
reactive 对象的属性如果是 ref,访问时会自动解包,用起来跟普通属性一样。官方文档管这个叫"作为 reactive 对象的属性自动解包"。
但这条规则有两个明确的例外,官方文档专门列了一节:当 ref 作为响应式数组的元素、或者原生集合(如 Map)的值被访问时,不会解包。
const books = reactive([ref('Vue 3 Guide')])
console.log(books[0].value) // 必须写 .value
const map = reactive(new Map([['count', ref(0)]]))
console.log(map.get('count').value) // 同样必须 .value
还有一个例外:ref 作为浅层响应式对象(shallowReactive)的属性时,也不解包。
这个差异在实际项目里造成的典型 bug 是:一个列表里每项都是一个 ref(比如动态表单的行数据),你在模板里写 {{ row.name }},渲染出来是空的,因为 row 本身是个 ref,row.name 是 undefined。正确的写法是 {{ row.name.value }},或者干脆别在数组里塞 ref——数组用 reactive,元素用普通对象。
边界四:模板里只有顶层 ref 会解包,而文本插值是个例外
这一条最反直觉,官方文档里有一小段专门讲它,但读过去很容易忘。
在模板渲染上下文中,只有顶层的 ref 属性会被解包。
const count = ref(0)
const object = { id: ref(1) }
在这个前提下:
{{ count + 1 }} <!-- 正常,2 -->
{{ object.id + 1 }} <!-- 渲染成 [object Object]1 -->
{{ object.id }} <!-- 渲染成 1 -->
同一个 object.id,加了个 + 1 就废了,单独插值又好了。原因是:object 是个普通对象,id 在它里面,不属于"顶层",所以参与表达式运算时它还是那个 ref 对象本身,ref对象 + 1 会走 toString(),得到 "[object Object]1"。
而单独写 {{ object.id }} 之所以正常,是因为它命中了文本插值的便利特性——如果插值的最终计算值是一个 ref,会被解包显示。这等价于 {{ object.id.value }},但只在这一种位置有效。
避开它的办法很简单:需要参与运算的 ref,解构成顶层变量再用。
const { id } = object
{{ id + 1 }}
这条边界也解释了另一个常见疑惑:为什么 v-if="obj.flag" 有时候判断恒为真。因为 obj.flag 是个 ref 对象,而任何对象在布尔上下文里都是 true,ref(false) 也是 true。这种 bug 极其隐蔽,因为表达式本身不报错。
边界五:深层 watch 里,newValue 和 oldValue 是同一个对象
这条官方文档写得很明白,但看到的时候往往已经踩过了。
const obj = reactive({ count: 0 })
watch(obj, (newValue, oldValue) => {
// 嵌套属性变化时触发
// 但这里 newValue === oldValue,因为它们是同一个对象
})
obj.count++
为什么?watch 的"新旧值"比较的是引用。obj.count++ 改的是对象内部属性,obj 这个引用从头到尾没变过,所以两个参数指向同一块内存,打印出来当然是同一个东西。
顺带说,watch 传入整个 reactive 对象时会隐式变成深层侦听,不用写 deep: true。而传 getter 返回对象时是浅层的,只有对象被整体替换才触发:
watch(() => state.someObject, () => { /* 只在替换时触发 */ })
想要"变化前后的差异",只能自己留快照:
let snapshot = structuredClone(toRaw(obj))
watch(obj, () => {
const current = toRaw(obj)
// 用 snapshot 和 current 做对比
snapshot = structuredClone(current)
}, { deep: true })
toRaw 那一步不能省——structuredClone 拿代理去克隆,某些情况下会抛错,而且克隆出来的结构和代理的可见性不一定一致。这个坑在"监听表单变化做草稿保存"的场景里特别容易遇到。
边界六:watchEffect 只收集同步执行期间的依赖
这条是官方的一句 TIP,但它的影响比看起来大得多:
watchEffect仅会在其同步执行期间追踪依赖。在使用异步回调时,只有在第一个await正常工作前访问到的属性才会被追踪。
也就是说:
const id = ref(1)
const keyword = ref('')
watchEffect(async () => {
const res = await fetch(`/api/list?id=${id.value}`)
// keyword 是在 await 之后才读的,不会被收集
console.log(keyword.value, res)
})
这段代码里,id 变了会重新执行,keyword 变了不会。因为第二次运行时,函数执行到 await 就交出了控制权,此时依赖收集已经结束(当前活跃的 effect 被清掉了),await 之后的那句读取不在任何追踪上下文里。
这个 bug 的表现是"某个筛选条件改了没反应",而另一个条件改了有反应,非常容易误判成"是不是这个字段没绑定上"。
三种修法:
// 一:把依赖访问全部提前
watchEffect(async () => {
const q = keyword.value
const res = await fetch(`/api/list?id=${id.value}&q=${q}`)
})
// 二:要精确控制就换 watch,明确列出数据源
watch([id, keyword], async ([newId, newKeyword]) => { ... })
// 三:内部再开一个同步的 watchEffect 只负责收依赖
第一种最简单,记住"想知道什么,就在 await 之前读一眼"。
顺带说一个同一类问题:侦听器里发请求,先发的可能后回来。
watch(keyword, async (kw) => {
const res = await search(kw)
list.value = res // 输入 "vue" 和 "vue3" 时,可能 "vue" 的结果最后到达
})
用户快速输入几个字,请求按发出顺序返回,最后写进 list 的可能是最早那一次的结果。表现是搜索结果和输入框对不上,而且只在网络抖动时出现,很难复现。
官方给的正解是副作用清理:watch 回调的第三个参数是一个 onCleanup 注册函数,重新运行前会被调用一次。
watch(keyword, async (kw, _, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // 上一次还没回来的请求,直接中止
const res = await search(kw, { signal: controller.signal })
list.value = res
})
Vue 3.5+ 还提供了 onWatcherCleanup(),效果一样但有个限制:必须在同步执行期间调用,不能写在 await 之后。而通过第三个参数拿到的 onCleanup 不受这个限制,因为它和侦听器实例直接绑定。两种都能用,但如果你的清理逻辑要放在请求后面,用参数版更省心。
边界七:异步创建的侦听器,不会被自动停止
官方文档提到一个关键点:侦听器必须用同步语句创建,才会自动绑定到当前组件实例,并在卸载时停止。
// 会自动停止
watchEffect(() => {})
// 不会自动停止,组件卸载了它还在跑
setTimeout(() => {
watchEffect(() => {})
}, 100)
后果就是内存泄漏加幽灵回调:组件已经销毁了,侦听器还在跑,读到的是旧闭包里的数据,可能还往一个已经卸载的组件上写状态。
这种写法在真实代码里不算罕见,比如"等某个异步初始化完成后才开始监听":
onMounted(async () => {
await initChart()
watch(size, () => resize()) // 这个 watch 在 await 之后创建,不会被自动清理
})
onMounted 里 await 之后的代码已经不在组件的同步执行上下文里了。修法有两种,要么把 watch 移到 await 之前(让回调自己判断初始化是否完成),要么手动收集返回值并在 onUnmounted 里调一次:
let stop
onMounted(async () => {
await initChart()
stop = watch(size, () => resize())
})
onUnmounted(() => stop?.())
边界八:flush 时机决定了你拿到的是新 DOM 还是旧 DOM
watch 的默认回调时机,官方描述是:在父组件更新之后、所属组件的 DOM 更新之前。
也就是说,callback 里访问自己组件的 DOM,看到的还是更新前的状态。这在"监听数据变化后要测量元素高度、滚动到底部、聚焦输入框"的场景里必然出问题。
watch(list, () => {
// 这里 listEl.value 的 DOM 还是旧的,高度没变
console.log(listEl.value.scrollHeight)
})
watch(list, () => {
console.log(listEl.value.scrollHeight) // 这里才是新的
}, { flush: 'post' })
flush: 'post' 把回调推迟到 DOM 更新之后,等价于在回调里包一层 nextTick,但更省事。对应的 watchPostEffect() 是 watchEffect 加 post 的别名。
还有一个 flush: 'sync',同步触发,不批处理。官方明确提醒谨慎使用:如果你在一个循环里改了 100 次数据源,同步侦听器就跑 100 次。它适用的场景很窄,比如需要立刻拦截一个布尔开关。
顺便把 nextTick 的本质说清楚:它不是"等一会儿",而是"等这一次更新队列 flush 完"。Vue 把同一轮事件循环里的所有状态变更攒起来,在微任务里统一刷新;nextTick 就是把你的回调排在这次刷新之后。所以它跟延时长短无关,跟"这一轮还有没有别的更新"有关。
边界九:computed 不是函数,它有缓存也有依赖链
computed 被当成"带缓存的 getter",这个理解基本对,但它带来两个反直觉的后果。
第一,依赖不变就不重算。 这是缓存的意义,但也意味着"在 computed 里做副作用"是彻底错的:
const total = computed(() => {
console.log('只有依赖变化时才会打印') // 不是每次渲染都打印
return list.value.reduce((a, b) => a + b.price, 0)
})
如果你想靠 computed 去触发一个请求、或者去改另一个状态,行为会变得完全不可预测——它可能一次都不执行,也可能在依赖抖动时连 execute 好几次,还可能引发循环更新。官方对 computed 的定位是纯函数:只读取响应式状态、只返回结果。
第二,computed 的返回值如果是对象,解构同样会断链。
const info = computed(() => ({ name: state.name, age: state.age }))
const { name } = info.value // 断链,name 是快照字符串
要取其中一项并保持响应,用 computed(() => info.value.name),别解构。
还要提一句版本差异:Vue 3.5 对响应式系统做了一次重构,官方 release notes 里明确说解决了"陈旧的 computed 值"和 SSR 中悬挂 computed 导致的内存问题。如果你在用 3.4 及以下,遇到 computed 结果偶尔不更新的怪现象,升级本身就是一种修法。
边界十:第三方实例不要放进响应式系统
这条严格说不算"响应式边界",但它是渲染异常的高发区,值得单独说。
ECharts 实例、地图实例、富文本编辑器实例、IntersectionObserver、WebSocket 对象——这些东西一旦被 reactive / ref 包起来,就会被 Proxy 拦截。而它们内部大量依赖 this 指向、私有字段、实例相等性判断,被代理之后行为会变得很奇怪:图表不渲染、编辑器报错、instanceof 判断失败,而且报错信息通常指向库内部,看不出跟响应式有关。
正确做法是标成原始对象:
import { markRaw, shallowRef } from 'vue'
const chart = shallowRef(null)
onMounted(() => {
chart.value = markRaw(echarts.init(el)) // 双保险
})
markRaw 让对象永久跳过代理;shallowRef 则只追踪 .value 的替换,不深入内部。两者经常一起用。
但 shallowRef 有个必须知道的副作用:改它的内部属性不会触发更新。
const state = shallowRef({ count: 0 })
state.value.count++ // 不触发
state.value = { count: 1 } // 触发
triggerRef(state) // 或者手动强制触发
triggerRef 是给这种场景准备的:你明确知道内部变了、也知道不会自动触发,就手动通知一次。它不适合日常用,但在包装第三方状态时是必要的逃生口。
边界十一:props 是"浅只读",改深层属性不会拦你
这一条不属于"丢失响应性",但属于同一类困惑,而且它的表现更迷惑人:有时候改了会警告,有时候改了一点事都没有。
组件接收的 props 对象是浅层只读的。只有顶层属性被设为只读:
// 子组件里
props.title = 'x' // 控制台警告:Set operation on key "title" failed: target is readonly
props.user.name = 'x' // 没有任何警告,而且真的改掉了
原因在于 props 的只读是 shallowReadonly——只代理第一层。props.user 拿到的是父组件那个对象本身(或者它的响应式代理),往里写属性完全不经过 props 的只读拦截。
后果是:子组件顺手改了一下 props.user.name,父组件的数据跟着变了,如果这个对象还被别的组件共享,会出现"我没改过这里,它自己变了"的诡异现象。而且这个改动在所有代码里都搜不到赋值语句,因为写的是嵌套属性。
这实际上是官方允许的行为,不是 bug——深层只读需要递归代理,代价太高,而且很多场景确实需要把对象引用传下去。但作为使用者,得自己守住单向数据流:子组件只读不改,要改就 emit 出去让父组件改,或者用 v-model(3.4+ 可以直接 defineModel())把双向绑定显式化。
如果你确实需要一个本地副本做编辑,注意 ref(props.obj) 只是换了个容器,引用还是同一个,改它等于改父组件。要么深拷贝,要么把编辑态放在子组件自己声明的 ref 里、提交时再 emit 上去。
判断标准很简单:只要你在子组件里对 props.xxx.yyy 出现了赋值,这段代码就应该被 review 一次。
出问题时按这个顺序查
上面十条覆盖了绝大多数情况,但排查时还是要有顺序,不然就是在猜。
第一步,确认手上是不是代理。 isReactive(obj)、isRef(obj)、toRaw(obj) !== obj,三个判断下去,能立刻区分"改错了对象"还是"依赖没收集"。这一步能解决一半问题。
第二步,确认依赖是在哪个阶段被读的。 有没有 await 把读写隔开?有没有 setTimeout 里才第一次访问?渲染函数和 watchEffect 的依赖收集窗口都很短,只在同步执行期间。
第三步,确认时机。 是不是在 DOM 还没更新的时候就去读 DOM 了?是不是在组件卸载之后还在写状态?
Vue 还提供了两个开发期钩子,专门用来看依赖:onRenderTrack 和 onRenderTrigger。它们会在渲染副作用追踪依赖、或被依赖触发时回调,打印出具体的 key。
onRenderTrigger((e) => {
console.log('触发重渲染的 key:', e.key, '类型:', e.type)
})
这两个钩子只在开发环境有效,但定位"到底是谁导致的重渲染"时,比在代码里到处 console.log 高效得多。
一张选型表,省掉一半争论
前面讲了这么多边界,落到日常就是一个问题:这个状态该用什么声明。给个能直接照用的判断:
| 场景 | 用什么 | 原因 |
|---|---|---|
| 单个值(数字、字符串、布尔) | ref |
reactive 不支持原始类型 |
| 会被整体替换的对象(接口返回、重置表单) | ref |
reactive 整体替换会断链 |
| 结构固定、只做属性级修改的对象 | reactive |
少写 .value,属性访问更自然 |
| 列表 / 数组 | ref([]) 或 reactive([]) 都行 |
元素别放 ref,否则要写 .value |
| 需要跨函数传递的值 | ref |
传 ref 能保住链接,传值不能 |
| 大型只读数据(字典、配置表) | shallowRef |
避免深层代理的开销 |
| 第三方实例(图表、编辑器、地图) | shallowRef + markRaw |
实例不能被代理 |
| 组件 props 派生的本地状态 | computed 或 toRef |
别用 ref(props.x) 做快照 |
| 需要双向绑定的子组件属性 | defineModel() |
比 props + emit 手写更清晰 |
这张表不是绝对规则,但它能覆盖 90% 的日常决策。剩下 10% 是性能敏感场景,那时候要看的就不是这张表,而是实际的渲染开销数据。
结尾:一个能省很多事的默认选择
把上面这些边界串起来看,会发现大部分坑都来自同一个根源:值的传递过程中丢了引用。解构、赋值、传参、放进数组,每一个动作都可能是断链点。
所以日常写代码有个简单的默认选择,能绕开其中大半:
状态声明优先用 ref,不要用 reactive 去装一个会被整体替换的对象;跨函数、跨模块传递状态时传 ref 本身而不是 .value;组合式函数的参数用 MaybeRefOrGetter + toValue 统一处理,调用方传 ref、传 getter、传普通值都能工作。
这不是风格偏好,是让"响应式链路"这条线尽可能短、尽可能不被中断。链路越短,出问题时越容易一眼看到断点在哪。