先记住这个答案
对同一个普通原始对象反复调用 reactive,会得到同一个响应式代理;再次把这个 reactive 代理传给 reactive,也会返回它自身。代理与原始对象不严格相等,因为它们是不同对象,访问代理才会经过响应式处理。这个结论不意味着字段相同的两个对象会合并,也不能随意推广到不同库副本或 readonly、shallow 等不同边界。业务选择与去重通常更适合依据稳定 ID。
- 同一 raw 的普通 reactive 转换可复用代理
- raw 与 proxy 不严格相等,字段相同也不代表同一对象
- 持续使用代理访问状态,避免绕过通知入口
同一原始对象与相同内容不是一回事
接口返回一个商品对象后,多个调用方若对同一个对象引用调用 reactive,不需要各自得到互不相认的普通代理。Vue 会复用这类转换的代理身份。可是再次请求得到一个新的 JSON 对象,即使商品 ID 和名称完全一致,也仍是新的 JavaScript 对象,不会因为内容相等自动命中原对象的身份。
下面示例分别比较重复转换、代理再次转换和另一份同内容对象。它只说明身份关系,不把代理缓存当成业务实体缓存。页面若需要跨请求合并同一商品,还应依据业务 ID 和更新规则组织数据,而不是把 reactive 当作按字段去重的数据库。
import { reactive } from 'vue';
const raw = { sku: 'book-7', stock: 3 };
const first = reactive(raw);
const again = reactive(raw);
const sameContent = reactive({ sku: 'book-7', stock: 3 });
const checks = {
sameRaw: first === again,
proxyAgain: reactive(first) === first,
equalsRaw: first === raw,
sameFields: first === sameContent
};在 Vue 3 环境中,checks 的前两项为 true,后两项为 false。示例没有控制台输出;比较的是引用身份,并没有比较两个商品字段是否相同。
混用 raw 与 proxy 为什么让集合行为难理解
原生 Set 和 Map 使用对象身份作为键的一部分,若某处保存 raw,另一处拿 proxy 查询,不能简单按严格相等期待命中。Vue 的响应式集合有自己的代理处理,但业务代码若混杂原生集合、外部 SDK 与代理对象,仍容易产生难以追踪的身份差异。
可以在边界统一使用代理,或在业务选择、缓存索引中使用稳定的 sku 等 ID。需要拿原始对象交给某个外部库时,应明确这个边界并避免长期同时保留两条随意写入的路径。toRaw 是临时绕过代理的工具,不能把它当成复制数据或解除所有关联的方法。
直接改原对象不会自动经过代理通知
给 raw.stock 赋值修改的仍是底层同一份数据,但这次写入没有经过响应式代理入口,因此不能依赖它触发 Vue 更新。之后某个逻辑如果因为其它原因读取代理,可能又看见已经变化的字段,这种偶然刷新会让界面表现不一致。
嵌套对象访问也可能返回代理,因此外部保留的原始子对象与 state.child 不一定相等。排查时应追踪对象从哪里进入响应式系统、谁持有哪一个引用以及谁执行写入,而不是到处增加深比较。只读、浅层与多个 Vue 副本的转换语义也不宜与普通 reactive 混为一个全局唯一承诺。
容易答错的地方
- reactive 会直接把原对象改造成代理对象
- 返回的代理是另一种访问入口,并不是把所有已有 raw 引用自动替换掉。先前拿到原对象的调用方仍然持有原引用,直接写它不会自动经过代理处理。
- toRaw 返回的是独立快照
- 它返回对应原始对象,并非深克隆。修改它可能改变代理底层数据而绕过通知;需要快照时应明确复制方案以及数据类型边界。
面试官还会怎么问?
重新赋值给保存 reactive 的变量会更新所有使用者吗?
不会自动替换其它调用方已经持有的旧代理。需要整体替换对象时,可以考虑稳定的 ref 容器,或让数据所有者明确更新同一代理中的字段。
为什么按对象比较删除列表项有时失败?
待删除项和列表中取出的项可能分别是原始对象与代理,或者来自两次请求的不同实例。先确定业务身份,通常使用稳定 ID 更适合表达要删除哪一个实体。
不同内容的对象能得到同一个代理吗?
对同一个原始对象修改字段并不会改变其代理身份;对两个不同对象,则不能因为内容相近期待共用代理。对象身份与当前字段内容是两个独立维度。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。