Vue3源码解析04–响应式核心effect

时间:2021-2-20 作者:admin

Vue3 源码解析 04–effect

前言

Vue3 的核心就是响应式系统,而 effect 是响应式系统的核心。所以我们的源码解析从 effect 开始学起

这里简单说一下 Vue3 源码目录:

  • reactivity 目录:数据响应式系统,一个单独的系统,可以与任何框架结合使用
  • runtime-core 目录:与平台无关的运行时。实现的功能:虚拟 DOM 渲染器,Vue 组建和 Vue 的各种 API,我们可以利用这个 runtime 实现真堵某个具体平台的高阶 runtime,比如自定义渲染器
  • runtime-dom 目录:针对浏览器的 runtime。实现的功能:原生 DOM API,DOM 事件和 DOM 属性
  • runtime-test 目录:一个专门为了测试写的轻量级 runtime。由于这个 runtime 渲染出的 DOM 树其实是一个 JS 对象,所以这个 runtime 可以用在所有的 JS 环境里。可以用于序列化 DOM,触发 DOM 事件,以及记录某次更新中的 DOM 操作。
  • server-renderer 目录:用于 SSR
  • compiler-core 目录:平台无关的编译器,它既包含可扩展的基础功能,也包含所有平台无关的插件。
  • shared 目录:没有暴露任何 API,主要包含了一些平台无关的内部帮助方法。

什么是 effect

effect 一直穿插在 tranger 和 track 之间。其实单纯的响应式原理,根本不需要 effect,那么 effect 到底是什么呢?
下面我们来看一下 effect 源码中的测试用例:

    //package/reactivity/__tests__/effect.spec.ts
    it('should observe multiple properties', () => {
        let dummy
        const counter = reactive({ num1: 0, num2: 0 })
        effect(() => (dummy = counter.num1 + counter.num1 + counter.num2))

        expect(dummy).toBe(0)
        counter.num1 = counter.num2 = 7
        expect(dummy).toBe(21)
    })

我们先看一下这段代码是用来干啥的:

  • 声明 dummy 变量,和响应式的 counter 对象
  • 在 effect 回调函数中把 counter 的 num1 num2 属性的和赋值给 dummy
  • 断言 dummy 是否等于 0
  • 修改 counter 的 num1 和 num2 属性
  • 然后断言 dummy 是否是 21

这个和我们经常用的一个东西是不是很熟悉?没错,就是 computed
下面我们来看一下 computed 的测试用例来对比一下:

    // packages/reactivity/__tests__/computed.spec.ts
    it('should return updated value', () => {
        const value = reactive<{ foo?: number }>({})
        const cValue = computed(() => value.foo)
        expect(cValue.value).toBe(undefined)
        value.foo = 1
        expect(cValue.value).toBe(1)
    })

上面的代码很好理解,计算属性 cValue 和 value.foo 关联,当 value.foo 变化的时候断言 cValue.value.
这样看下来 effect 和 computed 的作用是不是很相似 ?。

从这里就可以看出来,effect 就是副作用的意思。顾名思义就是响应式的副产品。每次出发了 get 时收集 effect,每次 set 时触发我们收集的 effect。这样我们就可以做一些响应式数据之外的一些事情了。
那么,下面我们来看一下 effect 到底是怎么实现的

effect 的实现原理

在看 effect 源码之前,我们首先要明确一点:effect 应该如何实现

  • 首先,在我们给 effect 传递的回调参数中的响应式数据的 get 被触发后,我们的 effect 就会被收集起来
  • 然后,每个响应式数据被触发的时候都可能有多个相关联的 effect,所以每个数据都需要有一个用来收集 effect 的收集器,即源码中的 deps,当响应式的数据 set 属性被触发时执行这些 effect
  • 最后,为了数据的单一性,我们的 deps 不能放在响应式数据本身,所以我们需要有个集合来储存响应式数据 target 和 deps 的关系

effect 源码

export function effect

effect 的源码都在effect.ts这个文件里面,我们这里先看一下暴露的 effect 函数

    export function effect<T = any>(
        fn: () => T, //回调函数
        options: ReactiveEffectOptions = EMPTY_OBJ //传入的参数
        ): ReactiveEffect<T> {
        //先判断fn是不是effect,如果是则取出原始值
        if (isEffect(fn)) {
            fn = fn.raw
        }
        //创建新的effect
        const effect = createReactiveEffect(fn, options)
        //如果传入的lazy参数为false
        if (!options.lazy) {
            //立即执行我们创建的effect
            effect()
        }
        return effect
        }

