AICon 深圳站聚焦 Agent 技术、应用与生态,大咖分享实战干货 了解详情
写点什么

Vue 的响应式机制就是个“坑”

jkonieczny

  • 2024-04-22
    北京
  • 本文字数:5473 字

    阅读完需:约 18 分钟

Vue 的 reactivity 响应式机制确实不错,只是有个“小”缺点:它会搞乱引用。本来一切看起来好好的,连 TypeScript 都说没问题,但突然就崩了。


我这里聊的可不是带有强制输入的嵌套引用,那明显更复杂、更混乱。只有对一切了然于胸的大师才能解决这类问题,所以本文暂且不表。


哪怕在日常使用当中,如果大家不了解其工作原理,reactivity 也可能引发各种令人抓狂的问题。


一个简单数组


让我们看看以下代码:


let notifications = [] as Notification[];function showNotification(notification: Notification) {  const { autoclose = 5000 } = notification;   notifications.push(notification);
function removeNotification() { notifications = notifications .filter((inList) => inList != notification); }
if (autoclose > 0) { setTimeout(removeNotification, autoclose); }
return removeNotification;}
复制代码


都挺好的,对吧?如果 autoclose 不为零,它就会自动从列表中删除通知。我们也可以调用返回的函数来手动将其关闭。代码又清晰又漂亮,哪怕调用两次,removeNotification 也能正常起效,仅仅删除掉跟我们推送到数组中的元素完全相同的内容。


好的,但它不符合响应式标准。现在看以下代码:


const notifications = ref<Notification[]>([]);function showNotification(notification: Notification) {  const { autoclose = 5000 } = notification;   notifications.value.push(notification);
function removeNotification() { notifications.value = notifications.value .filter((inList) => inList != notification); }
if (autoclose > 0) { setTimeout(removeNotification, autoclose); }
return removeNotification;}
复制代码


这完全就是一回事,所以应该也能正常运行吧?我们是想让数组迭代各条目,并过滤掉与我们所添加条目相同的条目。但情况并非如此。理由也不复杂:我们以参数形式收到的 notification 对象很可能是个普通的 JS 对象,而在数组中该条目是个 Proxy。


那该如何处理?


使用 Vue 的 API


如果我们出于某种原因而不想修改对象,则可以使用 toRaw 获取数组中的实际条目,调整之后该函数应该如下所示:


function removeNotification() {  notifications.value = notifications.value    .filter(i => toRaw(i) != notification);}
复制代码


简而言之,函数 toRaw 会返回 Proxy 下的实际实例,这样我们就可以直接对实例进行比较了。到这里,问题应该消失了吧?


不好意思,问题可能仍然存在,后面大家就知道为什么了。


直接使用 ID/Symbol


最简单也最直观的解决方案,就是在 notification 中添加一个 ID 或者 UUID。我们当然不想在每次代码调用通知时都生成一个 ID,比如 showNotification({ title: “Done!”, type: “success” }),所以这里做如下调整:


