手写 Vue 源码 === 依赖清理机制详解

22 阅读6分钟

手写 Vue 源码 === 依赖清理机制详解

目录

[TOC]

引言

Vue的响应式系统是其核心特性之一,而依赖清理机制则是确保响应式系统高效运行的关键部分。本文将深入探讨 Vue 响应式系统中的依赖清理机制,特别是 preCleanEffect、 postCleanEffect 以及 trackEffects 中的 diff 算法,并通过实际案例分析其工作原理。

响应式系统基础回顾

在深入依赖清理之前,让我们先简单回顾一下Vue 响应式系统的基本工作原理:

  1. 当我们使用  effect  函数包裹一个回调函数时,这个回调函数会立即执行
  2. 执行过程中,会访问响应式对象的属性,触发 getter
  3. 在 getter 中,会通过 track 函数收集当前正在执行的 effect 作为依赖
  4. 当响应式对象的属性发生变化时,会通过 trigger 函数触发收集的依赖,重新执行 effect
export function effect(fn, options: any = {}) {
  // 创建一个 effect 只要依赖的属性变化,就会重新执行
  const _effect = new ReactiveEffect(fn, () => {
    _effect.run();
  });
  // 执行
  _effect.run();
}

依赖清理的必要性

为什么需要依赖清理?考虑以下场景:

effect(() => {
  if (condition) {
    console.log(obj.a)
  } else {
    console.log(obj.b)
  }
})

conditiontrue 时,effect 依赖 obj.a ;当 conditionfalse 时,effect 依赖 obj.b

如果没有依赖清理机制,当 conditiontrue 变为 false 后,effect 仍然会保持对 obj.a 的依赖。这意味着当 obj.a 变化时,effect 会重新执行,但实际上 obj.a 已经不再被使用了。

ReactiveEffect 类的设计

首先,让我们看一下 ReactiveEffect 类的设计,它是依赖清理机制的核心:

class ReactiveEffect {
  _trackId = 0; // 当前的 effect 执行了几次
  deps = []; // 当前的 effect 依赖了哪些属性
  _depsLength = 0; // 当前的 effect 依赖的属性有多少个
 
  public active = true; //默认是响应式的
  constructor(public fn, public scheduler) {}
  run() {
    // 如果当前状态是停止的,执行后,啥都不做
    if (!this.active) {
      return this.fn();
    }
 
    let lastEffect = activeEffect;
    try {
      activeEffect = this; // 当前的 effect 「依赖收集」
 
      // 每次执行前需要将上一次的依赖清空 effect.deps
      preCleanEffect(this);
 
      return this.fn(); //依赖收集 「state.name ,state.age」
    } finally {
      postCleanEffect(this);
      activeEffect = lastEffect; // 执行完毕后 恢复上一次的 activeEffect
    }
  }
}

ReactiveEffect 类有几个关键属性:

  • _trackId:记录当前 effect 执行的次数,每次执行都会递增
  • deps:存储当前 effect 依赖的所有属性
  • _depsLength:记录当前 effect 依赖的属性数量

依赖清理的三个关键函数

1. preCleanEffect:执行前的准备
function preCleanEffect(effect) {
  effect._depsLength = 0; //清空当前effect中所有关联的属性
  effect._trackId++; //每次执行id都会+1,如果当前同一个effect执行,id都是相同的
}

preCleanEffect 函数在 effect 执行前被调用,主要做两件事:

  • 将 _depsLength 重置为 0,这个值用于记录当前 effect 依赖的属性数量
  • 递增 _trackId,这个值用于标识当前 effect 的执行轮次
  • _trackId 的递增非常关键,它确保了每次 effect 执行时都有一个唯一的标识,这样在依赖收集时可以判断当前属性是否已经在本次执行中被收集过。
2. trackEffects:依赖收集与 diff 算法
export function trackEffects(effect, dep) {
  // 需要重新去依赖收集,将不要的移除掉
  // 如果当前的 effect 没有存储过,那么就存储
  if (dep.get(effect) !== effect._trackId) {
    dep.set(effect, effect._trackId);
 
    // 我们需要一个算法,来比对不同分支切换的时候,的差异
    // {flag,name}
    // {flag,age}
    let oldDep = effect.deps[effect._depsLength];
 
    if (oldDep !== dep) {
      // 如果没有存过
      effect.deps[effect._depsLength++] = dep; //永远按照本次最新的存放
      // 如果老的依赖存在,那么就移除老的依赖
      if (oldDep) {
        cleanDepEffect(oldDep, effect);
      }
    } else {
      effect._depsLength++;
    }
  }
}

trackEffects 函数是依赖收集的核心,它实现了一个精巧的 diff 算法:

  • 首先检查当前 effect 是否已经在本次执行中被收集过(通过比较 dep.get(effect) 和 effect._trackId)
  • 如果没有被收集过,则将当前 effect 和 _trackId 存入 dep 中
  • 获取 effect 在当前位置的旧依赖 oldDep
  • 如果旧依赖和当前依赖不同,则用当前依赖替换旧依赖,并清理旧依赖
  • 如果旧依赖和当前依赖相同,则只增加 _depsLength
