01|ref 与 reactive:先决定“追踪哪一层”
第三章:响应式系统纵览 / 下一篇:依赖追踪
响应式容器不是给普通变量贴一张“自动更新”标签。它先决定 Vue 能拦截哪个入口:ref 拦截 .value,reactive 拦截对象属性;之后,渲染、computed 或 watcher 才能在读取这些入口时订阅变化。
运行上下文
本篇主线使用 Vue 3.5+ Composition API。页面组件建议写在 <script setup lang="ts"> 中;不涉及 DOM 的受控实验可保存为 .mjs,在本项目根目录运行 node 文件名.mjs。模板会自动解包顶层 ref,JavaScript 中仍要写 .value。
两种拦截入口
import { reactive, ref } from 'vue'
const count = ref(0)
const profile = reactive({ name: 'Ada', address: { city: 'London' } })
count.value++
profile.name = 'Grace'
profile.address.city = 'New York'ref(0) 返回一个带 .value getter/setter 的对象。ref 接收对象时,默认也会把对象深层转换成响应式代理。reactive() 只接受对象类型,返回对象代理;嵌套普通对象会在访问时继续转换。
| 决策问题 | 首选 | 原因 |
|---|---|---|
| 值会整体替换,或可能是基本类型 | ref | .value = next 是稳定替换入口 |
| 一组字段长期按属性修改 | reactive | 读写形状接近普通对象 |
| 大对象由外部状态库管理,只关心整体版本 | shallowRef | 内部身份保持原样,替换 .value 才触发 |
| 只让调用方读取 | readonly | 写操作在开发环境告警,所有修改走显式 action |
| 第三方实例绝不能被代理 | markRaw | 明确退出深层代理转换 |
“对象就一定用 reactive”不是规则。分页结果、当前用户、编辑器文档都常被整体替换,ref<User | null> 往往更直白。
先读懂实验中的观察工具
本章暂用两种工具观察容器,完整选项在第05篇学习:watch(source, callback)监听ref或getter返回值,变化后把新值交给callback;默认不执行初始回调。watchEffect(callback)先执行一次,再跟踪同步读取的响应式值。默认更新会排队,所以测试写入后用 await nextTick() 等待这轮Vue更新,再检查次数;它不是任意延时,也不等待网络。
source → derived → effect 的中间状态
import { computed, readonly, ref, watch } from 'vue'
const price = ref(20) // source
const quantity = ref(2) // source
const total = computed(() => price.value * quantity.value) // derived
const audit = readonly({ total })
watch(total, (value) => {
console.log('提交审计记录', value) // effect
})当 quantity.value 从 2 变成 3 时,中间过程是:
quantity的 setter 发现值改变,通知依赖者。total先被标记为需要重新求值;此时还没有必要立刻乘法。- 渲染或 watcher 读取
total.value,得到60。 - watcher 把
60送往外部审计系统。
audit 只是只读视图,不是第二份状态。真正可写的 source 仍只有 price 和 quantity。
深层、浅层与身份
import { isReactive, reactive, shallowRef, watchEffect } from 'vue'
const deep = reactive({ nested: { count: 0 } })
console.log(isReactive(deep.nested)) // true
const documentState = shallowRef({ version: 1, text: 'draft' })
let runs = 0
watchEffect(() => {
runs++
console.log(documentState.value.version)
})
documentState.value.version++ // 内层原地修改,不触发 effect
documentState.value = { version: 3, text: 'published' } // 根替换,触发shallowRef 的“浅”是合同:只追踪 .value。它适合不可变快照、第三方代理、编辑器实例和大型数据;调用方必须用根替换表示新版本。若团队一半原地改、一半整体换,问题不在 Vue,而在合同没有统一。
reactive(raw) !== raw。同一个原对象重复传给 reactive() 会得到同一个代理,但原对象与代理仍是两个 JavaScript 身份。不要长期保存 toRaw() 的结果并绕开代理写入;它是临时逃生口,不是第二条更新通道。
只限制替换,同时保留外部对象身份
readonly(holder)可能继续把读出的嵌套对象包装为只读代理;如果第三方以 === 识别快照,这会改变身份。shallowReadonly(holder)只阻止ref根入口赋值,读出的快照仍保持原身份:
import { shallowReadonly, shallowRef } from 'vue'
const snapshot = { version: 1, text: 'draft' }
const inner = shallowRef(snapshot)
const state = shallowReadonly(inner)
console.log(state.value === snapshot) // true
// state.value = anotherSnapshot 会被只读边界阻止。
inner.value = { version: 2, text: 'published' } // 所有者通过原ref替换这不是深冻结,state.value.text = ...仍可能修改外部对象,但不会触发浅ref订阅。练习将这视为错误使用并验证它不发布;业务若要真正禁止内层修改,应让数据生产者交付不可变快照。发布版本必须是严格递增整数,旧版本应抛RangeError而不替换当前值。
ref 的解包边界
深层 reactive 对象的普通属性会解包 ref:
const age = ref(36)
const person = reactive({ age })
console.log(person.age) // 36,而不是 Ref 对象
person.age = 37
console.log(age.value) // 37数组元素、Map 值与浅层响应式对象不会做同样的自动解包,届时仍需 .value。这不是随机差异,而是为了避免集合元素的身份和类型在读取时悄悄变化;第 9 篇会专门验证。
受控验证:替换、深度和写权限
把下面代码保存为 container-lab.mjs 后运行。成功时无输出;任一合同被破坏都会抛出断言错误。
import assert from 'node:assert/strict'
import { isReactive, isReadonly, reactive, readonly, shallowRef } from 'vue'
const raw = { nested: { count: 0 } }
const deep = reactive(raw)
assert.equal(isReactive(deep.nested), true)
assert.notEqual(deep, raw)
assert.equal(reactive(raw), deep)
const external = { nested: { count: 0 } }
const holder = shallowRef(external)
assert.equal(holder.value, external)
assert.equal(isReactive(holder.value.nested), false)
const publicState = readonly(deep)
assert.equal(isReadonly(publicState), true)排错顺序
- 先打印
isRef、isReactive、isReadonly,确认拿到的容器类型。 - 再看修改入口:改的是
.value、代理属性,还是早已取出的普通值。 - 检查需求是深层修改还是根替换;
shallowRef的内层修改本来就不触发。 - 最后检查身份:集合 key、缓存和第三方库是否混用了 raw 与 proxy。
练习:迁移外部文档快照
旧代码把第三方编辑器生成的 DocumentSnapshot 放进 reactive(),编辑器用 === 判断快照身份,因收到代理而失效。请改成 shallowRef:
- 输入是
{ version: number, text: string },调用replace(next)才表示新快照。 - 暴露给消费者的状态不可直接替换;提供
replaceaction。 - 写一个 watcher 记录已发布版本。原地修改
state.value.version不应新增记录,整体替换应新增一次。 - 断言外部对象身份保持不变,并覆盖初始、错误原地修改、正确替换三条路径。
读完后应能回答:这个值由谁拥有、Vue 追踪哪一层、更新通过哪个入口发生、raw 与 proxy 的身份是否会越过边界。