type StoredNotification = Notification & {  __uuid: string;};const notifications = ref<StoredNotification[]>([]);function showNotification(notification: Notification) {  const { autoclose = 5000 } = notification;  const stored = {    ...notification,    __uuid: uuidv4(),  }  notifications.value.push(stored);
function removeNotification() { notifications.value = notifications.value .filter((inList) => inList.__uuid != stored.__uuid); } // ...}
复制代码


由于 JS 运行时环境是单线程的,我们不会将其发送到任何其他地方,所以这里只需要创建一个计数器并生成 ID,具体参考以下代码:


let _notificationId = 1;function getNextNotificationId() {  const id = _notificationId++;  return `n-${id++}`;}// ...const stored = { ...notification, __uuid: getNextNotificationId(),}
复制代码


实际上,只要这里的 _uuid 不会被发送到其他地方,而且调用次数不超过 2⁵³次,那上述代码就没什么问题。如果非要改进,也可以加上带有递增值的日期时间戳。


如果担心 2⁵³这个最大安全整数值还不够用,可以采取以下方法:


function getNextNotificationId() {  const id = _notificationId++;  if (_notificationId > 1000000) _notificationId = 1;  return `n-${new Date().getTime()}-${id++}`;}
复制代码


到这里问题就解决了,但本文的重点不在于此。


使用“浅”响应


既然没有必要,为什么要使用“深”响应?说真的,我知道这很简单、性能也不错,但是……为什么要在非必要时使用“深”响应?


无需更改给定对象中的任何内容。我们可能需要显示通知的定义、一些相关标签,也许还涉及某些操作(函数),但这些都不会对内部造成任何影响。只需将 ref 直接替换成 shallowRef,就这么简单!


const notifications = shallowRef<Notification[]>([]);
复制代码


现在 notifications.value 将返回源数组。但容易被大家忽略的是,如此一来该数组本身不再具有响应性,我们也无法调用.push,因为它不会触发任何效果。所以说如果我们用 shallowRef 直接替换 ref,结果就是条目只有在被移除出数组时才会更新,因为这时我们才会用新实例重新分配数组。我们需要把:


notifications.value.push(stored);
复制代码


替换成:


notifications.value = [...notifications.value, stored];
复制代码


这样,notifications.value 将返回一个包含普通对象的普通数组,保证我们可以用 == 安全进行比较。


下面我们总结一下前面这些内容,并稍做解释:


  • 普通 JS 对象——就是一个简单的原始 JS 对象,没有任何打包器,console.log 将只输出{title: ‘foo’},仅此而已。

  • ref 与 shallowRef 实例会直接输出名为 RefImpl 的类的对象,其中包含一个字段(或者说 getter).value 和一些其他我们无需处理的私有字段。

  • ref 的.value 所返回的,就是会返回 reactive 的相同内容,即用于模仿给定值的 Proxy,因此它将输出 Proxy(Object){title: ‘foo’}。每个非原始嵌套字段也都是一个 Proxy。

  • shallowRef 的.value 返回该普通 JS 对象。同样的,这里只有.value 是响应式的(后文将具体解释),而且不涉及嵌套字段。


我们可以总结如下:


plain: {title: 'foo'}deep: RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: Proxy(Object)}deepValue: Proxy(Object) {title: 'foo'}shallow: RefImpl {__v_isShallow: true, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: {…}}shallowValue: {title: 'foo'}
复制代码


现在来看以下代码:


const raw = { label: "foo" };const deep = ref(raw);const shallow = shallowRef(raw);const wrappedShallow = shallowRef(deep);const list = ref([deep.value]);const res = {  compareRawToOriginal: toRaw(list.value[0]) == raw,  compareToRef: list.value[0] == deep.value,  compareRawToRef: toRaw(list.value[0]) == deep.value,  compareToShallow: toRaw(list.value[0]) == shallow.value,  compareToRawedRef: toRaw(list.value[0]) == toRaw(deep.value),  compareToShallowRef: list.value[0] == shallow,  compareToWrappedShallow: deep == wrappedShallow,}
复制代码


运行结果为:


{  "compareRawToOriginal": true,  "compareToRef": true,  "compareRawToRef": false,  "compareToShallow": true,  "compareToRawedRef": true,  "compareToShallowRef": false,  "compareToWrappedShallowRef": true}
复制代码


解释:


  • compareOriginal (toRaw(list.value[0]) == raw): toRaw(l.value[0]) 将返回与 raw 相同的内容:一个普通 JS 对象实例。这也证实了我们之前的假设。

  • compareToRef (list.value[0] == deep.value): deep.value 是一个 Proxy,与该数组要使用的 proxy 相同,这里无需创建额外的打包器。此外,这里还存在另一种机制。

  • compareRawToRef (toRaw(list.value[0]) == deep.value): 我们是在将“rawed”原始对象与 Proxy 进行比较。之前我们已经证明了 toRaw(l.value[0]) 与 raw 相同,因此它肯定不是 Proxy。

  • compareToShallow (toRaw(list.value[0]) == shallow.value): 然而,这里我们将 raw(通过 toRaw 返回)与 shallowRef 存储的值进行比较,而后者并非响应式,因此 Vue 在这里不会返回任何 Proxy,而仅返回该普通对象,也就是 raw。跟预期一样,这里没有问题。

  • compareToRawedRef (toRaw(list.value[0]) == toRaw(deep.value)): 但如果我们将 toRaw(l.value[0]) 与 toRaw(deep.value) 进行比较,就会发现二者拥有相同的原始对象。总之,我们之前已经证明 l.value[0] 与 deep.value 是相同的。可 TypeScript 会将此标记为错误。

  • compareToShallowRef (list.value[0] == shallow): 明显为 false,因为 shallowRef 的 Proxy 不可能与 ref 的 Proxy 相同。

  • compareToWrappedShallowRef (deep == wrappedShallow): 这是……什么玩意?出于某种原因,如果向 shallowRef 给定一个 ref,它只会返回该 ref。而如果源 ref 与预期 ref 均属于同一类型(浅或深),那就完全没问题。但这里……可就奇了怪了。


