跳到主要内容
← 返回文章列表
面试题-基础2026/11/06约 11 分钟读完

Vue 3 响应式解构:为什么替换 user 后出现“李四 / 王五”

通过一道 Vue 3 代码题,区分 reactive 对象解构、嵌套属性修改与对象替换的行为,并用 toRef 保持源属性关联。文章保留首次作答及追问补答,说明从误判到纠正的过程。

Vue 3 响应式解构:为什么替换 user 后出现“李四 / 王五”
文章目录 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>

请分析这段代码,并完成以下要求:

  1. 分别说出首次渲染、点击一次“修改字段”后、再点击一次“替换用户”后,两行文字显示的内容,并解释原因。
  2. 在不修改两个更新函数、保留 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 姓名:李四 / 王五

关键原理

解构的三种结果,取决于取出来的是什么:

  1. 基本类型(count) → 取到值拷贝,响应式追踪在此彻底断开。
  2. 嵌套对象(user) → 取到的是 reactive 代理返回的响应式子对象引用,对它的属性修改仍能被追踪;但整体替换 state.user 时,局部变量不会跟着换指向。
  3. 数组 → 同嵌套对象,取到的是响应式数组代理。

为什么整体替换会触发更新,但局部变量不更新? 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;这与「局部变量不更新」是两件独立的事,前者关于依赖通知,后者关于引用指向。

实践上的三条结论:

  1. 不需要就别解构。 模板里直接写 state.count 永远正确,零心智负担。
  2. 需要把属性单独传出去时用 toRef / toRefs,它们把「访问权」交还给源代理,既保持同步又能在模板中自动解包。
  3. 面试时把「引用指向」和「依赖通知」分开讲——这题的两个陷阱(局部变量不更新、替换属性仍会渲染)分别落在这两条轴上,混在一起讲最容易讲错。

能力画像更新:Vue 正式证据数为 3,维持 L2、低置信度。首答 65 分暴露「把属性替换误判为不触发响应式」,追问补答 88 分证明纠正到位且能说清对象指向差异,但「响应式触发条件」这一小块仍需通过不同场景复测确认稳定性。

评论

0 条

评论会在审核后展示,邮箱不会公开。

正在确认登录状态

5000 / 5000