写点什么

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:033756

评论

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

Vue 生命周期 钩子函数,mybatisdao接口工作原理

Java 程序员 后端

XXL-Job启动源码详解,Java日常开发的12个坑,你踩过几个

Java 程序员 后端

zabbix监控nginx、mysql、java应用,64位java8百度云盘

Java 程序员 后端

Zookeeper 集群部署的那些事儿,消息队列rabbitmq面试

Java 程序员 后端

ICCV 2021人脸鉴伪比赛全赛道冠军!AI反诈这块,百度算是弄明白了

科技热闻

技术分享| RTC通讯中常用的音频格式

anyRTC开发者

音视频 WebRTC RTC 语音通话 音频格式

“ShardingCore”是如何针对分表下的分页进行优化的,深入理解linux内核架构

Java 程序员 后端

“情商比智商重要”,java面试代码题

Java 程序员 后端

“打工人”都在用的邮件使用规范,入职3个月的Java程序员面临转正

Java 程序员 后端

Zookeeper用作注册中心的原理,张孝祥jsp视频教程

Java 程序员 后端

“一学就会”微服务的架构模式,一名毕业三年的女程序媛面试头条经验

Java 程序员 后端

Vue 数组操作,java基础教程百度网盘

Java 程序员 后端

Vue学习之v-if和v-for指令,tomcat常见面试题

Java 程序员 后端

Vue学习之事件修饰符,java后端开发入门

Java 程序员 后端

Vue学习之自定义指令,宅家36天咸鱼翻身入职腾讯

Java 程序员 后端

requests库与 lxml 库常用操作整理+总结,爬虫120例阶段整理篇

梦想橡皮擦

11月日更

WPF学习——依赖项属性,中软国际java面试流程

Java 程序员 后端

《JVM系列》 第五章 -- 堆空间与对象分配,springboot项目分层架构

Java 程序员 后端

《Spring实战》读书笔记-第3章 高级装配,全网最具深度的三次握手、四次挥手讲解

Java 程序员 后端

vue移动端自适应,mybatis面试问题

Java 程序员 后端

yum安装ansible报错如何解决,自定义线程池面试题

Java 程序员 后端

windows下nginx的安装及使用,linux实用教程第三版pdf

Java 程序员 后端

“数组&方法”常见知识分解,简述java编译原理

Java 程序员 后端

zookeeper原理篇-Zookeeper选举过程分析,深入linux内核架构pdf下载

Java 程序员 后端

《大型数据库技术》MySQL的进阶开发技巧,java基础知识重点总结pdf

Java 程序员 后端

xxl-job 源码运行解析,java基础编程视频

Java 程序员 后端

zookeeper分布式锁,java开发技术教程

Java 程序员 后端

“穷苦乡村”小伙就得安于现状,你掌握了多少?

Java 程序员 后端

vue遇到的坑,linux网络编程pdf百度云

Java 程序员 后端

【终极预告】Apache ShardingSphere Dev Meetup 彩蛋篇

SphereEx

开源社区 ShardingSphere Meetup SphereEx 热门活动

ZooKeeper实现生产-消费者队列,万字长文总结Java多进程

Java 程序员 后端

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