写在前面
大家好,我是时橙~
本文为新式响应式系统的解析,该pr已被正式合并入minor分支,如果解析有不到位的地方,望各位提表建议。
本次重构来源于尤雨溪的本次pr ⬇️
新·响应式系统带来的收益
正文
现在的订阅者(Subscriber)和依赖(dep)的抽象关系长什么样?
现在看不懂这个图谱没有关系,后续我们再解释这个表式什么意思
Link是什么东西?
3.4版本内部的Link接口定义如下:
ts
interface Link {
dep: Dep
sub: Subscriber
/**
* - Before each effect run, all previous dep links' version are reset to -1
* - During the run, a link's version is synced with the source dep on access
* - After the run, links with version -1 (that were never used) are cleaned
* up
*/
version: number //依赖每次被触发时+1
/**
* Pointers for doubly-linked lists
*/
nextDep?: Link
prevDep?: Link
nextSub?: Link
prevSub?: Link
prevActiveLink?: Link
}
归纳一下:
- 承载依赖(Dep)和订阅者(Sub)
- 版本(version),版本意味着dep被访问的次数
- 四方链表怪兽😂,就正如上图中的这个节点,它可以指向四个方向
Link在依赖的追踪(dep.track)和依赖的触发(dep.trigger)中起着至关重要的作用,不过让我们重温依赖追踪/触发之前,我们再看看重看链表图,理解一下到底怎么看这个链表图
再看链表图
我们链表图看成一个坐标图,它有Dep轴和Sub轴,每个Link代表一个坐标节点,我在下面这张图标记了坐标轴和Link节点序号
举例:在整个图谱绘制完之后,
- Link 1 上的订阅者(sub)和依赖(dep)分别是Dep1和Sub1.
- Link 5 上的订阅者(sub)和依赖(dep)分别是Dep2和Sub2.
- Link 6 上的订阅者(sub)和依赖(dep)分别是Dep3和Sub2.
PS: 每个dep还会指向队尾Link,而每个sub会有两个箭头,一个指向队头,一个指向队尾。
那么,现在有两个问题,
- 这个图谱是怎么绘制出来的?
- 我们如何根据这个图谱,在依赖(dep)更新时,让所有订阅者(sub)进行更新?
图谱的绘制
9x9的宫格实在是太大了😂 我们换一个4x4的图谱来看看它怎么绘制的 图谱版本: 代码版本:
typescript
let dummy1, dummy2
//dep1
const counter1 = reactive({ num: 1 })
//dep2
const counter2 = reactive({ num: 2 })
//sub1
effect(() => {
dummy1 = counter1.num + counter2.num
})
//sub2
effect(() => {
dummy2 = counter1.num + counter2.num + 1
})
expect(dummy1).toBe(3)
expect(dummy2).toBe(4)
counter1.num++
counter2.num++
expect(dummy1).toBe(5)
expect(dummy2).toBe(6)
让我们一起回顾一下如何绘制出这个图谱:
左右建联
第一个sub1在第7行创建
ts
effect(() => {
dummy1 = counter1.num + counter2.num
})
然后sub1会在effect执行的时候创建此时执行到第七行我们的图表是这样的: sub1会被赋予在全局activeSub上,然后effect运行回调
typescript
dummy1 = counter1.num + counter2.num
此时counter1会创建只属于它的Dep1
然后便会进行依赖追踪dep1.track来追踪全局上的ActiveSub(sub1)。
然鹅,dep1和sub1要如何联系起来?我们偷窥一眼源码(只看红框即可):
link就这么顺理成章的把dep1和sub1都联系起来了! 现在我们的图谱长这样:
不过这只是以link视角的 完整一次track建立完之后的图谱应该是,:
大家在这张图中只需要知道订阅者Sub1会追踪它关联依赖的Link的队头和队尾(这里暂时是Link1)
依赖Dep1会追踪它关联订阅者的Link(这里是Sub1)
我们可以得到一个启示:
Link在依赖Dep视角是订阅节点,而在订阅者Sub视角是依赖节点。
Link同时关联依赖和订阅者,我称之为依赖/订阅的二象性(笑)
后续我们将不再展示完整图谱,因为这样图谱会太复杂了,大家只需要知道Sub能知晓依赖链的头尾,Dep能知道Sub链的尾就行。
接下来,回到回调函数会执行counter2.num 依然如dep1创建时一样,不过link1和link2是如何联系起来的呢?
我们依然继续偷窥源码(只看画红框的部分就好了):
看else里的逻辑,我们只需要取订阅者队尾然后链接上即可!
上下建联
不过link的上下建联的逻辑是怎么做的?
还是在依赖追踪的逻辑中,在左右建联完之后就会进行上下建联:addSub(link)。
上下建联的原理类似于左右建联,不过这次是dep为主导,依然是链表操作:⬇️
typescript
function addSub(link: Link) {
const currentTail = link.dep.subs
if (currentTail !== link) {
link.prevSub = currentTail
if (currentTail) currentTail.nextSub = link
}
link.dep.subs = link
}
至此,我们的蓝图已经完成了!
有了这样的一张图表,我们能做什么?
优化内存性能1 - 在依赖追踪和触发时:复用Link节点
假如现在有这么一串代码:
typescript
const dep1 = reactive({value:true});
const dep2 = reactive({value:1});
const dep3 = reactive({value:1});
const dep4 = reactive({value:1});
effect(() => {
if (dep1.value) {
dep2.value;
dep3.value;
} else {
dep3.value;
dep4.value;
}
});
第一次运行依赖时他的图谱如下⬇️
如果此时设置dep1.value=false
那么新收集的依赖便是 在这个过程中,
- 在触发结束阶段,清理无效依赖时,我们通过Dep上的属性version判断Dep是否被访问,然后将Link3放逐掉,悬空的Link对V8的垃圾回收非常友好,提升内存回收性能。
- 在追踪时:Link1,Link2仍然被继续复用,只创建了Link4,减小了内存创建开销。
优化运行性能2 - 减少依赖调用堆栈
在这个蓝图下如果任意一个link的dep被触发了,我们直接沿着链表从上到下依次触发sub1,sub2,sub3即可。 如果是曾经的vue3,可能会存在某些递归调用,创建递归堆栈是一种非常消耗内存的行为。
魔法Computed-缓存比较
如果有一个Computed更新前后的值无所改变,那么它会直接中断订阅Computed的订阅者的更新。 假设有这么一个图谱, 对应代码如下:
ts
const dep2=ref(0)
const v=computed(()=>{
return dep2.value % 2
)
effect(()=>{
v
))
effect(()=>{
v
dep2
))
此时如果更新:Dep2.value=2
那么由于缓存比较,
由于Sub1订阅了v,v没有改变,所以不会运行,
由于Sub2订阅了v合dep2,dep2改变了,effect会被再次运行。
当然如果是链式的computed,在缓存策略下能避免更新整个订阅链,订阅链会根据computed缓存在合适的情况下中途终止。
当然本次更新还给Coumpted带来了更多的特性,但本人精力有限,剩下的就留给各位探索啦~
我是时橙,下次再见。