Vue 3 响应式解构:为什么替换 user 后出现“李四 / 王五”
通过一道 Vue 3 代码题,区分 reactive 对象解构、嵌套属性修改与对象替换的行为,并用 toRef 保持源属性关联。文章保留首次作答及追问补答,说明从误判到纠正的过程。

文章目录 11
题目信息
- 出题日期:2026-11-06
- 题目:面试题:Vue 3 reactive 对象解构与嵌套对象替换
- 领域:Vue 3、Composition API、响应式原理
- 难度:L2 基础应用(中级偏低)
- 预计用时:10–15 分钟
- 题型:代码题、原理题
- 考察方向:分析响应式对象解构后的读取与更新行为,解释不同修改方式对页面展示的影响,并给出合理的修改方案。
面试题
下面是一个 Vue 3 单文件组件:
<script setup>
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: '张三' },
})
const { count, user } = state
function updateFields() {
state.count += 1
state.user.name = '李四'
}
function replaceUser() {
state.user = { name: '王五' }
}
</script>
<template>
<p>计数:{{ count }} / {{ state.count }}</p>
<p>姓名:{{ user.name }} / {{ state.user.name }}</p>
<button @click="updateFields">修改字段</button>
<button @click="replaceUser">替换用户</button>
</template>请分析这段代码,并完成以下要求:
- 分别说出首次渲染、点击一次“修改字段”后、再点击一次“替换用户”后,两行文字显示的内容,并解释原因。
- 在不修改两个更新函数、保留
state作为数据源的前提下,给出最小修改,使模板中的count和user始终反映state当前的内容,并解释修改依据。
约束:按上述顺序操作,每次点击后都等待页面更新完成;组件期间不会卸载或重新挂载。
标准答案
口述版标准答案
解构发生在 setup 执行的那一刻,它把源属性当时的值取出来存进局部变量,从此局部变量与源属性再无关联。
count取到的是数字0,是一个值拷贝。之后state.count += 1改变的是源对象,局部count不会跟着变,模板读局部count时也不会重新访问state.count,所以它永远停在0。user取到的是嵌套响应式对象的引用。state.user.name = '李四'修改的是这个对象内部的内容,局部user指向的正是同一个对象,因此user.name会变成“李四”,模板能正常更新。state.user = { name: '王五' }是整体替换属性值。state.user指向了新的对象,但局部user仍然指向旧对象(旧对象的内容还是“李四”),所以两个位置显示不同:局部user.name是“李四”,state.user.name是“王五”。- 这次赋值会触发渲染更新:它是通过响应式代理
set拦截器写入的,依赖state.user的模板表达式会被重新求值。它不会让局部user更新——局部变量根本不在响应式系统的追踪范围内。
三个阶段的最终显示:
| 阶段 | 计数行 | 姓名行 |
|---|---|---|
| 首次渲染 | 计数:0 / 0 |
姓名:张三 / 张三 |
| 点击“修改字段” | 计数:0 / 1 |
姓名:李四 / 李四 |
| 再点击“替换用户” | 计数:0 / 1 |
姓名:李四 / 王五 |
关键原理
解构的三种结果,取决于取出来的是什么:
- 基本类型(
count) → 取到值拷贝,响应式追踪在此彻底断开。 - 嵌套对象(
user) → 取到的是 reactive 代理返回的响应式子对象引用,对它的属性修改仍能被追踪;但整体替换state.user时,局部变量不会跟着换指向。 - 数组 → 同嵌套对象,取到的是响应式数组代理。
为什么整体替换会触发更新,但局部变量不更新? Vue 的响应式依赖建立在 Proxy 的 get/set 拦截上:
- 模板渲染时读取
state.user,触发了代理的get,此时state.user这个属性被记录为当前渲染函数的依赖。 state.user = { name: '王五' }触发代理的set,通知依赖了state.user的渲染函数重新执行,重新读取到新对象 →state.user.name显示“王五”。- 而局部的
user是普通变量,不在代理的拦截范围内,它保存的旧引用没有任何机制去感知源属性被换掉了。
一句话总结:reactive 只对通过代理访问的属性保持追踪;解构等于把访问权从代理上摘下来交给了普通变量,除非你拿到的值本身仍是响应式代理。
可直接照抄的代码
最小修改:把解构换成 toRef,保留两个更新函数与模板不变。
import { reactive, toRef } from 'vue'
// state 的定义保持原样。
const count = toRef(state, 'count')
const user = toRef(state, 'user')完整组件:
<script setup>
import { reactive, toRef } from 'vue'
const state = reactive({
count: 0,
user: { name: '张三' },
})
const count = toRef(state, 'count')
const user = toRef(state, 'user')
function updateFields() {
state.count += 1
state.user.name = '李四'
}
function replaceUser() {
state.user = { name: '王五' }
}
</script>
<template>
<p>计数:{{ count }} / {{ state.count }}</p>
<p>姓名:{{ user.name }} / {{ state.user.name }}</p>
<button @click="updateFields">修改字段</button>
<button @click="replaceUser">替换用户</button>
</template>为什么 toRef 能解决:toRef(state, 'count') 返回一个 ObjectRefImpl,它内部持有源对象的引用和属性名 key,读写都转发到 state[key]。因此:
- 读
count.value→ 实际读state.count,始终是当前值; - 写
count.value = x→ 实际写state.count,触发代理set,依赖正常更新; - 源属性被整体替换(
state.user = {...})时,user.value依然转发到state.user,自然拿到新对象。
模板中 {{ count }} 会自动解包 ref(顶层 setup 绑定的 ref 在模板中不需要 .value),所以模板代码不用改。
工程取舍与适用边界
| 方案 | 写法 | 适用 | 代价 |
|---|---|---|---|
不解构,直接用 state.count |
模板写 state.count |
最简单,无心智负担 | 模板里层级变深 |
toRef |
const count = toRef(state, 'count') |
需要把属性单独传给子组件或组合式函数时 | 多一层 ref 概念 |
toRefs |
const { count, user } = toRefs(state) |
一次解构多个属性 | 同样需要 .value 意识 |
storeToRefs |
Pinia 场景 | 从 store 取 state/getter | 只能用于 state 和 getter,不能用于 action |
边界提醒:
toRef(state, 'count')只在state是响应式对象时才有意义;对普通对象用toRef得到的是「普通 ref」,不会与源属性同步。toRefs解构出来的每个属性都是 ref,在 JS 里使用时必须写.value(模板中自动解包)。这是它相对toRef逐个声明更容易踩坑的地方。- 如果只是想在模板里读值,不解构是最省心的选择——不要为了“少写几个字”引入响应式陷阱。
- 传递响应式对象给子组件时,Vue 会自动做深层代理,子组件里的修改会同步影响父级;传
toRef出来的 ref 则是「引用同一状态」而非「拷贝」,这一点在设计 props 契约时要清楚。
我的回答
- 首次渲染,“计数:0 / 0”,“姓名:张三 / 张三”
- 点击一次修改字段后,“计数:0 / 1”,“姓名:李四 / 李四”
- 点了一次替换用户,“计数:0 / 1”,“姓名:李四 / 李四”
- 首次渲染没什么疑问,我们可以看到state中有一个基础变量和引用变量,reactive后变成响应式,它在setup的上下文进行解构,那么后续如果改动state,基础变量的引用就断掉了,引用变量才记得住,然后触发页面的重新渲染;然后点了一次替换用户,重新赋值,这个是不触发响应式的;
- state从reactive变成ref,它对所有.value的操作都可以追踪;如果两个函数代码都不能改的话,通过toRef 来创建对于的ref,这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值,反之亦然
追问补答:
当然不会指向同一个对象,我刚才看错了,以为是state的整个替换,这也是不会触发页面的渲染,如果只是state.user,页面中的姓名应该变成了 "李四 / 王五"
面试点评
首答评分:65 / 100
| 评分项 | 得分 |
|---|---|
| 正确性 | 27 / 40 |
| 原理理解 | 11 / 20 |
| 工程应用 | 15 / 20 |
| 边界与权衡 | 4 / 10 |
| 面试表达 | 8 / 10 |
| 总分 | 65 / 100 |
正确点
首次渲染和点击“修改字段”后的两行结果都正确。已经注意到解构得到的基本类型值与对象引用后续表现不同。
对基于源对象属性创建的 toRef 的描述正确:它能保持与源属性的双向关联。这符合本题保留两个更新函数的修改方向。
遗漏点
没有分别说明替换后局部变量 user 和 state.user 各自指向哪个对象。
修改方案缺少具体的变量定义,以及这些变量在模板中如何使用。因此方案方向可认可,但还不能认定实现已经完成。
错误点
“重新赋值不触发响应式”这个结论不成立。这里执行的是 state.user = ...,仍然是在通过响应式代理修改属性,会触发依赖该属性的模板更新。
“后续修改时,基础变量的引用才断掉”也不够准确:JavaScript 解构在执行时就取得了当时的数值,局部 count 并没有持续关联源属性。
另外,仅把 state 改成 ref 还不能完成本题:脚本中的属性访问需要相应调整,原来的解构问题也需要处理,这与两个更新函数不能修改的约束有关。
更适合面试口述的表达
解构会读取当时的属性值。
count得到的是数值,后续读取局部count不会重新访问state.count;user得到的是嵌套响应式对象的引用,所以修改这个对象的姓名时,相关展示能够更新。分析替换操作时,还需要分别追踪局部变量和源属性的指向。
递进追问与补答
追问(首答点评后提出)
只聚焦“替换用户”这一步:执行
state.user = { name: '王五' }后,user与state.user还指向同一个对象吗?据此重新判断姓名一行的展示。
用户补答
当然不会指向同一个对象,我刚才看错了,以为是state的整个替换,这也是不会触发页面的渲染,如果只是state.user,页面中的姓名应该变成了 “李四 / 王五”
追问补答评分:88 / 100(按「追问表现」记录,不新增正式证据数,不覆盖首答评分)
| 评分项 | 得分 |
|---|---|
| 正确性 | 38 / 40 |
| 原理理解 | 17 / 20 |
| 工程应用 | 18 / 20 |
| 边界与权衡 | 6 / 10 |
| 面试表达 | 9 / 10 |
| 总分 | 88 / 100 |
正确点
明确指出替换后 user 与 state.user 不再指向同一个对象,并正确判断姓名显示为 “李四 / 王五”。这说明已经能区分「属性替换」与「局部变量保留的对象引用」。
遗漏点
还差一句机制解释:为什么给 state.user 赋值能触发模板更新。面试时应把「对象指向发生变化」和「赋值经过响应式代理」连接起来说明。
错误点
对本题替换步骤已没有明确错误。
不过,“整个 state 替换不会触发渲染”需要明确:如果指给普通局部变量重新赋值,该赋值本身不经过原代理的属性拦截;而原题的 state 使用 const 声明,直接重新赋值会报错,不能作为可执行操作讨论。
更适合面试口述的表达
替换后,局部
user仍指向之前的嵌套对象,state.user则指向新对象,因此姓名显示为“李四 / 王五”。这次赋值修改了响应式代理的属性,会触发依赖该属性的模板更新。
遗留的最后一问(本轮未获得补答)
沿用 toRef 方案:只调整 import 和原解构位置的变量定义,保留两个更新函数及模板,写出让 count、user 持续关联源属性的最小修改。完整答案见上文「标准答案 · 可直接照抄的代码」。
最终总结
这道题真正考的不是语法,而是「响应式的追踪边界在哪」。
reactive的追踪建立在 Proxy 的get/set上。解构等于在 setup 执行的那一刻把属性值「摘」下来交给普通变量——取到值拷贝就彻底断链,取到响应式子对象引用则只断了「换引用」这一条路。- 因此本题三个阶段的差异都可以用一句话解释:局部
count是值拷贝所以不变;局部user是旧引用所以显示“李四”;state.user被换成新对象所以显示“王五”。 - 整体替换属性值依然会触发渲染,因为它经过代理的
set;这与「局部变量不更新」是两件独立的事,前者关于依赖通知,后者关于引用指向。
实践上的三条结论:
- 不需要就别解构。 模板里直接写
state.count永远正确,零心智负担。 - 需要把属性单独传出去时用
toRef/toRefs,它们把「访问权」交还给源代理,既保持同步又能在模板中自动解包。 - 面试时把「引用指向」和「依赖通知」分开讲——这题的两个陷阱(局部变量不更新、替换属性仍会渲染)分别落在这两条轴上,混在一起讲最容易讲错。
能力画像更新:Vue 正式证据数为 3,维持 L2、低置信度。首答 65 分暴露「把属性替换误判为不触发响应式」,追问补答 88 分证明纠正到位且能说清对象指向差异,但「响应式触发条件」这一小块仍需通过不同场景复测确认稳定性。
相关文章
- 面试题-基础
面试题:AI 前端中的模型 API Key 安全
本题考察模型调用链路设计,以及前端环境变量、BFF 和接口安全。
- 面试题-基础
Vue 3 编辑弹窗:草稿状态、取消重开与父子数据契约
以一个始终挂载的姓名编辑弹窗为场景,给出覆盖数据归属、保存取消、重开初始化和外部更新冲突的完整 Vue 3 解法。结合首次回答与递进补答,说明本地草稿、脏标记和版本校验各自解决的问题。
- 面试题-基础
JavaScript 浅拷贝、深拷贝与 structuredClone:前端状态更新的正确方式
通过一道对象复制代码题,梳理 JavaScript 浅拷贝、JSON 深拷贝、structuredClone 和 transferable object 的边界,并解释 React 结构共享与 Vue 3 响应式更新的差异。
评论
0 条评论会在审核后展示,邮箱不会公开。