likes
comments
collection
share

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

作者站长头像
站长
· 阅读数 37

前言

本文属于笔者Vue3源码阅读系列第四篇文章,往期精彩:

  1. 生成vnode到渲染vnode的过程是怎样的
  2. 组件创建及其初始化过程
  3. 响应式实现——reactive篇

在第三篇文中主要看了reactive的相关源码,本文主要看看ref以及effect中的源码。effect 作为 reactive 的核心,主要负责收集依赖更新依赖,那依赖是什么?

ref

带着这个问题,我们先来看下 ref 的源码(packages/reactivity/src/ref.ts):

export function ref(value?: unknown) {
  // 调用createRef
  return createRef(value, false)
}

// ......

export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

function createRef(rawValue: unknown, shallow: boolean) {
  // 如果已经是 ref对象,则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  // 否则直接 new 一个 RefImpl
  return new RefImpl(rawValue, shallow)
}

以上代码非常易懂,就不做太多解读了,接着看RefImpl类。

RefImpl

RefImpl类的作用主要是为了创建Ref对象,每一个ref(),返回的都是包含value属性的Ref对象,看下RefImpl类的实现:

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

RefImpl采用了ES2015类的写法,如果打包后降级其实还是通过Object.definePropertyvalue的访问做了拦截。我们还能看到shallowRef 和 ref 都是通过调用 createRef 实现,只是传入的参数不同。当使用 shallowRef 时,不会调用 toReactive 去将对象转换为响应式,因此shallowRef返回的对象只支持对value值的响应式,ref返回的对象则支持对value深度响应式,比如:

const shallow = shallowRef({a: 'hello'})
// shallow.value 响应式
// shallow.value.a 非响应式

const notShallow = shallowRef({a: 'hello'})
// notShallow.value 响应式
// notShallow.value.a 响应式

咱们接下来看下 trackRefValuetriggerRefValue 的实现。

trackRefValue

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

为什么要判断 shouldTrack 和 activeEffect

  1. 定义了一个ref变量,但没有任何地方使用到,此时activeEffectundefined,就不需要收集依赖。
  2. 在上一篇文中,讲到了对数组的一些方法的重写,由于这些方法会改变数组的length,可能造成死循环,因此重写时设置了shouldTrackfalse,因此这种情况也不需要收集依赖。

triggerRefValue

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

