写点什么

揭秘!Vue3.5 响应式重构如何让内存占用减少 56%

作者:EquatorCoco
  • 2024-11-13
    福建
  • 本文字数:2759 字

    阅读完需:约 9 分钟

前言


Vue3.5 版本又将响应式给重构了,重构后的响应式系统主要有两部分组成: 双向链表和版本计数。我们在前两篇文章中我们已经讲过了,这篇文章我们来讲讲为什么这次重构能够让内存占用减少 56%。


为什么说“又”将响应式重构了


因为在之前的 Vue3.4 版本中刚刚将响应式给重构了,这次响应式重构是 vscode 插件 Vue-Official(原名 Volar)的作者 Johnson Chu 搞的。


3.4 版本的重构优化了很多东西,最直观的就是:computed 计算属性的值没有变化,另外一个 watch 又监听了这个 computed 的值。在 3.4 以前还是会触发 watch 的回调,经过 3.4 的优化后就不会触发了。


在 3.5 版本以前,Vue 的响应式系统中有两个角色:Sub 订阅者和 Dep 依赖。


Sub订阅者:主要有 watchEffect、watch、render 函数、computed 等。


Dep依赖:主要有 ref、reactive、computed 等响应式变量。


他们两之间是相互依赖的关系,如下图:



Dep依赖(比如 ref 响应式变量)可以通过dep属性访问到Sub订阅者(比如 computed 计算属性),就知道了到底有哪些订阅者依赖自己,当自己的值改变后就能去通知订阅者。


同样Sub订阅者(比如 computed 计算属性)可以通过deps属性访问到Dep依赖(比如 ref 响应式变量),当Sub订阅者不再依赖某个变量时就可以通过这个关系去访问到这个Dep依赖。然后把自己从不再依赖的变量的Sub订阅者集合中去掉,这样当这个响应式变量改变后就不会通知到不再订阅到他的Sub订阅者了。


我们来看个例子,代码如下:


<template>  <p>{{ doubleCount }}</p>  <button @click="flag = !flag">切换flag</button></template>
<script setup>import { computed, ref } from "vue";const count1 = ref(1);const count2 = ref(10);const flag = ref(true);
const doubleCount = computed(() => { console.log("computed"); if (flag.value) { return count1.value * 2; } else { return count2.value * 2; }});</script>
复制代码


flag的值为 true 时计算属性doubleCount其实只依赖响应式变量flagcount1,当flag的值切换为 false 时,计算属性应该变成依赖变量flagcount2


就上面这个更新Sub订阅者依赖的逻辑,Vue 其实重构了很多次。在早期的 Vue3 版本中是直接清空Sub订阅者所依赖的响应式变量,然后再重新执行计算属性doubleCount时再去将新的响应式变量进行收集。很明显这个版本内存的使用就非常浪费了。


在最新的 Vue3.4 版本重构后的响应式系统中会在执行计算属性之前利用_trackId_depsLength字段进行标记,在重新执行计算属性时进行依赖收集就可以利用_trackId_depsLength字段判断出 Dep 依赖是否能够复用,并且执行完计算属性的回调函数后同样利用_trackId_depsLength字段就可以将不再依赖的 Dep 依赖给移除掉。


上面这个方案看着很完美,但是他的核心是依赖计算属性中所依赖的变量顺序不变,如果顺序变了,那么依然还是不能够复用的,同样会对浪费内存。(PS:这一段 3.4 版本响应式看不懂没关系,因为他已经是过去式了)


内存优化主要原因:复用 Link 节点


在 Vue3.5 版本中那个最了解 Vue 的男人出手了,使用双向链表版本计数将响应式系统再次给重构了。说实话这次重构后让读响应式源码的门槛变得更高了,但是收益特别明显,最主要是通过复用 Link 节点去实现减少内存的使用。


还是上面的那个例子,对应新的响应式模型如下图:



在新的响应式模型中Sub订阅者Dep依赖之间不再有直接的关联关系了,而是通过中间的Link节点作为桥梁去关联。


