Skip to content

07|响应式解构:变量拿到的是值,还是读取入口 ​

第三章:响应式系统纵览 / 前置:调度器与 flush / 下一篇:effectScope

Proxy 只能拦截“通过代理发生的属性读取”。把基本类型属性解构成局部变量后,后续读取不再经过代理,订阅线自然消失。

运行上下文 ​

普通 reactive() 解构规则属于运行时,在 Node 和浏览器一致。Vue 3.5+ 的 defineProps() 解构是 <script setup> 编译器特性,只在同一个脚本块中由编译器重写;不要把它外推到任意 reactive 对象。

值快照如何切断读取 ​

js
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 保留属性入口 ​

ts
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) // 50

toRef(state, 'page') 返回一个把 get/set 转发到源属性的 ref,不复制状态。toRefs(state) 只为调用当时可枚举的已有字段生成 ref;可选字段尚不存在时,使用单属性 toRef。

source → derived → effect:错误与修复 ​

ts
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 从未 track state.price;
  • price.value 重新转发到代理属性,total 失效并变为 40;
  • watcher 才会收到新 derived。

加 deep: true 或 nextTick() 都修不好 wrongTotal,因为缺的是读取边,不是深度或调度。

组合式函数的返回合同 ​

若组合式函数返回一个 reactive 对象,调用方很自然地会解构。为了让这个用法安全,可以直接返回 refs:

ts
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 解构例外 ​

vue
<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:

ts
watch(() => page, (value) => console.log(value))
useFeature(() => page)

直接写 watch(page, ...) 传入的是当前值,不是响应式 source。通用组合式函数可用 toValue() 统一读取普通值、ref 或 getter;unref() 只处理普通值和 ref,不调用 getter。

展开也会取值 ​

ts
const state = reactive({ page: 1 })
const copy = { ...state }

copy.page 是展开时的快照,copy 也不是 state 的只读视图。需要只读权限用 readonly(state);需要新快照就明确命名为 snapshot,不要让调用方误以为它会继续同步。

排错路线 ​

  1. 从不更新的局部变量回溯:它来自直接解构、展开,还是 toRef?
  2. 用 isRef / isReactive 确认运行时身份,不凭变量名猜。
  3. 若来自 props,确认 Vue 版本、是否位于同一 <script setup>,以及是否跨函数边界。
  4. watcher source 必须是 ref、reactive 对象、getter或它们的数组;不要传普通数字或字符串。
  5. 最后再检查 flush。没有依赖边时,等待多久都不会更新。

练习:修复可解构的分页组合式函数 ​

给定一个返回 reactive({ page, pageSize }) 的 usePagination(),调用方写 const { page } = usePagination() 后页面不更新。

  • 保持调用方可解构的合同,修复函数返回值。
  • next() 后 page.value 必须变化;给 page.value 赋值也必须回写源状态。
  • 新增一个调用时不存在的可选字段 cursor,解释为什么不能只依赖 toRefs(),并用 toRef 处理。
  • 用严格断言对照失败快照与修复后的属性 ref。

查看第 07 题参考答案。

用心记录每一步成长