问题场景
用 Vue3 Composition API 写了一个商品列表组件:
vue
<script setup>
import { ref, reactive, onMounted } from 'vue'
const state = reactive({
list: [],
loading: false,
page: 1,
total: 0
})
// ❌ 常见操作:解构出来用
const { list, loading, page, total } = state
const loadMore = () => {
page.value++ // ❌ 报错:page is not a ref
console.log(page) // 输出 1,但页面不会更新
}
onMounted(async () => {
const res = await fetch('/api/goods?page=1')
const data = await res.json()
list.push(...data.list) // ✅ 数组变了
// ❌ 但页面没渲染!
total = data.total // ❌ 页面也没更新
loading = false // ❌ 页面还是 loading 状态
})
</script>
代码逻辑自认为都对,但 页面纹丝不动。total 从 0 变 100,loading 从 true 变 false,渲染层永远停留在初始状态。
原因分析
Vue3 的 reactive 基于 Proxy 实现响应式 。Proxy 拦截的是对原始代理对象 的属性读写。当你 const { list, loading } = state 解构时:
- 读取发生在解构那一刻 ---
list拿到的是state.list的当前值副本,而不是一个"活的"引用 - 后续对
list的.push()之所以有效,是因为state.list本身是数组(引用类型),list持有同一个 Proxy 数组的引用,改数组内容触发 Proxy 的 set 拦截 - 但
total和loading是原始值 (number/boolean),解构后变成普通的 JS 变量,完全脱离了 Proxy 的追踪 - 对普通变量的赋值
total = data.total只是在给局部变量重新绑定,没有触发任何 set 拦截
总结一句:reactive 解构基本类型 = 自动丧失响应式。
解决方案
方案一:不解构,直接用 state.xxx
vue
<script setup>
const state = reactive({
list: [],
loading: false,
page: 1,
total: 0
})
const loadMore = async () => {
state.page++
const res = await fetch(`/api/goods?page=${state.page}`)
const data = await res.json()
state.list.push(...data.list)
state.total = data.total
state.loading = false
}
</script>
最朴素也最有效。模板里写 state.list 啰嗦但绝对可靠。
方案二:改用 ref 一把梭
vue
<script setup>
const list = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
// ref 解构出来直接就是响应式的,不存在丢失问题
</script>
ref 存储原始值时,通过 .value 读写本质上就是通过 getter/setter,解构出来的 list 仍然是 Ref 对象引用,响应性不会丢。
方案三:需要解构时用 toRefs
vue
<script setup>
const state = reactive({
list: [],
loading: false,
page: 1,
total: 0
})
// ✅ toRefs 把每个属性变成 ref
const { list, loading, page, total } = toRefs(state)
// 现在可以愉快解构了
console.log(page.value) // 1
loading.value = true // ✅ 响应式更新
total.value = 100 // ✅ 触发渲染
</script>
原理 :toRefs 遍历 reactive 对象的每个 key,生成对应的 Ref(本质是 getter/setter),解构后拿到的每一个变量仍然是 Ref 对象引用,能做到"用值不等于断连"。
方案四:computed 读取不依赖解构
vue
<script setup>
const state = reactive({ count: 0, double: 0 })
// ❌ 不要这样
// const { count } = state
// const double = computed(() => count * 2) // 每次都是 0
// ✅ 这样
const double = computed(() => state.count * 2)
// ✅ 或包装成 ref
const count = computed(() => state.count)
</script>
要点总结
| 场景 | 推荐做法 |
|---|---|
| 少量字段,简单组件 | 直接 state.xxx,最稳 |
| 大量字段,频繁操作 | 用 ref 替代 reactive |
必须在外部解构 reactive |
toRefs() 包一层 |
模板中解构(<template>) |
自动解构,不受影响(模板编译时自动包装) |
嵌套深层的 reactive |
用 shallowReactive + 手动触发,或用 Pinia |
一句话记住 :reactive 解构 = 抽走快照,toRefs 解构 = 保留通道。选哪个,看你想要的是值还是响应链路。
📌 冷知识 :Vue3 官方文档其实明确写了"reactive 的属性解构或展开为局部变量会丢失响应性",但每 10 个 Vue3 开发者里至少有 6 个第一次碰到时都踩了。你不是一个人 🫡