3. postCleanEffect:执行后的清理
function postCleanEffect(effect) {
  // {flag:{effect},name:{effect},age:{effect}}
  // {flag:{effect},name:{effect}} _depsLength=2
  // 如果当前的effect中依赖的属性,比之前收集的属性多,那么就删除多余的属性
  if (effect.deps.length > effect._depsLength) {
    for (let i = effect._depsLength; i < effect.deps.length; i++) {
      // 删除多余的属性
      cleanDepEffect(effect.deps[i], effect);
    }
    // 更新当前依赖列表中的长度
    effect.deps.length = effect._depsLength;
  }
}

postCleanEffect 函数在 effect 执行后被调用,主要处理依赖减少的情况:

  • 如果 effect.deps.length 大于 effect._depsLength,说明有依赖被移除了
  • 遍历这些被移除的依赖,调用 cleanDepEffect 函数清理它们
  • 最后将 effect.deps.length 更新为 effect._depsLength
4. cleanDepEffect:清理依赖
function cleanDepEffect(dep, effect) {
  dep.delete(effect);
  if (dep.size == 0) {
    dep.cleanUp(); //如果map为空,则删除这个属性
  }
}

cleanDepEffect 函数用于从依赖集合中移除指定的 effect:

  1. 从 dep 中删除指定的 effect
  2. 如果 dep 为空,则调用 cleanUp 方法删除这个属性

实际案例分析

effect(() => {
    console.log('effect执行了');
    document.body.innerHTML = `<h1>${state.flag ? state.name : state.age}</h1>`
})
 
setTimeout(() => {
    state.flag = false
    setTimeout(() => {
        console.log('修改属性后不应该触发更新');
        state.name = "李四"
    }, 1000)
}, 1000)

这个案例展示了条件渲染中的依赖清理。让我们逐步分析:

初始执行

  • effect 首次执行, preCleanEffect 将 _depsLength 设为 0, _trackId 增加为 1
  • 执行回调函数,访问 state.flag(为 true)和 state.name
  • 通过 trackEffects 收集 state.flag 和 state.name 作为依赖
  • postCleanEffect 检查并清理多余的依赖(此时没有)

此时,effect 的依赖关系是:

effect.deps = [dep(state.flag), dep(state.name)]
effect._depsLength = 2

条件变化后的执行

  • 1秒后,state.flag 变为 false,触发 effect 重新执行
  • preCleanEffect 将 _depsLength 设为 0, _trackId 增加为 2
  • 执行回调函数,访问 state.flag(为 false)和 state.age
  • 通过 trackEffects 收集 state.flag 和 state.age 作为依赖
  • 对于 state.flag,由于它在上次执行中也是依赖,所以会保留
  • 对于 state.age,它是新的依赖,会被添加到 effect.deps 中
  • 对于 state.name,它不再是依赖,会在 postCleanEffect 中被清理
  • postCleanEffect 检查并清理多余的依赖,此时 state.name 被清理

此时,effect 的依赖关系变为:

effect.deps = [dep(state.flag), dep(state.age)]
effect._depsLength = 2

验证依赖清理的效果

  1. 再过1秒后,修改 state.name 为 "李四"
  2. 由于 state.name 已经不再是 effect 的依赖,所以这次修改不会触发 effect 重新执行
  3. 控制台输出 "修改属性后不应该触发更新",但不会再输出 "effect执行了"

这个案例完美地展示了依赖清理的效果:当条件从 true 变为 false 后, state.name 不再是依赖,所以修改 state.name 不会触发 effect 重新执行。

依赖清理算法的优化

Vue 的依赖清理算法非常精巧,它通过比较新旧依赖列表,实现了高效的依赖更新。这个算法的核心思想是:

  1. 使用  _trackId 标识当前 effect 的执行轮次
  2. 使用  _depsLength 记录当前 effect 依赖的属性数量
  3. 在依赖收集时,比较新旧依赖,只更新发生变化的部分
  4. 在 effect 执行后,清理多余的依赖

这种算法的优势在于:

  1. 避免了不必要的依赖重新收集
  2. 精确地识别出哪些依赖是新增的,哪些是被移除的
  3. 减少了内存占用和计算开销

总结

Vue 响应式系统中的依赖清理机制是一个精巧的设计,它通过

preCleanEffecttrackEffectspostCleanEffect

三个关键函数,实现了精确的依赖追踪和清理。这种机制确保了:

  1. 只有真正被使用的属性才会触发 effect 的重新执行
  2. 当依赖关系发生变化时,能够精确地更新依赖列表
  3. 避免了不必要的计算和内存泄漏

通过实际案例的分析,我们可以看到依赖清理机制在条件渲染等场景中的重要作用。理解这一机制,对于深入理解 Vue 的响应式系统以及编写高效的 Vue 应用都有很大帮助。

在实际开发中,我们可能不需要直接操作这些底层 API,但了解它们的工作原理,可以帮助我们更好地理解 Vue 的响应式系统,以及在遇到性能问题时进行优化。