总结:


  • deep.value == list[0].value (一个内部 reactive)

  • shallow.value == raw (普通对象,没什么特别)

  • toRef(deep.value) == toRef(list[0].value) == raw == shallow.value (获取普通对象)

  • wrappedShallow == deep , 因此 wrappedShallow.value == deep.value (重用为该目标创建的 reactive )


现在来看第二个条目 ,根据 shallowRef 的值或者直接根据 raw 值进行创建:


const list = ref([shallow.value]);
复制代码


{  "compareRawToOriginal": true,  "compareToRef": true,  "compareRawToRef": false,  "compareToShallow": true,  "compareToRawedRef": true,  "compareToShallowRef": false}
复制代码


看起来平平无奇,所以这里我们只聊最重要的部分:


  • compareToRef (list.value[0] == deep.value): 我们将列表返回的 Proxy 与根据同一来源创建的 ref 的.value 进行比较。结果……为 true?这怎么可能?Vue 在内部使用 WeakMap 来存储对所有 reactive 的引用,所以当创建一个 reactive 时,它会检查之前是否已经重复创建并进行重用。正因为如此,从同一来源创建的两个单独 ref 才会彼此产生影响。这些 ref 都将拥有相同的.value。

  • compareRawToRef (toRaw(list.value[0]) == deep.value): 我们再交将普通对象与 RefImpl 进行比较。

  • compareToShallowRef (list.value[0] == shallow): 即使条目是根据 shallowRef 的值创建而成,列表也仍为“深”响应式,且会返回深响应式 RefImpl——其中所有字段均为响应式。因此比较式左侧包含 Proxy,而右侧是一个实例。


那又会怎样?


即使我们将列表的 ref 替换为 shallowRef,那么哪怕列表本身并非深响应式,只要以参数形式给定的值为响应式,则该列表也将包含响应式元素。


const notification = ref({ title: "foo" });
showNotification(notification.value);
复制代码


被添加进数组中的值将是 Proxy,而非{title: ‘foo’}。好消息是 == 仍然能够正确完成比较,因为.value 返回的对象也会随之改变。但如果我们只在一侧执行 toRaw,则 == 将无法正确比较两个对象。


总结


VUe 中的深响应式机制确实很棒,但也带来了不少值得我们小心警惕的陷阱。请大家再次牢记,在使用深响应式对象时,我们实际上一直在处理 Proxy、而非实际 JS 对象。


请尽量避免用 == 对响应式对象实例进行比较,如果确定必须这样做,也请保证操作正确——比如两侧都需要使用 toRaw。而更好的办法,应该是尝试添加唯一标识符、ID、UUID,或者使用可以安全比较的现有条目唯一原始值。如果对象是数据库中的条目,则很可能拥有唯一的 ID 或者 UUID(如果足够重要,可能还包含修改日期)。


千万不要直接使用 Ref 作为其他 Ref 的初始值。务必使用它的.value,或者通过 ToValue 或 ToRaw 获取正确的值,具体取决于大家对代码可调试性的需求。


方便的话尽量使用浅响应式,或者更确切地说:只在必要时使用深响应式。在大多数情况下,其实我们根本不需要深响应式。当然,通过编写 v-model=”form.name”来避免重写整个对象肯定是好事,但请想好有没有必要在一个只从后端接收数据的只读列表上使用响应式?


对于体量庞大的数组,我在实验渲染时成功实现了性能倍增。虽然 2 毫秒和 4 毫秒之间的差异可有可无,但 200 毫秒和 400 毫秒间的差异却相当明显。而且数据结构越是复杂(涉及大量嵌套对象和数组),这种性能差异就越大。


Vue 的响应式类型可谓乱七八糟,我们完全没必要非去避简就繁。而且只要一旦开始使用奇奇怪怪的机制,就需要更多奇奇怪怪的操作来善后。千万别在这条弯路上走得太远,及时回头方为正道。这里我就不讨论把 Ref 存储在其他 Ref 中的情况了,那容易让人脑袋爆炸。


太长不看:


  • 别嵌套 Ref。使用值(myRef.value)来代替,但请注意其中可能包含 reactive,哪怕是从 shallowRef 获取也无法避免。

  • 如果大家(出于某种原因)需要用 == 来比较对象实例,请使用 toRaw 以确保实际比较的是普通 JS 对象。只要可能,最好只比较原始唯一值,例如 ID 或者 UUID。