trackRefValuetriggerRefValue中,dep是一个关键的参数,dep是一个存着依赖的Set对象——Set<ReactiveEffect>。看下createDep的源码(packages/reactivity/src/dep.ts

createDep

// createDep 的作用就是创建一个用来存依赖的 Set 对象
// 并初始化 w、n,这两个属性是用来区别依赖是否收集过
// 用于后续的副作用函数执行再次收集依赖时确保不重复收集、多余收集。
export const createDep = (effects?: ReactiveEffect[]): Dep => {
  const dep = new Set<ReactiveEffect>(effects) as Dep
  dep.w = 0
  dep.n = 0
  return dep
}

确定了dep这个参数后,再分别调用trackEffectstriggerEffects,在上篇文章的reactive相关源码中,咱们遇到了tracktrigger两个函数,这四个函数都在packages/reactivity/src/effect.ts文件中,接下来看下具体是怎么收集依赖和触发更新的。

effect

先从effect函数看起:

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

effect函数,有两个参数,第一个参数是一个函数,这个函数将被立即执行,并且会收集这个函数执行过程中的所有响应式依赖,当这些依赖变更,这个函数会再次执行;第二个参数可以传入一些选项以自定义这个副作用的行为,选项说明如下:

export interface ReactiveEffectOptions {
  lazy?: boolean //  是否延迟触发 effect
  computed?: boolean // 是否为计算属性
  scheduler?: (job: ReactiveEffect) => void // 调度函数
  onTrack?: (event: DebuggerEvent) => void // 追踪时触发
  onTrigger?: (event: DebuggerEvent) => void // 触发回调时触发
  onStop?: () => void // 停止监听时触发
}

effect的主要逻辑如下:

  1. 创建包含响应式副作用函数的对象 const _effect = new ReactiveEffect(fn)
  2. 如果没有第二个参数,或者第二个参数中lazy不为true,立即执行响应式副作用函数。
  3. 通过bind绑定_effect.run执行的上下文为_effect
  4. 返回runner,可以通过这个函数来执行响应式副作用函数。

笔者在看effect的时候,顺便看了一下会在哪些地方用到这个effect函数,但是源码中一处都找不到,后面看了提交记录,发现是以前用到了,后面才封装了ReactiveEffect,看日志应该是为了更佳的性能。

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

因此我们不必太关注这个effect,虽然我们也能够 import 这个 effect,但在开发中完全没必要使用它。

接下来看下ReactiveEffect

ReactiveEffect

在上面我们刚看了Dep对象——用来存依赖的Set对象(Set<ReactiveEffect>),那是不是可以简单理解ReactiveEffect就是封装依赖的对象?个人理解完全可以

Vue2 中,有一个 Watcher 类,其种类包括 RenderWatcher(渲染Watcer)UserWatcher(用户定义的watch选项)ComputedWatcher(用户定义的computed选项)。在对数据进行响应式处理时,数据会持有 Dep 实例,并且将这些 Watcher 实例添加到 subs 中;当依赖的数据变化,通过 dep.notify() 通知 subs 中的每个 Watcher 实例,Watcher 实例收到通知后再去做相应的处理。由此可见 Watcher 类的作用就是封装各种 Watcher 的处理逻辑。可以说在 Vue2Watcher实例 就是依赖。

同样的 ReactiveEffect 的作用与 Watcher 差不多,笔者在源码中搜了一下,在下面这些地方会new ReactiveEffect

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

我们可认为 ReactiveEffect 的实例就是依赖。接下来看 ReactiveEffect 的实现。

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

ReactiveEffect这个类用于创建一个包含响应式的副作用函数的对象。这个对象中包含很多属性,比如:

  • active 当前effect实例是否在工作,当调用stop()就会停止工作。
  • parent 上一级effect实例。
  • computed 是不是computedeffect
  • fn 副作用函数fn
  • scheduler 负责调用 fn 的函数。
  • ......

runstop才是ReactiveEffect的核心,一个一个的看:

run

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

这个run的作用就是用来执行副作用函数fn,并且在执行过程中进行依赖收集

⭐️ 在描述这个run的逻辑之前,我们需要知道 effect.run 会执行副作用函数fnfn中可能会触发其他effect.run,就形成了嵌套调用,比如:

<script setup>
import Child from './Child.vue'
</script>

<template>
  <h1>haha</h1>
  <Child />
</template>

在上面这个很简单的例子中,根据之前 初始化文章 讲到组件渲染过程中会new ReactiveEffect()创建一个渲染的副作用函数,在执行渲染的副作用函数时遇到 Child 组件,Child 组件也会有渲染的副作用函数,我们知道整个渲染过程是根据根组件的vnode递归开箱,这样就形成了effect.run 的嵌套调用。

知道这个以后就可以分析run的逻辑了:

  1. 如果当前effect实例是不工作状态,就仅仅执行一下fn,不需要收集依赖。
  2. 由于在一个effect.run的过程中可能会触发另外的effect.run, 暂存上一次的activeEffect、shouldTrack,目的是为了本次执行完以后把activeEffect、shouldTrack恢复回去。
  3. 设置activeEffect、shouldTrack
  4. effectTrackDepth自增,trackOpBit 更新为 1 << effectTrackDepth。其中effectTrackDepth是一个全局变量,表示当前嵌套调用 effect.run 的深度。
  5. 判断 effectTrackDepth 是否小于全局的 maxMarkerBits = 30,如果是就调用 initDepMarkersdep.w 做标记(优化方案),否则就把依赖都删除(简单方案)。(这里其实是两种依赖收集的方式,在组件初始化我们已经收集到了依赖,后续有依赖变更触发了更新,会再次收集依赖,由于依赖项会发生变化,可能变多,可能变少。为了保证依赖的实时性,不多余收集,就有两种收集依赖的方式,一种是把上一次收集到的全部清除,以本次收集到的为准(简单粗暴);考虑到全部清除 & 重新收集损耗更多性能,因此就有了优化的收集方案——打标记,dep.w 代表已收集到的,dep.n 代表是本次收集到的,最后如果 有dep.w 没有 dep.n就需要从上次已收集到的依赖中移除,咱们先简单这么理解,源码中用了位运算去处理,稍后详细分析)
  6. 执行fn()
  7. finally中主要是做一些善后的工作了:移除多余依赖、恢复activeEffect、shouldTrack、调用--effectTrackDepth & trackOpBit更新。
  8. 如果deferStoptrue,执行stop,可能在调用stop时,正在收集依赖,因此推迟到本次收集完成再stop

简单方案

这个非常简单,就是在执行fn之前,先把effect.deps中的dep全部删掉,执行fn收集到的依赖就是最新的。当嵌套的深度超过maxMarkerBits = 30,就会采用这种方案。看下删除依赖的逻辑:

cleanupEffect

function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

遍历effect.deps,删除每个dep中的当前effect

为什么maxMarkerBits = 30

首先我们先得知道JS是怎么进行位运算的:

JavaScript 将数字存储为 64 位浮点数,但所有按位运算都以 32 位二进制数执行。在执行位运算之前,JavaScript 将数字转换为 32 位有符号整数。执行按位操作后,结果将转换回 64 位 JavaScript 数。

32位中有符号整数中第一位是 0(正) 或 1(负),10001,所以可用的正整数前两位固定01,还有30位可以补0

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

因此maxMarkerBits = 30

再来看下trackOpBiteffectTrackDepth 的关系。

trackOpBiteffectTrackDepth 的关系

effectTrackDepth(effect.run 嵌套调用深度)trackOpBit
10001 << 1 = 0010 = 2
20001 << 2 = 0100 = 4
30001 << 3 = 1000 = 8
40001 << 4 = 1 0000 = 16

接下来看优化方案的具体实现过程。

优化方案详解

当嵌套的深度小于maxMarkerBits = 30,就会采用优化方案。 咱们先来看看 &、|位运算。

& 和 | 位运算

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

更详细的可以去查阅更多资料详细学习。接下来咱们就看下打标记的实现。

initDepMarkers

// 在 上面的 run 方法里
if (effectTrackDepth <= maxMarkerBits) {
    initDepMarkers(this);
}

const initDepMarkers = ({ deps }) => {
    // 首次收集依赖跳过,因为压根就还没收集过依赖,所以本次收集到的就是最新的,不需要打标记
    // 如果已经有deps了,就需要遍历给每一个 dep 打标记
    // 在createDep时 dep.w 和 dep.n 都被初始化为 0
    // 在首次收集只会给 dep.n 设置为 dep.n |= trackOpBit,因此首次收集完以后 dep.w = 0
    // 当依赖更新 -> 触发更新
    // 打标记时 deps[i].w = deps[i].w | trackOpBit
    // 在 dep.w = 0 时相当于 dep.w = trackOpBit
    
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            deps[i].w |= trackOpBit; // 相当于 deps[i].w = deps[i].w | trackOpBit;
        }
    }
};

