【AICon】AI 基础设施、LLM运维、大模型训练与推理,一场会议,全方位涵盖! >>> 了解详情
写点什么

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

评论

发布
暂无评论

spring源码视频教程,java尚学堂,Java项目视频

Java 程序员 后端

【大牛系列教学】,数据库系统原理及mysql应用教程第二版,面试心得体会

Java 程序员 后端

一条正确的Java职业生涯规划,毕业工作5年被裁

Java 程序员 后端

一文详解,java基础入门第二版课后答案黑马,Java校招面试

Java 程序员 后端

【Spring注解驱动开发】未来教育二级java激活码,Java基础项目实战

Java 程序员 后端

【面试必会】极客时间吾爱破解,和腾讯大牛的技术面谈

Java 程序员 后端

一个三非渣本的Java校招秋招之路,mysql使用教程,Java程序员全套

Java 程序员 后端

【大牛疯狂教学】,java教程网站免费,成功入职腾讯月薪45K

Java 程序员 后端

一条正确的Java职业生涯规划,浦发银行Java开发笔试题

Java 程序员 后端

tomcat服务器面试题,java项目开发实训教程,Java编程教程视频下载

Java 程序员 后端

tomcat面试题汇总,java设计模式菜鸟教程,linux内核教程

Java 程序员 后端

【Spring注解驱动开发】java基础全套视频教程,被逼无奈开始狂啃底层技术

Java 程序员 后端

一次违反常规的Java大厂面试经历,2021Java网络编程总结篇

Java 程序员 后端

【面试总结】尚硅谷2021百度云,Java技术基础知识总结

Java 程序员 后端

一年后斩获腾讯T3,headfirstjavapdf百度云,带你碾压面试官!

Java 程序员 后端

一年后斩获腾讯T3,一次违反常规的Java大厂面试经历

Java 程序员 后端

【微信小程序】,java程序开发范例宝典百度云,Java零基础自学书籍

Java 程序员 后端

一个三非渣本的Java校招秋招之路,2021高级Java笔试总结

Java 程序员 后端

一名毕业三年的女程序媛面试头条经验,Java项目视频百度

Java 程序员 后端

一招教你看懂Netty!硅谷一至五季百度网盘,springmvc源码分析图

Java 程序员 后端

一文了解OOM及解决方案,尚硅谷java课程表,Java编程入门教材

Java 程序员 后端

【工作感悟】牛客java面试宝典pdf,助你面试一臂之力

Java 程序员 后端

【金九银十】,java程序设计精编教程第三版,Redis有几种数据类型

Java 程序员 后端

一个Java程序员的腾讯面试心得,这次被它搞惨了

Java 程序员 后端

一个月成功收割腾讯、阿里、字节offer,springmvc面试题常问2020

Java 程序员 后端

tomcat面试题,传智播客java就业班视频教程,Spring的XML解析原理

Java 程序员 后端

“金三银四”春招指南!linux高级编程教程,和阿里大佬的技术面谈

Java 程序员 后端

【一篇文章搞懂】,java程序设计案例教程许敏,费时6个月成功入职阿里

Java 程序员 后端

【工作经验分享】kafka视频教程下载,Java开发者跳槽面试

Java 程序员 后端

【干货,马士兵的java教程,这个JVM虚拟机内存模型你必须知道

Java 程序员 后端

一眼就能看懂的Java自学手册,阿里巴巴Java编程笔试题

Java 程序员 后端

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