07|响应式解构:变量拿到的是值,还是读取入口
第三章:响应式系统纵览 / 前置:调度器与 flush / 下一篇:effectScope
Proxy 只能拦截“通过代理发生的属性读取”。把基本类型属性解构成局部变量后,后续读取不再经过代理,订阅线自然消失。
运行上下文
普通 reactive() 解构规则属于运行时,在 Node 和浏览器一致。Vue 3.5+ 的 defineProps() 解构是 <script setup> 编译器特性,只在同一个脚本块中由编译器重写;不要把它外推到任意 reactive 对象。
值快照如何切断读取
import { reactive } from 'vue'
const state = reactive({ page: 1, filters: { keyword: '' } })
const { page, filters } = state
state.page = 2
console.log(page) // 1:解构时复制了 number
state.filters.keyword = 'vue'
console.log(filters.keyword) // 'vue':复制的是同一个嵌套对象引用filters 看似“仍然响应式”,因为取出的是嵌套代理对象;但 page 是普通 number 快照。规则不是“解构必定丢响应式”,而是看局部变量后续读取是否还经过一个响应式入口。
toRef / toRefs 保留属性入口
import { reactive, toRef, toRefs } from 'vue'
const state = reactive({ page: 1, pageSize: 20 })
const page = toRef(state, 'page')
const { pageSize } = toRefs(state)
page.value++
console.log(state.page) // 2
state.pageSize = 50
console.log(pageSize.value) // 50toRef(state, 'page') 返回一个把 get/set 转发到源属性的 ref,不复制状态。toRefs(state) 只为调用当时可枚举的已有字段生成 ref;可选字段尚不存在时,使用单属性 toRef。
source → derived → effect:错误与修复
const state = reactive({ price: 10 })
const { price: snapshot } = state
const price = toRef(state, 'price')
const wrongTotal = computed(() => snapshot * 2)
const total = computed(() => price.value * 2)
watch(total, (value) => console.log('effect', value))当 state.price = 20:
snapshot仍为10,wrongTotal从未 trackstate.price;price.value重新转发到代理属性,total失效并变为40;- watcher 才会收到新 derived。
加 deep: true 或 nextTick() 都修不好 wrongTotal,因为缺的是读取边,不是深度或调度。
组合式函数的返回合同
若组合式函数返回一个 reactive 对象,调用方很自然地会解构。为了让这个用法安全,可以直接返回 refs:
function usePagination() {
const state = reactive({ page: 1, pageSize: 20 })
const next = () => state.page++
return {
...toRefs(state),
next
}
}
const { page, pageSize, next } = usePagination()另一种合同是返回 state 本身并明确要求调用方用 pagination.state.page。选择哪一种不重要,重要的是文档、类型和使用方式一致。
Vue 3.5+ 的 props 解构例外
<script setup lang="ts">
import { watchEffect } from 'vue'
const { page = 1 } = defineProps<{ page?: number }>()
watchEffect(() => {
console.log(page)
})
</script>在 Vue 3.5+,编译器会把同一 <script setup> 中对 page 的访问改写为 props.page,所以父组件更新 prop 会重新运行 effect。Vue 3.4 及更早版本中,它只是常量快照。
把它传给函数时,仍要传 getter:
watch(() => page, (value) => console.log(value))
useFeature(() => page)直接写 watch(page, ...) 传入的是当前值,不是响应式 source。通用组合式函数可用 toValue() 统一读取普通值、ref 或 getter;unref() 只处理普通值和 ref,不调用 getter。
展开也会取值
const state = reactive({ page: 1 })
const copy = { ...state }copy.page 是展开时的快照,copy 也不是 state 的只读视图。需要只读权限用 readonly(state);需要新快照就明确命名为 snapshot,不要让调用方误以为它会继续同步。
排错路线
- 从不更新的局部变量回溯:它来自直接解构、展开,还是
toRef? - 用
isRef/isReactive确认运行时身份,不凭变量名猜。 - 若来自 props,确认 Vue 版本、是否位于同一
<script setup>,以及是否跨函数边界。 - watcher source 必须是 ref、reactive 对象、getter或它们的数组;不要传普通数字或字符串。
- 最后再检查 flush。没有依赖边时,等待多久都不会更新。
练习:修复可解构的分页组合式函数
给定一个返回 reactive({ page, pageSize }) 的 usePagination(),调用方写 const { page } = usePagination() 后页面不更新。
- 保持调用方可解构的合同,修复函数返回值。
next()后page.value必须变化;给page.value赋值也必须回写源状态。- 新增一个调用时不存在的可选字段
cursor,解释为什么不能只依赖toRefs(),并用toRef处理。 - 用严格断言对照失败快照与修复后的属性 ref。