- 作者:老汪软件技巧
- 发表时间: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