带你阅读Vue3.0响应式系统源码4-依赖绑定以及触发依赖的执行策略

1,285 阅读13分钟

主题:依赖绑定以及触发依赖的执行策略

ps:多代码预警,本节因为涉及到的流程重要代码比较多,所以贴出了核心代码。

1.代理对象和影响因子effect如何绑定?(track)

​ 上一篇文章讲的是响应型数据的诞生。而前面说过,响应数据的诞生和它绑定观察者是两回事。那么响应型对象和影响因子effect是如何进行绑定依赖的呢?依赖的添加是在track方法中,下面来进行讲解:

  1. 什么时候进行调用track方法?

可以看到基本上都在handlers中进行调用,笔者对这些调用的方法进行追踪,发现调用者都在这里:

  • baseHandlers.tsgethasownKeys代理拦截方法里面进行追踪。
  • collectionHandlers.tsgethassizeforEachiterator方法里面进行追踪。
  • ref.ts:在value方法中,也就是求值方法。

所以可以验证其那面的一句话:在获取数据的地方进行依赖的绑定

  1. track方法中做了什么事情呢?下面我们会对整个流程所有必要代码进行解释,前面说过不会是以所有代码来进行讲解,只会讲解关键代码。

    /**
     * @description 追踪就是依赖的添加过程,相当于2.0的observer过程
     * @param target 代理对象
     * @param type 观察的类型,在开发环境中使用
     * @param key 想要被观察的属性,也就是effect函数中访问的对象的属性
     */
    export function track(target: object, type: TrackOpTypes, key: unknown) {
      // 如果不是在观察依赖添加时期或者当前没有活跃的effect的时候,不进行任何处理
      if (!shouldTrack || activeEffect === undefined) {
        return
      }
      // 接下来是进行构建targetMap的过程,详情可以查看附录2
      let depsMap = targetMap.get(target)
      if (depsMap === void 0) {
        targetMap.set(target, (depsMap = new Map()))
      }
      let dep = depsMap.get(key)
      if (dep === void 0) {
        depsMap.set(key, (dep = new Set()))
      }
    
      // 进行响应数据和effect的互相绑定,dep是一个Set数据结构,这个是进行去重操作
      if (!dep.has(activeEffect)) {
        dep.add(activeEffect)
        activeEffect.deps.push(dep)
        if (__DEV__ && activeEffect.options.onTrack) {
          activeEffect.options.onTrack({
            effect: activeEffect,
            target,
            type,
            key
          })
        }
      }
    }
    

    看完上面代码,我们会想要知道shouldTrackactiveEffect到底在什么时候进行:

    • shouldTrack:使用pauseTrackresumeTrack进行修改shouldTrack,而这两个方法在哪里进行调用呢?

      if (target.isUnmounted) {
          return
        }
        // disable tracking inside all lifecycle hooks
        // since they can potentially be called inside effects.
        pauseTracking()       // 退出观测模式模式
        // Set currentInstance during hook invocation.
        // This assumes the hook does not synchronously trigger other hooks, which
        // can only be false when the user does something really funky.
        setCurrentInstance(target)
        const res = callWithAsyncErrorHandling(hook, target, type, args)
        setCurrentInstance(null)
        resumeTracking()			// 进入观测模式
        return res
      })
      

      我们可以从代码和注释中了解到,在除了Unmounted生命周期钩子之外,在其他声明周期钩子函数调用期间不进行追踪操作。

    • activeEffect:在执行effect函数之前,会进行effect赋值给activeEffecteffect是一个带有属性的方法实例),在执行期间会进行依赖的绑定。

      function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
        if (!effect.active) {
          return fn(...args)
        }
        if (!effectStack.includes(effect)) {
          // 每次执行的时候会进行清空effect所包含的所有依赖,等到执行的时候会重新添加依赖,所以依赖的绑定是动态的
          cleanup(effect)
          try {
            // 进栈的原因是可能观察者之间的嵌套,可能一个计算属性有另外的计算属性,这里进行父子观察者之间的绑定
            effectStack.push(effect)
            activeEffect = effect
            return fn(...args)
          } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
          }
        }
      }
      
    • 最后就进行依赖的depactiveEffect的互相绑定。

  2. 特殊情况:

    ​ 计算属性最终是会处理成为数据属性,计算属性有两种定义方式:代理其他属性(不是Proxy,仅仅是引用其他属性)、一个方法。

    ​ 对于代理其他属性我们不进行讨论,我们主要来讲一下当计算属性是一个方法时候是怎么进行处理的。而这个计算方法会处理成为一个effect函数,所以笔者将计算方法纳入观察者的类型中也是这个原因。

    ​ 笔者想要讲的是当一个计算方法引用另外一个计算方法的时候,会怎么进行处理呢?具体可以看一下章节3的4.1部分的trackChildRun方法

