• 作者:老汪软件技巧
  • 发表时间:2024-09-20 11:01
  • 浏览量:

写在前面

大家好,我是某时橙~如果喜欢我的文章,就点点关注吧

在六个月前,笔者曾经发布了一篇文章《连yyx都要借鉴的二维双链表到底怎样?!揭秘vue3.4神秘黑魔法》

但这篇文章预设读者已经对3.5版本以前的响应式系统有一定了解,导致阅读困难。上次的更新在最近已经正式并入3.5,借着这次更新的东风,笔者也正巧再来详细解读一下新型响应式系统~

依赖追踪与触发的基本流程什么是依赖Dep,什么又是订阅者Sub?

订阅者Sub,在Vue中,Subscriber是一类抽象接口,其对应的实现有我们常用的Effect和Computed

class ReactiveEffectany>
  implements Subscriber, ReactiveEffectOptions
class ComputedRefImplany> implements Subscriber

依赖Dep,在Vue中每个响应式变量对应一个Dep,通常来讲这样的结构在Vue中有Ref,reactvie,Computed

理解响应式流程极速版本:订阅者在执行时进行依赖收集,这里可以理解为执行effect中的回调时记录effect和ref的关系

而Dep在变动时进行依赖触发,这里可以理解为ref更新了,就同步变动effect

其结构简单理解:

依赖收集就是完成上图连线结构的过程,在执行Sub1和Sub2的时候,会触发a和b的getter代理器,a和b就会开始连接Sub1和Sub2。注意,你暂时只需要理解这个线是一种连接关系,不需要纠结其底层数据结构和连接方式!

依赖触发就是在依赖更新的时候

比如:

a.value=2

此时a的setter代理器会沿着结构图的红线去触发Sub1和Sub2

曾经响应式系统的依赖结构

刚刚经过极速版的理解,我们大致了解了响应式的基本流程,接下来让我们梳理一下响应式结构:

在vue3.4的版本(或者说绝大数其他响应式框架)中,大部分依赖的记录方式是通过Set or Map这类集合结构这种结构允许在

O(1) 时间内添加和删除项目,

以及在线性 O(n) 时间内迭代当前项目。重复项也会自动处理。

上面的红线就可以理解为:

Dep1有一个关于Sub的集合,收集了Sub1和Sub2

反之亦然,

Sub1也有一个关于Dep的集合,收集了Dep1和Dep2,

用代码表示,关系如下⬇️

dep1.subs=集合{sub1,sub2}
sub1.deps=集合{dep1,dep2}

bingo,这样的结构不是很完美吗?你可能会疑惑难道在Vue3.5还有更好的结构去优化响应式?

您好,是有的,复习环节结束,接下来让我们正式进入到vue3.5优化的讲解~

链表优化后的依赖结构

现在的响应式结构如下⬇️

如果你好奇这个图表是怎么建立出来的,可以看我的文章⬇️

《连yyx都要借鉴的二维双链表到底怎样?!揭秘vue3.4神秘黑魔法》

链表有的优势:

基于这些优势,本次Vue的优化也在于此,后文详析。

优化点1: 链表节点复用

通过复用,我们再也不用在依赖收集前删除全部依赖节点了。

const s1 = ref(true);
const s2 = ref(0);
const s3 = ref(0);
const s4 = ref(0);
effect(() => {
  if (s1.value) {
    s2.value;
    s3.value
  } else {
    s3.value;
    s4.value
  }
});

在过去,遇到effect这种依赖分支的情况,可能第一次运行

s1.value===true

effect的依赖列表为[s1,s2,s3]第二次运行:

s1.value===false

effect的依赖列表为[s1,s3,s4]

如果我们在第二次运行的时候在依赖列表中不清理s3这个依赖,依赖列表就会变成[s1,s2,s3,s4]这将会造成非常严重的后果,

比如s3更新时,effect也会执行一遍,即使effect中的分支根本没有走到s3!

vue原先对分支依赖处理

因为原先的底层数据结构是集合,因此通常在依赖收集前,把deps清空,在effect执行的时候重新收集依赖

这会造成一个问题,因为一般依赖分支的情况是边缘情况,你为了这种边缘情况,次次都清空依赖,又重新收集,这就会在整个系统造成相当一部分性能损耗,因为他的时间复杂度是O(1)的

链表化后对分支依赖的处理

在依赖分支变化后,我们不需要完完全全重新收集依赖,只需要将s2指向s4就好了!

s1->s2->s3
//丢弃s3,指向s4
s1->s2->s4

