HarmonyOS开发者限时福利来啦!最高10w+现金激励等你拿~ 了解详情
写点什么

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

评论

发布
暂无评论

Monibuca 中的内存复用

不卡科技

GC go语言 流媒体开发 内存池 Monibuca

机器学习洞察 | JAX,机器学习领域的“新面孔”

亚马逊云科技 (Amazon Web Services)

机器学习

Git教学

猫九

git 学习

深入理解 Python 虚拟机:字节(bytes)的实现原理及源码剖析

EquatorCoco

Python 虚拟机 字节

从php5.6到golang1.19-文库App性能跃迁之路

百度Geek说

golang App 百度文库

专利技术系列 001 | 鹏云网络分布式系统脑裂问题解决方案

鹏云网络

云计算 分布式系统 分布式存储 分布式系统脑裂 软件定义存储

前端开发需要了解的工具集合

树上有只程序猿

软件测试/测试开发丨Python常用数据结构-集合Set

测试人

Python 程序员 软件测试 测试开发

打工人都在用的AI工具

不在线第一只蜗牛

人工智能 工具 ChatGPT

KaiwuDB 亮相 2023 可信数据库发展大会

KaiwuDB

KaiwuDB 2023可信数据库发展大会

C++中vector自定义大小方式

梦笔生花

es笔记五之term-level的查询操作

Hunter熊

elasticsearch

华为云代码托管CodeArts Repo:保护企业核心代码资产安全

华为云PaaS服务小智

云计算 华为云 代码托管 华为开发者大会2023

软件测试 | 测试设计技巧—游戏类

测吧(北京)科技有限公司

测试

企业利用bi商业智能工具有哪些改变呢?以瓴羊QuickBI为例

对不起该用户已成仙‖

华为云“盘古气象”登上Nature!

新消费日报

如何用大模型 Prompt 解决行业场景问题?大厂中文教程来了!

飞桨PaddlePaddle

人工智能 百度 paddle 飞桨

直播软件开发知识:实现感知网络质量功能

山东布谷科技

源码 软件 软件开发 直播 源码搭建

代码随想录训练营Day08 - 字符串(上)

jjn0703

JavaScript 函数

猫九

不容错过的基础设施专场!今天4点,关于全场景质量协同平台T-One的介绍 | 第86-96期

OpenAnolis小助手

开源 基础设施 操作系统 龙蜥大讲堂 T-one

星辰天合公司产品完成阿里云PolarDB数据库产品生态集成认证

阿里云数据库开源

polarDB PolarDB-X PolarDB for PostgreSQL

用Vue3编写一个简单的组件

互联网工科生

Vue 3 slots

prometheus Histogram 统计原理

蓝胖子的编程梦

Grafana Prometheus #Grafana #Prometheus #监控

沙漠觅绿洲——华为HMS生态强势赋能伙伴成功掘金中东非

最新动态

文创商城项目实战

猫九

华为云盘古气象大模型研究成果在《Nature》正刊发表

新消费日报

[BitSail] Connector开发详解系列二:SourceSplitCoordinator

字节跳动数据平台

新兴技术诞生,国产操作系统崛起| 社区征文

梦笔生花

操作系统 国产开源 年中技术盘点

AWS云VS阿里云 横向对比

WuKongCoder

云计算 阿里云 AWS EMR

对线面试官-Redis(六 如何保证 Redis 高并发 主从复制原理)

派大星

Java 面试题

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