2.影响因子effect的触发流程(trigger)

​ 在前面进行讲解依赖的添加,现在进行讲解依赖的触发过程,触发影响因子的流程。

  1. 什么时候触发执行?

同样,都是在修改数据的时候进行触发。

  1. 起点:一切影响因子的触发起点都是数据进行改变,在trigger函数里面进行搜集然后触发,下面是trigger函数的方法:

    function trigger(
      target: object,
      type: TriggerOpTypes,
      key?: unknown,
      extraInfo?: DebuggerEventExtraInfo
    ) {
      const depsMap = targetMap.get(target)
      if (depsMap === void 0) {
        // never been tracked
        return
      }
      // 搜集要执行的影响因子,分开搜集是因为计算属性要先执行
      const effects = new Set<ReactiveEffect>()
      const computedRunners = new Set<ReactiveEffect>()
      if (type === TriggerOpTypes.CLEAR) {
        // collection being cleared, trigger all effects for target
        // 是清除操作的时候,先把所有属性添加在runner中,然后在执行runner前会清除KeyToDepSet里面的项,也就是进行解绑操作。整个流程后面会说
        depsMap.forEach(dep => {
          addRunners(effects, computedRunners, dep)
        })
      } else {
        // schedule runs for SET | ADD | DELETE
        if (key !== void 0) {
          addRunners(effects, computedRunners, depsMap.get(key))
        }
        // also run for iteration key on ADD | DELETE
        if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
    			// 在进行属性添加的时候,分成两种情况
          // 数组:数组的迭代、slice等其他方法都会在这个数组所对应的KeyToDepSet中的length属性的Set上添加依赖,修改到数组长度的时候会进行触发。
          // 容器:容器类型的迭代器是框架源码制定的,修改长度也会进行触发迭代的依赖
          const iterationKey = isArray(target) ? 'length' : ITERATE_KEY
          addRunners(effects, computedRunners, depsMap.get(iterationKey))
        }
      }
      const run = (effect: ReactiveEffect) => {
        scheduleRun(effect, target, type, key, extraInfo)
      }
      // Important: computed effects must be run first so that computed getters can be invalidated before any normal effects that depend on them are run.
      // 重要提示:必须先运行计算的效果,以便在运行依赖于它们的任何普通效果之前,使计算的dirty无效
      // 记住,js的forEach是个特例,是一个同步执行代码。
      computedRunners.forEach(run)
      effects.forEach(run)
    }
    

    trigger函数是进行跟数据绑定的所有依赖的搜集,搜集完毕直接执行依赖。初次看到这里,笔者也会疑惑:Vue3.0不准备使用队列来进行性能的提升了吗?

    ​ 其实不是,这是因为Vue3.0将观察者拆散了,重新定义了effect,是Watcher的精简版,执行策略是由创建者规定的,针对于三种观察者有三种执行策略,下一部分会进行讲解。

    阅读代码的时候会产生如下的问题:

    • 为什么会先进行带有计算属性effect的执行,然后再执行其他方法呢?

      是为了保证观察者的执行顺序:computed->watch->render,为什么要这么做?

      • 对于computed选项:这是因为在其他观察者中可能会对计算属性进行访问,也就是访问getter方法,如果计算属性在最后执行的话,会产生数据与视图不一致问题,也就是说读到脏数据。
      • 对于watch选项:同样如果watch中修改了render所绑定的数据,如果watchrender后面执行,那么也会产生数据与视图不一致问题
    • Vue3.0Vue2.0是如何来实现以上顺序呢?

      1. 3.0:采取了一下两种策略:

        1. 带有computed属性的effect的先执行:其中computedwatch选项的effect都有该属性,其中computed选项是同步代码,也就是遇到了直接执行,而watch采取了三种执行策略,但是都不会早于computed选项执行。即computed->watch

        2. computed使用同步代码进行执行:如果在watch选项中修改到computed数据,那么搜集完后会同步执行,所以下一个观察者进行执行的时候拿到了最新数据。

      2. 2.0:按这么说,那Vue2.0是如何处理来避免读到脏数据呢?先阅读一下这篇文章:从源码上看,Vue2.0每个生命周期钩子函数前都做了什么?created生命周期钩子前处理选项的顺序。

        首先确定一下几种观察者的初始化时间:

        • computed:在created生命周期钩子前,在watch之前处理。
        • watch:在created生命周期钩子前,但是在computed之后处理。
        • render函数:在mounted之前。

        那么处理顺序为computed->watch->render。那么这么做有什么意图吗?

        要知道watcher实例是有一个属性,叫做id(自增),有两种作用:

        1. 显示区别不同watcher
        2. 在观察者执行队列的时候按id顺序从小到大执行

        这是在执行队列前进行排序,如果执行队列的时候又触发了修改watcher,又是如何处理的呢?如下图

        在运行前和运行时候这两个策略,能够有效防止读到脏数据。

  2. 依赖绑定的更新:

    在触发依赖的终点是用effect构建时候传进来的scheduler策略来执行effect,不过在之前会进行依赖的重新绑定操作。我们可以看到effect.ts中的createReactiveEffect方法:

    function createReactiveEffect<T = any>(
      fn: () => T,
      options: ReactiveEffectOptions
    ): ReactiveEffect<T> {
      const effect = function reactiveEffect(...args: unknown[]): unknown {
        return run(effect, fn, args)  // 转到run函数,我们进行查看run函数
      } as ReactiveEffect
    	// code...
      return effect
    }
    
    function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
      if (!effect.active) {
        return fn(...args)
      }
      if (!effectStack.includes(effect)) {
        // 每次执行的时候会进行清空effect所包含的所有依赖,等到执行的时候会重新添加依赖,所以这就是运行时候的动态修改依赖
        cleanup(effect)
        try {
          // 进栈的原因是可能这个观察者调用的时候会使用到另外一个观察者,也就是观察者的嵌套
          effectStack.push(effect)
          activeEffect = effect
          return fn(...args)
        } finally {
          effectStack.pop()
          activeEffect = effectStack[effectStack.length - 1]
        }
      }
    }
    

    ​ 在执行观察者的回调前,需要先进行消除掉观察者的所有依赖,然后再进行执行effect函数进行重新绑定依赖。这是因为每次执行观察者的回调函数的时候,可能显示的数据属性修改了,那么就要对不需要绑定的依赖进行解绑,对新的依赖进行绑定。按照老规矩,我们将Vue3.0Vue2.0进行对比:

    是否支持新增加属性的监听(未定义的)?

    • 2.0:不支持,是由机制决定的,一开始初始化选项的时候已经将所有数据进行变成响应式数据了。之后不会进行观测数据了,所以直接对对象进行添加新的下标的话,是不会进行绑定依赖操作的。不过2.0提供vm.$set这个API进行动态添加观测数据。

    • 3.0:支持,这是因为代理是对对象的所有属性进行统一处理(包括已定义和未定义的属性)。我们还是设定一个场景吧,在data选项中定义以下数据:

      data() {
        return {
          a: {}
        };
      }
      

      但是我们在渲染函数访问了a.b属性,我们知道在2.0中就算给a.b属性进行赋值,也不会触发视图层响应的,但是在3.0是可以的。这是为什么呢?因为2.0响应目标是对象的属性,而3.0响应目标是对象

      这么来想:我们可以把对象的所有可能的并且未定义的属性看做undefined,渲染函数访问了一个未定义的属性,此时会在全局的KeyToDepSet对象上添加了该属性的Set并且进行了依赖的绑定。

