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

Vue 3.5 双向链表如何实现依赖收集

Vue 近期发布了 3.5,响应式系统重构了,所以打算以此文档记录一下 3.5 的双向链表是怎么实现依赖收集的。响应式系统里面有两个核心反应式功能,一个是 effect,一个是 computed,本文不涉及 computed,主要针对 effect 功能分析。

参考链接

调试环境介绍

安装响应式系统包 "@vue/reactivity": "^3.5.8” , 核心代码如下所示,通过 debugger 直接进入reactivity 包调试

import { reactive, effect, computed } from '@vue/reactivity'
const obj = reactive({
  a: 1,
  b: 2,
  c: 3,
  d: 4
})
effect(() => {
  console.log('effect run')
  obj.a
a
})
debugger
obj.a++

双向链表结构

pr 里面有一张图,大概表示了双向链表的结构,我觉得更像是**双向十字链表,**除了前后这个维度外,还多了上下这个维度,看着比 React Fiber 节点的链表复杂多了

减少 dep 和 sub,我画了一个更详细的关系图

对应代码


import { reactive, effect, computed } from '@vue/reactivity'
const obj = reactive({
  a: 1,
  b: 2,
  c: 3,
  d: 4
})
effect(() => {
  console.log("effect 1")
  obj.a
  obj.b
})
effect(() => {
  console.log("effect 2")
  obj.a
  obj.b
})

前后维度分析

下面代码是一个最简单的场景,effect 执行后,仅仅形成前后维度的链表,注意这里形成的链表不会有上下维度,因为只有一个 effect

import { reactive, effect, computed } from '@vue/reactivity'
const obj = reactive({
  a: 1,
  b: 2,
  c: 3,
  d: 4
})
effect(() => {
  console.log('effect run')
  obj.a
  obj.b
})
debugger
obj.a++

节选 track 里面部分代码 ,track 函数会在访问 obj.a 触发,经典的收集函数

    if (link === void 0 || link.sub !== activeSub) {
      link = this.activeLink = new Link(activeSub, this);
      if (!activeSub.deps) {
        activeSub.deps = activeSub.depsTail = link;
      } else {
        link.prevDep = activeSub.depsTail;
        activeSub.depsTail.nextDep = link;
        activeSub.depsTail = link;
      }
      if (activeSub.flags & 4) {
        addSub(link);
      }
    }

首先会创建一个名为 link 的节点,link 会关联上 dep 和 sub , dep 就是 obj.a ,sub就是订阅者,上面代码 effect 里面的回调函数 link = this.activeLink = new Link(activeSub, this); 执行后

graph LR
Node1[link] -->|dep| Node2[dep obj.a]
Node1[link] -->|sub| Node3[sub effect回调函数]

activeSub.deps = activeSub.depsTail = link; 执行后

graph LR
Node1[link] -->|dep| Node2[dep obj.a]
Node1[link] -->|sub| Node3[sub effect回调函数]
Node3[sub effect回调函数] -->|deps| Node1[link]

addSub(link); 执行后

graph LR
Node1[link] -->|dep| Node2[dep obj.a]
Node1[link] -->|sub| Node3[sub effect回调函数]
Node3[sub effect回调函数] -->|deps| Node1[link]
Node2[dep obj.a] -->|subs| Node1[link]

经典的 dep 和 sub 相互收集过程没有了,但是 dep 和 sub 通过中间的 link,都可以找到对方,比如 trigger 的时候,dep 通过 link 能到找到 effect 函数,effect 函数执行后,通过 link 可以找到 dep 清理依赖

目前是执行完 obj.a 的状态,继续执行 obj.b ,同样会创建一个新的 link ,状态和上面的一样,但是目前会有两个 link,我们看下是怎么关联的

if (!activeSub.deps) {
  activeSub.deps = activeSub.depsTail = link;
} else {
  link.prevDep = activeSub.depsTail;
  activeSub.depsTail.nextDep = link;
  activeSub.depsTail = link;
}

activeSub 是当前的 effect,这次会进入 else 逻辑,因为从上图可知,deps 指向了 obj.a 对应的 link,else 里面就是两个 link 相互关联。并且 effect 上面的 depsTail,一直指向最新的 link,这里即 obj.b的 link,deps一直指向 head,这里即 obj.a 的 link

graph LR
Node1[link obj.a] -->|nextDep| Node2[link obj.b]
Node2[link obj.b] -->|prevDep| Node1[link obj.a]

现在两个 link 就串联起来,如果一个 effect 里面还需要追踪其他依赖,也是这样串联起link,值得注意的是,前后串联的 link 是在一个 effect 作用域内的,多个 effect 则会有多条这种链表

最开始看到 pr页面那张链表图的时候,以为obj.b 触发更新的时候,会沿着 link 找到对应的 effect,但其实没必要,因为 obj.b link 本身就指向了 effect,否则时间复杂复是 O(n) 而不是 O(1)

双向链表基本操作的实现__双向链表lru

obj.a++
obj.b++

这个链表的作用可能是:

本文不涉及到 computed,所以分析一下清理依赖是怎么利用链表的,以下面的代码为例

import { reactive, effect, computed } from '@vue/reactivity'
const obj = reactive({
  a: 1,
  b: 2,
  c: 3,
  d: 4
})
effect(() => {
  if (obj.a === 1) {
    obj.b
    obj.d
  } else {
    obj.c
    obj.d
  }
})
debugger
obj.a++

effect 第一次执行完成

graph LR
Node1[link obj.a] -->|nextDep| Node2[link obj.b]
Node2[link obj.b] -->|prevDep| Node1[link obj.a]
Node2[link obj.b] -->|nextDep| Node3[link obj.d]
Node3[link obj.d] -->|prevDep| Node2[link obj.b]