整个函数的结构很简单:

  • 接收一个回调函数和 options 参数(options 参数后面介绍)
  • 判断传入的回调函数是不是 effect,如果是取出原始值
  • 调用 createReactiveEffect 创建 effect
  • 如果传入的 options 参数中的 lazy 为 false 则执行 effect
  • 最后返回 effect

这里,我们顺便介绍一下 effect 的 options 参数:

export interface ReactiveEffectOptions {
  lazy?: boolean //是否延迟触发effect
  scheduler?: (job: ReactiveEffect) => void //调度函数
  onTrack?: (event: DebuggerEvent) => void //追踪时触发
  onTrigger?: (event: DebuggerEvent) => void //触发回调时触发
  onStop?: () => void //停止监听时触发
  allowRecurse?: boolean //是否允许递归调用
}

从上面的源码可以看出,该函数的核心就是 createReactiveEffect。

createReactiveEffect

createReactiveEffect 函数主要是用来创建 effect,下面我们看一下 createReactiveEffect 的实现:

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions //选项
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    if (!effect.active) {//判断是否是激活状态
      return options.scheduler ? undefined : fn()
    }
    if (!effectStack.includes(effect)) {
      cleanup(effect)
      try {
        //然后重新进行依赖收集
        enableTracking()
        //effect入栈
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
  //将各种数据挂载到effect上,覆盖更新
  effect.id = uid++ //自增ID,唯一标识
  effect.allowRecurse = !!options.allowRecurse
  effect._isEffect = true //用于标识方法是不是effect
  effect.active = true //effect是否激活
  effect.raw = fn //effect传入的fn
  effect.deps = [] //持有当前effect的dep数组
  effect.options = options //创建effect传入的options
  return effect
}

我们先来看一下这里面的reactiveEffect函数:

  • 先判断是否是激活状态,如果不是激活状态,根据调度函数的状态返回不同的结果
  • 如果当前 effect 收集器effectStack不包含当前 effect 则进行后续处理
  • 接着cleanup清除依赖
  • 然后重新收集依赖,将 effect入栈 effectStack,并将 activeEffect 设置为当前的 effect(主要是为了收集依赖的时候使用),返回执行的回调函数
  • 最后将当前 effect 出栈,恢复之前的状态

reactiveEffect 函数之后,将各种数据挂载到 effect 上,覆盖更新。下面我们来介绍一下 effect 的其他属性:

  • id:effect 自增 ID,唯一标识
  • allowRecurse:是否允许递归,默认为 false
  • _isEffect:用于标识当前函数是不是 effect,默认为 true
  • active:当前 effect 是否激活,默认为 false。调用 stop 函数之后,修改为 false
  • raw: effect 传入的 fn 回调函数(就是我们上面提到的原始值)
  • deps:持有当前 effect 的 deps 数组
  • options:创建 effect 传入的 options 参数

上面提到了 stop 函数,我们先看一下 stop 方法的实现:

export function stop(effect: ReactiveEffect) {
  if (effect.active) {
    cleanup(effect)
    if (effect.options.onStop) {
      effect.options.onStop()
    }
    effect.active = false
  }
}

stop 函数的主要作用就是停止当前的 effect,当调用该方法以后,会清空其他对象的 effect 依赖,同时调用 onStop 回调,最后将 effect 激活状态设为 false。
这样的话,下次调用 effect 的时候不会进行依赖的收集。

接着,我们来看一下 cleanup 函数:

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

其中,deps 就是持有 effect 的依赖数组。其中每个数组元素是对应对象某个 key 的全部依赖。cleanup 的大概实现就是先把 effect 从 deps 数组中移除,然后清空 deps 数组。

总的来说,createReactiveEffect 函数,创建了 effect 并挂载了各种属性。而 effect 函数,会在执行过程中完成依赖的收集。

那么,effect 是怎么收集依赖的呢,我们上面提到过,当 effect 运行的时候,当前的对象的 key 的依赖 set 集合会把 effect 收集进去。

track

Vue3 中在数据劫持的过程中,get 会触发 track 函数进行依赖的收集,set 会触发 trigger 方法执行我们收集到的依赖方法。
下面,我们来看一下 track 函数是怎么进行依赖收集的。

下面我们来分析一下 track 方法:

export function track(target: object, type: TrackOpTypes, key: unknown) {...}
  • 该方法接收三个参数

    • target:触发 track 的对象
    • type:表示触发 track 的类型(主要有 get、has、iterate 三种类型)
    • key:表示触发当前 track 方法对应的 object 属性 key
  • 首先根据 shouldTrack 和 activeEffect 判断是否进行依赖收集。(这里的activeEffect就是我们上面提到的跟收集依赖相关的属性)

if (!shouldTrack || activeEffect === undefined) {
    //满足该条件则不进行依赖收集
    return
  }
  • 接着,判断 targetMap 是否包含当前对象对应的依赖集合,则创建新的依赖集合
let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  • 判断有无当前 key 对应的 dep,没有则创建
let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  • 最后,如果当前 key 的依赖集合没有当前 activeEffect(就是当前的 effect),则把 activeEffect 加入到集合中,同时把 dep 加入到 deps 数组中。最后如果是开发环境并且 onTrack 函数存在,则触发 onTrack 函数。
if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
    if (__DEV__ && activeEffect.options.onTrack) {
      activeEffect.options.onTrack({
        effect: activeEffect,
        target,
        type,
        key
      })
    }
  }