最后提醒大家,本文内容只供各位参考。如果您明确知晓自己在做什么、能做到什么,那请随意发挥。技术大牛不需要指导意见的无谓束缚。


原文链接:


https://dev.to/razi91/vues-reactivity-is-a-trap-2jci


今日好文推荐


生成式 AI,前端开发的终结者?无障碍组件告诉你:NO!


砍掉百万行代码,这些巨头开始给自家 App “割肉瘦身”


重塑 Jamstack:打造更简单、更强大的 Web 架构


尘封多年,Servo 重磅回归!Rust 加持,执行速度可超过 Chromium


2024-04-22 19:034037

评论

发布
暂无评论
发现更多内容

多云转晴:Databend 的天空计算之路

Databend

Redis删除键命令: 新手用del,老手用unlink,有何区别?

Java你猿哥

Java redis SSM框架 Java工程师 delete

把脉分布式事务的模型、协议和方案

小小怪下士

Java 分布式 分布式事务 后端

从「搭子」文化,看融云如何助力垂类社交应用增长

融云 RongCloud

融云 Z世代 通讯 交友 搭子

Oracle 23c 新特性实操体验优质文章汇总

墨天轮

数据库 oracle sql 新版本/特性解读

治理告警风暴,告警降噪的一些典型手段

巴辉特

告警风暴 告警降噪

中船互联与嘉为科技共同打造“IT运维管理”融合解决方案

嘉为蓝鲸

自动化运维 IT 运维 中船集团

Alibaba最新神作!耗时182天肝出来1015页分布式全栈手册太香了

Java你猿哥

Java 分布式 SSM框架 分布式核心原理解析 分布式开发

代码质量难评估?一文带你用 SonarQube 分析代码质量!

Java你猿哥

架构师 代码 SSM框架 sonar

阿里P7了!全靠死磕这份阿里全彩版"并发编程笔记",大厂必备!

Java你猿哥

Java 并发编程 架构师 java面试 Java工程师

我在 20 年的软件工程师生涯中学到的 20 件事

宇宙之一粟

翻译 软技能

ChatGPT,音乐,与数据库

沃趣科技

数据库 云原生 音乐 ChatGPT

基于 Flink CDC 的现代数据栈实践

Apache Flink

大数据 flink 实时计算

多家大厂CTO鼎力推荐的微服务架构设计模式真的硬核

小小怪下士

Java 程序员 微服务 后端

大型SRE组织设计与建设落地,且看腾讯蓝鲸如何做?

嘉为蓝鲸

腾讯 运维自动化 蓝鲸

Unity 之 月签到累计签到代码实现(ScriptableObject应用 | DoTween入场动画)

陈言必行

Unity 三周年连更

阿里全新推出:微服务突击手册,把所有操作都写出来了

Java你猿哥

微服务 微服务架构 Spring Cloud SSM框架

90%的Java开发人员都会犯的5个错误

Flink CDC 在易车的应用实践

Apache Flink

大数据 flink 实时计算

Flomesh 软负载 FLB GA 版本发布

Flomesh

负载均衡 云原生 Pipy

基于 Flink CDC 的现代数据栈实践

Apache Flink

大数据 flink 实时计算

Spring Boot 实现接口幂等性的 4 种方案

Java Spring Boot

HummerRisk V1.0 :架构升级说明

HummerCloud

开源 云安全 云原生安全

改写同事代码——血压操作集锦第一弹

Java你猿哥

Java IDEA java编程 SSM框架 表单设计

揭秘云原生时代企业可观测体系落地实践

嘉为蓝鲸

云原生应用 云原生(Cloud Native) 可观测宇宙

【FAQ】关于华为推送服务因营销消息频次管控导致服务通讯类消息下发失败的解决方案

HarmonyOS SDK

HMS Core

3月寒窗!啃透美团保姆级分布式进阶技术手册,4月终入美团定L8

Java你猿哥

Java 分布式 SSM框架 分布式数据 分布式消息

不懂就问,Milvus 新上线的资源组功能到底怎么样?

Zilliz

非结构化数据 Milvus Zilliz

python统计程序耗时 | python小知识

AIWeker

Python python小知识 三周年连更

字节跳动正式开源分布式训练调度框架 Primus

字节跳动开源

开源 算法 流批一体

Vue 的响应式机制就是个“坑”_架构/框架_InfoQ精选文章