打完标记后,开始执行fn,当访问了响应式的变量,就会调用trackEffects

trackEffects

// dep.ts
// 采用 AND运算符,假如现在深度为1; trackOpBit 为 2;
// 当依赖更新触发更新 执行 trackEffects 时
// 如果是effect.deps中的dep,那么 dep.w >= trackOpBit; dep.w & trackOpBit 就肯定会大于0
// 判断在当前深度,当前的 dep 是不是上一次track过程中track过的
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

// dep.n 同理,判断在当前深度,本次track过程中,当前的 dep 是否track过
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

// effect.ts
export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep)
    }
  } else {
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
  }

  if (shouldTrack) {
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
    if (__DEV__ && activeEffect!.onTrack) {
      activeEffect!.onTrack(
        extend(
          {
            effect: activeEffect!
          },
          debuggerEventExtraInfo!
        )
      )
    }
  }
}

trackEffects的逻辑十分清晰:

if/else还是对应简单方案/优化方案,在简单方案中,之前已经将effect.deps中的dep全删了,shouldTrack = !dep.has(activeEffect!)的作用就是为了不重复收集;在优化方案中,先判断本次track过程中是否已经track过当前dep,如果没有则更新dep.n,且要不要track决定于上一次track过程中有没有track过,如果track过,就没必要再次track,只需要dep.n标记上即可。