在前一节中我们讲过了,3.5 以前Sub订阅者中有属性会去存依赖的Dep依赖Dep依赖中有属性去存依赖他的Sub订阅者,所以导致当Sub订阅者依赖的变量需要更新时就无法做到完全的复用,内存就会浪费。


在 3.5 新的响应式模型中,X 轴是Dep依赖,Y 轴是Sub订阅者Link节点是作为坐标轴上面的点。每一组Dep依赖Sub订阅者都会对应一个 Link 节点,并且可以通过这个Link节点直接访问到Dep依赖Sub订阅者


在 Y 轴上面找一个点(比如 Sub1 也就是计算属性doubleCount),横向出发就可以找到Sub1订阅者所依赖的所有响应式变量。因为横向的这些 Link 节点是一个双向链表,并且可以通过某一个 Link 节点直接访问到他的 Dep 依赖。


flag的值切换为 false 后,订阅者Sub1所依赖的响应式变量就从flag+count1变成flag+count2。这时我们需要做的事情就很简单了,新建一个Link3节点,可以直接访问到Sub1Dep3。然后将Link1中原本指向Link2的指针改为指向Link3,同时让Link3的指针也指向Link1。并且将Link2指向Link1的指针改为指向,由于Dep2现在不被任何订阅者所依赖了,所以将Link2原本指向Dep2的指针也改为指向空,同样将Dep2指向Link2的指针也指向空。


上面的一顿操作,除了必要的初始化一个Link3之外我们一直都是在进行指针的操作,并不像以前的响应式一样去增加 Sub 订阅者依赖或者减少依赖,这是非常高效的方式。


flag的值切换为 false 后,新的响应式模型图如下:



从上图中可以看到Link2已经彻底从双向链表中移除了,并且整个过程中我们都是在操作指针的指向,所以Link1也一直都是复用的。


V8 在进行垃圾回收的时候发现Link2不再被任何变量所使用,就可以认为Link2是一个可以被回收的变量,就会将其直接回收释放内存。


Link 节点复用以及让不再使用的 Link 节点尽快的被回收进而释放内存,就是这次响应式重构减少 56%内存占用的主要原因。


其他优化


有了双向链表后依赖触发也变得更加清晰了,当某个响应式变量改变后,只需要遍历 Dep 依赖(纵向)的 Link 节点组成的双向链表,然后通过这些 Link 节点直接访问到对应的 Sub 订阅者,触发其依赖。


基于此 Sub 订阅者的触发就是一个线性的过程,所以就可以实现将需要触发的 Sub 订阅者串起来组成了一个 Sub 订阅者组成的队列。等需要触发的订阅者收集完了后,再去进行触发 Sub 订阅者,避免同一个订阅者被触发多次。


依赖触发相比之前也变得更加简单了,性能以及内存也有所提升。


最后就是因为有了双向链表版本计数的加持后,computed 计算属性变得更加聪明,现在是惰性计算了。computed 计算属性只有等有人使用他(比如在 template 中使用计算属性 doubleCount)后才会去执行计算属性中的回调函数,以及 3.4 版本中就已经实现的如果计算属性值没有变化,另外一个 watch 又监听了这个 computed 的值,此时这个 watch 不会被触发。


总结


Vue3.5 响应式重构主要是通过双向链表版本计数实现的,优化后内存占用减少了 56%。主要原因是:在新的响应式系统中多了一个Link节点用于链接Sub订阅者Dep依赖,更新 Sub 订阅者依赖只是进行指针的变换,并且还能够复用Link节点以及将不再使用的Link节点给孤立出来便于 V8 更快的将这个 Link 节点给回收。此外还有 Sub 订阅者的触发也变得更加简单,以及现在是 computed 计算属性是惰性计算了,这些优化同样也优化了内存的使用。


文章转载自:前端欧阳

原文链接:https://www.cnblogs.com/heavenYJJ/p/18542806

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

EquatorCoco

关注

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
揭秘!Vue3.5响应式重构如何让内存占用减少56%_Vue_EquatorCoco_InfoQ写作社区