优化点2: computed计算属性懒更新computed既是Dep也是Sub!

Computed的懒加载有一定复杂度,所以我们先要理解computed其实既是Dep也是Sub

我们来仔细观察一个简单的Computed结构

const s1=ref(0)
const c=computed(()=>{
  return s1.value+1
})
effect(()=>{
  c.value
})

如果单看传入computed的callback,我们会发现它非常像effect,也订阅者Sub,

如果你看effect的调用,你会发现Computed导出的变量c非常像ref,即依赖Dep,

实际上,Computed在底层实现中,它既是Dep也是Sub。

但它的更新确有一些不同。

在被需要时更新

看如下代码

const s1=ref(0)
const c=computed(()=>{
  return s1.value+1
})
s1.value++; //1
s1.value++; //2
//3
effect(()=>{
  c.value
})

在上面我打注释做了标记,你觉得c在什么时候更新?实际上是3,

Computed只有在被需要的时候才更新,这被称为惰性计算

跳过计算复用缓存

更新上面的代码

const s1=ref(0)
const c=computed(()=>{
  return s1.value+1
})
s1.value++; //1
s1.value++; //2
//3
effect(()=>{
  c.value
  c.value
})

如果effect里面多次访问computed,那么vue会多次计算computed的值吗?答案当然是否定的:

这便引出了version&&globalVersion机制

每个dep都有自己的版本号version。每次它们注意到自己的值发生变化时,它们都自增全局版本号globalVersion。

运行Computed的计算函数时,它会将globalVersion记录下来到自己的version中,对全局版本号进行一个缓存。

你可能会问了,这样缓存全局版本号有什么用?我们一步步解释上面的代码你就明白了!

const s1=ref(0) 
//1
const c=computed(()=>{
  return s1.value+1
})
s1.value++; //2
s1.value++; //3
effect(()=>{
  c.value //4
  c.value //5
})

运行到1处

//1
const c=computed(()=>{
  return s1.value+1
})

刚开始创建computed时,computed记录globalVersion===0,到自己的version中,此时globalversion===0,version[computed]===0

运行到2,3处,

s1.value++; //2
s1.value++; //3

s1自增两次,此时globalVersion也会自增两次,此时globalVersion===2

运行到4,5处

effect(()=>{
  c.value //4
  c.value //5
})

运行到4,globalVersion!==version[computed],此时Computed更新,并在更新时同步记录当前version=globalVerison

运行到5,globalVersion===version,不需要更新,直接返回旧值

你看,这样是不是省计算了?

优化点3:依赖更新批处理

理解以下测试用例,我们会发现,counterSpy的调用次数只有1次,为什么呢?其实是因为vue3.5把两次counter.num++触发effect的操作归为一次,这样就进一步的提高了运行效率⬇️

 it('should be triggered once with batching', () => {
    const counter = reactive({ num: 0 })
    const counterSpy = vi.fn(() => counter.num)
    effect(counterSpy)
    counterSpy.mockClear()
    startBatch()
    counter.num++
    counter.num++
    endBatch()
    expect(counterSpy).toHaveBeenCalledTimes(1)
  })

怎么做到的?原因是startBatch这个API,他会记录一个全局batchDepth值,在startBatch调用时自增batchDepth

export function startBatch(): void {
  batchDepth++
}

而对于依赖更新来说,如果发现当前batchDepth不等于1,则会直接跳过依赖更新的阶段,转而仅仅记录,将要更新的节点在变量batchedSub中。

export function endBatch(): void {
  //如果不为0,则跳过依赖更新。
  if (--batchDepth > 0) {
    return
  }
  //更新所有batchedSub
}

你可以想象vue的组件场景,一个组件套用子组件将会有非常多的递归式更新,有了batch操作,就可以把重复的更新集中式处理了!这将极大的影响效率~

优化后的性能提升内存使用改进

给定一个有 1000 个refs + 2000 个computed(1000 个链接对)+ 1000 个effects最后一个计算的效果的测试用例,比较这些类实例使用的总内存:

性能比较

各方面都有合理的提升,最显著的是单个ref调用多个effects(+118%~185%)和读取多个无效computed(+176%~244%)。

写在最后

从来没在社区介绍过自己,这次简单介绍一下

我是某时橙,曾经就职于一家互联网大厂,同时也是开源爱好者,参与过Vue社区的相关共建工作,做过比较有意思的开源玩具,前段时间因为个人原因离职,现在正在深圳找工作,也是做一下毛遂自荐:

我的github