总结一下,track 方法主要用来收集依赖,当响应式对象的 get 的时候触发。首先根据当前响应式对象处理targetMap集合的状态,然后根据当前触发的属性 key 来处理 dep 的集合。最后判断是否触发 onTrack 函数。

结合之前的代码分析,deps 是 effect 中依赖的 key 对应的 Set 集合。因为正常情况下,effect 和对象是多对多的关系。

依赖收集完毕后,当对象 set 的时候则触发相应的依赖,下面我们来看一下触发依赖的 trigger 方法。

trigger

trigger 方法的作用就是触发收集的依赖。因为 trigger 的方法比较长,这里我们来简单分析一下

  • 这里 trigger 方法接收多个属性,因为都比较简单,这里额外介绍一下 type
    • type:触发数据更新的类型,主要有四种类型:set、add、delete、clear
export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {...}
  • add 方法是根据将 effect 添加进不同的分组。里面的 effect!==activeEffect||effect.allowRecurse 是为了避免死循环。
 //创建effect集合,用来存放effect
const effects = new Set<ReactiveEffect>()
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }

这里,我们利用一个官方的测试用例来说明一下为何要避免死循环

 it('should avoid infinite loops with other effects', () => {
    const nums = reactive({ num1: 0, num2: 1 })

    const spy1 = jest.fn(() => (nums.num1 = nums.num2))
    const spy2 = jest.fn(() => (nums.num2 = nums.num1))
    effect(spy1)
    effect(spy2)
    expect(nums.num1).toBe(1)
    expect(nums.num2).toBe(1)
    expect(spy1).toHaveBeenCalledTimes(1)
    expect(spy2).toHaveBeenCalledTimes(1)
    nums.num2 = 4
    expect(nums.num1).toBe(4)
    expect(nums.num2).toBe(4)
    expect(spy1).toHaveBeenCalledTimes(2)
    expect(spy2).toHaveBeenCalledTimes(2)
    nums.num1 = 10
    expect(nums.num1).toBe(10)
    expect(nums.num2).toBe(10)
    expect(spy1).toHaveBeenCalledTimes(3)
    expect(spy2).toHaveBeenCalledTimes(3)
  })

之前的判断,主要是为了规避在 effect 中循环触发 set 操作。

  • 之后根据触发类型,对 effect 进行不同的处理。如果是clear类型则触发这个对象的所有 effect。如果为数组的 set 操作,则会触发 key 为 length 的 effects,
if (type === TriggerOpTypes.CLEAR) {
    depsMap.forEach(add)
  } else if (key === 'length' && isArray(target)) {
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    if (key !== void 0) {
      add(depsMap.get(key))
    }
  • 下面都是根据 type 对正常的新增、修改、删除进行 effect 的分组

  • 最后,批量运行 effect。其中如果 scheduler 传入了调度函数,则通过 scheduler 函数去运行 effect,但是 scheduler 不一定使用了 effect.

const run = (effect: ReactiveEffect) => {
    if (__DEV__ && effect.options.onTrigger) {
        ...
    }
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }
  effects.forEach(run)
}

总结一下,trigger 是在数据触发 Set 的时候执行,目的是根据我们的操作对 effect 进行分组处理。

总结

这节介绍了下 Vue3 响应式的核心概念:effect。effect 的作用主要是在响应式数据处理的过程进行依赖的收集,然后在数据更新的时候触发依赖。代码看起来可能比较乱,如果大家看代码的时候无从下手的话,可以结合测试用例来看,因为测试用例的描述比较清晰。
以上,便是个人对 effect 源码的一点浅见,希望对大家的学习有所帮助。

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。