Vue3.4黑魔法:双链表重构后的新响应式系统

写在前面

大家好,我是时橙~

本文为新式响应式系统的解析,该pr已被正式合并入minor分支,如果解析有不到位的地方,望各位提表建议。

本次重构来源于尤雨溪的本次pr ⬇️

github.com/vuejs/core/...

新·响应式系统带来的收益

  • 解决了几个bugs #10290 #10069
  • 更高效的内存使用,基准测试提升56%
  • 更合理的computed行为
  • ....

正文

现在的订阅者(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带来了更多的特性,但本人精力有限,剩下的就留给各位探索啦~

我是时橙,下次再见。

相关推荐
yaoganjili7 分钟前
用 Tinymce 打造智能写作
前端
angelQ15 分钟前
Vue 3 中 ref 获取 scrollHeight 属性为 undefined 问题定位
前端·javascript
Dontla22 分钟前
(临时解决)Chrome调试避免跳入第三方源码(设置Blackbox Scripts、将目录添加到忽略列表、向忽略列表添加脚本)
前端·chrome
我的div丢了肿么办29 分钟前
js函数声明和函数表达式的理解
前端·javascript·vue.js
云中雾丽32 分钟前
React.forwardRef 实战代码示例
前端
朝歌青年说32 分钟前
一个在多年的技术债项目中写出来的miniHMR热更新工具
前端
武天33 分钟前
一个项目有多个后端地址,每个后端地址的请求拦截器和响应拦截器都不一样,该怎么封装
vue.js
Moonbit35 分钟前
倒计时 2 天|Meetup 议题已公开,Copilot 月卡等你来拿!
前端·后端
Glink35 分钟前
现在开始将Github作为数据库
前端·算法·github
小仙女喂得猪36 分钟前
2025 跨平台方案KMP,Flutter,RN之间的一些对比
android·前端·kotlin