effect 第二次执行到 obj.a , obj.a 对应 link 节点在最后面因为 effect 重新调用,首先访问 obj.a,断开了原来 link a 的链接,重新加到了最后

graph LR
Node1[link obj.b] -->|nextDep| Node2[link obj.d]
Node2[link obj.d] -->|prevDep| Node1[link obj.b]
Node2[link obj.d] -->|nextDep| Node3[link obj.a]
Node3[link obj.a] -->|prevDep| Node2[link obj.d]

effect 继续执行到 obj.c , 新增了 link c

graph LR
Node1[link obj.b] -->|nextDep| Node2[link obj.d]
Node2[link obj.d] -->|prevDep| Node1[link obj.b]
Node2[link obj.d] -->|nextDep| Node3[link obj.a]
Node3[link obj.a] -->|prevDep| Node2[link obj.d]
Node4[link obj.c] -->|prevDep| Node3[link obj.a]
Node3[link obj.a] -->|nextDep| Node4[link obj.c]

effect 继续执行到 obj.d, 断开了原来 link d 的链接,重新加到了最后

graph LR
Node1[link obj.b] -->|nextDep| Node2[link obj.a]
Node2[link obj.a] -->|prevDep| Node1[link obj.b]
Node3[link obj.c] -->|prevDep| Node2[link obj.a]
Node2[link obj.a] -->|nextDep| Node3[link obj.c]
Node4[link obj.d] -->|prevDep| Node3[link obj.c]
Node3[link obj.c] -->|nextDep| Node4[link obj.d]

从上面的流程变化可以看出,effect 重新执行的时候,访问到哪个属性,就会断开这个属性对应 link 的链接,重新加入到最后,形成一个新的顺序,而且这些 link 都是一直复用,并没有重新创建

当 effect 执行完后,就会执行 cleanupDeps ,这里会遍历链接,如果那个依赖不需要要了,直接就断开那个节点,并把这个这个节点的前后节点相连,遍历是 O(n),双向链表删除节点并重连是 O(1)

function cleanupDeps(sub, fromComputed = false) {
  let head;
  let tail = sub.depsTail;
  let link = tail;
  while (link) {
    const prev = link.prevDep;
    if (link.version === -1) {
      if (link === tail) tail = prev;
      removeSub(link, fromComputed);
      removeDep(link);
    } else {
      head = link;
    }
    link.dep.activeLink = link.prevActiveLink;
    link.prevActiveLink = void 0;
    link = prev;
  }
  sub.deps = head;
  sub.depsTail = tail;
}

形成新链,b 没有被收集到所以删除,等待垃圾回收


graph LR
Node1[link obj.a] -->|nextDep| Node2[link obj.c]
Node2[link obj.c] -->|prevDep| Node1[link obj.a]
Node3[link obj.d] -->|prevDep| Node2[link obj.c]
Node2[link obj.c] -->|nextDep| Node3[link obj.d]

删除 link b 是因为 link.version === -1这个判断,effect 回调函数执行前,会调用 prepareDeps,里面把 link.version 充值为-1 ,在 track 的时候,link.version 会改变,如果到最后还是-1,说明没有被 track


function prepareDeps(sub) {
  for (let link = sub.deps; link; link = link.nextDep) {
    link.version = -1;
    link.prevActiveLink = link.dep.activeLink;
    link.dep.activeLink = link;
  }
}

响应式系统避免不了依赖清理的环节,即使依赖关系变更是小部分场景的需求,从 vue2 的普通对象到 vue3的 set,再到 3.2 的依赖标记,依赖清理环节都会大量且频繁对集合产生添加和删除操作,双向链表有效优化这个环节,仅仅是删除链表上的某个节点并复用其他节点。

上下维度分析

一个 deb 对应 多个 sub,就会产生上下维度的 link

import { reactive, effect, computed } from '@vue/reactivity'
const obj = reactive({
  a: 1,
  b: 2,
  c: 3,
  d: 4
})
effect(() => {
// effect1
 obj.a
})
effect(() => {
// effect2
  obj.a
 })
debugger
obj.a++

和前后维度结构一样,只是变量名不同,因为是不同 effect ,obj.a 对应的 link 节点是有两个的 (图是前后节点,可以脑补成上下节点)

graph LR
Node1[link obj.a] -->|nextSub| Node2[link obj.a]
Node2[link obj.a] -->|prevSub| Node1[link obj.a]

回看这张图,需要注意的是,当一个 dep 对应多个 sub的时候,这个 dep也会产生多个 link,一个 sub里面一个 link, dep.subs 指向最后一个 link,而 dep.subsHead指向第一个 link

graph LR
Node1[link] -->|dep| Node2[dep obj.a]
Node1[link] -->|sub| Node3[sub effect回调函数]
Node3[sub effect回调函数] -->|deps| Node1[link]
Node2[dep obj.a] -->|subs| Node1[link]

更新的时候,沿着prevSub向上遍历就可以找到所有 effect,先 effect2 ,再 effect1,不是向下遍历是因为endBatch里面批量更新的逻辑,endBatch后更新顺序还是从前往后的,即先 effect1 再 effect2

    for (let link = this.subs; link; link = link.prevSub) {
        if (link.sub.notify()) {
          ;
          link.sub.dep.notify();
        }
      }

除了顺序触发 effect 作用之外,应该和 computed 也有关系,因为 computed 是 dep又是 effect,情况特殊,本文暂不涉及 computed

综合起来,多个 dep 和 多个 sub,就如图所示