如果shouldTrack,那么就将当前effect添加到此dep,并且effect.deps中也要添加这个dep

fn执行完,那么标记也打完了,进入到了finally的逻辑,主要是调用finalizeDepMarkers

finalizeDepMarkers

export const finalizeDepMarkers = (effect: ReactiveEffect) => {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let i = 0; i < deps.length; i++) {
      const dep = deps[i]
      // 有dep.w 没有dep.n的
      // 之前的 track 过程中 track 过,但是本次没有 track 到
      // 说明这个effect的依赖项少了这个dep关联的响应式变量,需要删除掉
      // 这个dep关联的响应式变量变化不再需要触发effect更新
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      } else {
        // 走到 else 的话,说明这个dep不能删,其关联的响应式变量变更依然要触发effect更新
        // 那么就 ptr++,注意这里++在后,假如ptr = 0; deps[ptr++] = dep 
        // 相当于 deps[ptr] = dep; ptr = ptr + 1; 也就是 deps[0] = dep
        // 结合最后一行的 deps.length = ptr,其实这两个操作就是在确定最终的deps
        // 每次遇到一个不能删的dep就往deps前面放,最后改变dep.length,超出ptr的部分就会被删掉。
        deps[ptr++] = dep
      }
      // clear bits
      // 这里是在将dep.w 和 dep.n 重置为0
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

以上就是依赖收集的优化方案,也是整个依赖收集的核心逻辑,接下来看下看下effectstop

stop

stop执行后会将 effect.active 变为false,并且调用cleanupEffect 删除effect.deps,其作用就是为了让一个effect对象不再工作,因为当effectfalse,不再会收集依赖,并且deps已经并删除,不再会触发effect.run。通常是在销毁之前调用这个stop

stop() {
    // stopped while running itself - defer the cleanup
    if (activeEffect === this) {
      this.deferStop = true
    } else if (this.active) {
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }

在源码中用到这个stop的地方有:

  1. unwatch中。
const unwatch = () => {
  effect.stop()
  if (instance && instance.scope) {
    remove(instance.scope.effects!, effect)
  }
}
  1. unmountComponent中。
  2. 销毁逻辑中。

Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)

到此 RactiveEffect 的东西就讲的差不多了,effect.ts中咱们还有三个函数没看,那就是track以及触发更新的triggertriggerEffects。本来计划响应式部分的源码两篇文章就能搞定,看来还得再加一篇啊,这三个函数就放到下一篇详细解读吧!另外下一篇计划通过一些图将响应式部分串起来,敬请期待!

总结

本文主要解读了以下内容:

  1. ref 的实现。
  2. effect 的实现(可以不用太关注,因为没用)。
  3. ReactiveEffect的实现,这个是依赖类,非常重要。
  4. 依赖收集的简单方案。
  5. 依赖收集的优化方案详解。

这是笔者第四篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!

如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【】都是我创作的最大动力 ^_^

转载自:https://juejin.cn/post/7251237669013913655
评论
请登录