3.三种观察者的执行策略

Vue有三种观察者:computedwatchrender。触发三者的回调函数执行的策略是不同的。这是在创建effect的时候使用了工厂模式和策略模式,根据传进来的策略来决定执行策略。触发依赖进行执行函数的代码说明了:

function scheduleRun(
  effect: ReactiveEffect,                 // 相当于watcher
  target: object,                         // 目标对象
  type: TriggerOpTypes,                   // 触发类型
  key: unknown,                           // 键值
  extraInfo?: DebuggerEventExtraInfo
) {
	// code...
  if (effect.options.scheduler !== void 0) {
    effect.options.scheduler(effect)
  } else {
    effect()
  }
}

那么这些scheduler有哪些呢?

  1. 首先最接近的莫过于同目录下的computed.ts文件:

    const runner = effect(getter, {
      lazy: true,
      // mark effect as computed so that it gets priority during trigger
      computed: true,
      scheduler: () => {
        dirty = true
      }
    })
    

    很容易理解,dirty相当于一个开关作用,下次访问数据的时候进行更新值。

  2. render函数的策略是什么呢?在runtime-core/renderer.ts文件的setupRenderEffect这个方法中的执执行effect的第二个参数:prodEffectOptions,很简单一句代码:

    const prodEffectOptions = {
      scheduler: queueJob
    }
    
    

    所以对于渲染函数采用的是Vue2.0的执行微观队列模式,而执行队列的话是跟Vue2.0一致,不过它并没有考虑不兼容Promise对象的浏览器,因为Vue3.0打包的时候会有两种版本,一种是支持ES6的浏览器,另外一种是针对于IE等低版本浏览器而是用Vue2.0版本。

  3. 接下来比较重要的一点是对watch选项的处理方式的讲解:

    我们长话短说,还是贴出关键代码,我们先看一下watch选项的接口声明:

    export interface WatchOptions {
      lazy?: boolean
      flush?: 'pre' | 'post' | 'sync'   // 定义了三种类型的执行方式
      deep?: boolean
      onTrack?: ReactiveEffectOptions['onTrack']
      onTrigger?: ReactiveEffectOptions['onTrigger']
    }
    
    

    我们可以知道watch有三种执行方式,接下来对这三种方式进行讲解,首先贴出代码:

    const invoke = (fn: Function) => fn()
    // code
    
    let scheduler: (job: () => any) => void
    if (flush === 'sync') {
      scheduler = invoke
    } else if (flush === 'pre') {
      scheduler = job => {
        if (!instance || instance.vnode.el != null) {
          // instance.vnode.el != null  即当前VNode有挂载数据,也就是mounted到unMounted声明周期之间
          // !instance 代表着是在执行生命周期钩子函数以外的时间,因为instance指向currentInstance,而currentInstance赋值的时候只有在触发生命周期钩子才不为空
          queueJob(job)
        } else {
          // 这里执行的条件是  在mouted之前,并且是在生命周期钩子里面进行修改数据的时候才触发
          // with 'pre' option, the first call must happen before
          // the component is mounted so it is called synchronously.
          // 第一次执行是在component之前,所以必须是同步执行
          job()
        }
      }
    } else {
      // post 则放到post队列
      scheduler = job => {
        queuePostRenderEffect(job, suspense)
      }
    }
    
    
    • sync:同步,也就是和computed处理方式一致,触发依赖后直接同步执行。

    • pre:是syncqueueJob之间的摇摆人,而分界线在于mounted

      • 在元素挂载之前,并且是在生命周期钩子函数(beforeCreatecreated等)修改数据的话,是sync
      • 在元素挂载之后,则是是用微观队列来进行执行。
    • postVue3.0多了一个执行队列,是postFlushCbs,它是在queueJob执行完毕后进行执行的。它的调用过程是:

      1. queuePostFlushCb(进行postFlushCbs入队后后,触发下一个微观事件进行执行queueJob
      2. queueJob执行完毕后,进行同步执行执行postFlushCbs队列。

      所以post是在微观队列之后执行的。

4.小结

  1. 首先总结一下数据响应的整个流程:

    首先是依赖的添加过程:

    其次是依赖触发流程:

  2. 对于目前已经定义的三种观察者的执行顺序是computed->watch->render。而Vue2.0Vue3.0采取的是完全不同的策略,不过它们的最终目的就是保证执行顺序按照上面来。

5.本系列解读传送门

1.绪论

2.对象及数据结构分析

3.响应型数据诞生

4.依赖绑定以及触发依赖的执行策略

5.总结

附录1.Vue3.0代理如何对数组的原生方法进行观察

附录2.Vue3.0响应数据对象的构建过程(必读)