写点什么

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

评论

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

Loopback for Mac:打造专业音频环境,一键开启高效录音之旅!

Rose

过等保时候常提到的堡垒机是什么?有什么用?

行云管家

等保 堡垒机 等级保护 过等保

2024 TDengine 用户大会:顶尖专家带你决胜 AI 新时代

TDengine

CVPR2024论文解读|对齐人类审美!MPS让图像生成评估更“懂你”

快手技术

开源 #大模型

GPT被封锁了怎么办?轻松获取高质量的数据,训练自己的人工智能和大语言模型。

热爱编程的小白白

FinClip 强势登陆 AWS Marketplace,全球扩展战略迈出关键一步

FinClip

AI心灵伙伴:数业智能心大陆如何提升年轻成人的心理健康?

心大陆多智能体

压力 智能体 AI大模型 心理健康 数字心理

基于LangChain手工测试用例生成工具

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

测试

Pgsql 全量+增量迁移 tidb7.5

TiDB 社区干货传送门

7.x 实践

星辰考古:TiDB v4.0 进化前夜

TiDB 社区干货传送门

版本升级 版本测评 新版本/特性解读

【第七在线】智能推算辅助选品组货 驱动零售品牌业绩增长

第七在线

什么?for循环也会出问题?

阿里技术

for循环 故障排查

初识langchain:LLM大模型+Langchain实战[qwen2.1、GLM-4]+Prompt工程

汀丶人工智能

AI大模型

降成本,提人效:火山引擎VeDI实验平台架构升级

字节跳动数据平台

大数据 A/B 测试 数字化增长

玳数科技集成 Flink CDC 3.0 的实践

Apache Flink

大数据 flink Flink CDC chunjun

基于YMP工具实现Oracle迁移YashanDB

YashanDB

yashandb 崖山数据库 崖山DB

江苏企业采购堡垒机推荐哪家?为什么?

行云管家

堡垒机 过等保 江苏

参加DevSecOps认证培训 ,掌握安全高效的软件开发

雅菲奥朗

运维 SRE DevSecOps

降低大模型推理87%时延!华为云论文入选顶会USENIX ATC'24

华为云开发者联盟

人工智能 存储 华为云开发者联盟 LLM 企业号2024年7月PK榜

国内大模型LLM选择以及主流大模型快速使用教程[GLM4/Qwen/Baichuan/Coze/Kimi]

汀丶人工智能

AI大模型

API Token 是什么?深入介绍与实践指南

Apifox

程序员 安全 Token API

tidb7.5.1压测

TiDB 社区干货传送门

性能测评 7.x 实践

TiDB实践—索引加速+分布式执行框架创建索引提升70+倍

TiDB 社区干货传送门

7.x 实践

人工智能与情感分析:理解情感,驱动未来

天津汇柏科技有限公司

情感分析 人工智能’

基于 Three.js 的 3D 模型加载优化

vivo互联网技术

rust webassembly 3D模型 three.js web3d

解读「快意」大模型关键技术,揭秘实践中的挑战与创新

快手技术

NLP 大模型 #大模型

tidb源码研究分析日常碰到的bug

TiDB 社区干货传送门

TiDB 源码解读 6.x 实践 TiKV 源码解读

FlagEval 7月榜丨新增29个模型评测结果,智源发布评估技术报告《AI大模型能力全景扫描》

智源研究院

utf8mb4 默认排序规则引起的索引失效

TiDB 社区干货传送门

应用适配 数据库连接

铜线工厂生产管理MES系统解决方案

万界星空科技

mes 万界星空科技 铜线mes 生产管理软件 铜线工厂

基于LangChain手工测试用例生成工具

测试人

软件测试

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