27

Vue 3.x 响应式原理——effect源码分析

 3 years ago
source link: https://zhuanlan.zhihu.com/p/95012874
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Vue 3.x 响应式原理——effect源码分析

在上一篇文章Vue 3.x 响应式原理——ref源码分析中,笔者简述了Vue 3.x 的 ref API 的实现原理,本文是响应式原理核心部分之一,effect模块用于描述 Vue 3.x 存储响应,追踪变化,这篇文章从effect模块的tracktrigger开始,探索在创建响应式对象时,立即触发其getter一次,会使用track收集到其依赖,在响应式对象变更时,立即触发trigger,更新该响应式对象的依赖。

阅读此文之前,如果对以下知识点不够了解,可以先了解以下知识点:

笔者之前也写过相关文章,也可以结合相关文章:

从track开始

track是收集依赖的函数,怎么理解呢,例如我们使用计算属性computed时,其依赖的属性更新会引起计算属性被重新计算,就是靠得这个track。在reactive模块时,我们就看到了响应式对象的getter都会在内部调用这个track

function createGetter(isReadonly: boolean) {
  return function get(target: object, key: string | symbol, receiver: object) {
    // 通过Reflect拿到原始的get行为
    const res = Reflect.get(target, key, receiver)
    // 如果是内置方法,不需要另外进行代理
    if (isSymbol(key) && builtInSymbols.has(key)) {
      return res
    }
    // 如果是ref对象,代理到ref.value
    if (isRef(res)) {
      return res.value
    }
    // track用于收集依赖
    track(target, OperationTypes.GET, key)
    // 判断是嵌套对象,如果是嵌套对象,需要另外处理
    // 如果是基本类型,直接返回代理到的值
    return isObject(res)
      // 这里createGetter是创建响应式对象的,传入的isReadonly是false
      // 如果是嵌套对象的情况,通过递归调用reactive拿到结果
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }
}

在阅读reactive模块的代码时我们就带有这样的疑问:怎么理解这里的track调用呢?笔者之前有看过 Vue 1.x 的响应式源码的部分,这里猜想应该是和 Vue 1.x 差不多的,相关文章可见Vue源码学习笔记之Dep和Watcher

我们假设,在初始化响应式对象时,就会调用其getter一次,在getter调用前,我们初始化一个结构,假设叫dep,在初始化这个响应式对象,即其getter调用过程中,如果对其它响应式对象进行取值,则会触发了其它响应式对象的getter方法,在其它响应式对象的getter方法中,调用了track方法,track方法会把被依赖的响应式对象及其相关特征属性存入其对应的dep中,这样在被依赖者更新时,这次初始化的响应式对象会重新调用getter,触发重新计算。

现在,我们开始来看track,并从中印证我们的猜想:

// 全局开关,默认打开track,如果关闭track,则会导致 Vue 内部停止对变化进行追踪
let shouldTrack = true

export function pauseTracking() {
  shouldTrack = false
}

export function resumeTracking() {
  shouldTrack = true
}

export function track(target: object, type: OperationTypes, key?: unknown) {
  // 全局开关关闭或effectStack为空,无需收集依赖
  if (!shouldTrack || effectStack.length === 0) {
    return
  }
  // 从effectStack取出一个叫做effect的变量,这里先猜想:effect用于描述当前响应式对象
  const effect = effectStack[effectStack.length - 1]
  // 如果当前操作是遍历,标记为遍历
  if (type === OperationTypes.ITERATE) {
    key = ITERATE_KEY
  }
  // targetMap是在创建响应式对象时初始化的,target是响应式对象,targetMap映射到一个空map,这个map指的就是depsMap
  // 所以可以看出来,targetMap两层map,第一层从响应式对象映射到depsMap,第二层才是depsMap,通过后面的代码我们知道depsMap是相关操作:SET,ADD,DELETE,CLEAR,GET,HAS,ITERATE到一个Set的映射,Set里存放的是对应的effect
  // 如果depsMap为空,这时候在targetMap里面初始化一个空的Map
  let depsMap = targetMap.get(target)
  if (depsMap === void 0) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // 通过key拿到dep这个Set
  let dep = depsMap.get(key!)
  // 如果dep为空,初始化dep为一个Set
  if (dep === void 0) {
    depsMap.set(key!, (dep = new Set()))
  }
  // 开始收集依赖:将effect放入dep,并且更新effect里的deps属性,将dep也放到effect.deps里,用于描述当前响应式对象的依赖
  if (!dep.has(effect)) {
    dep.add(effect)
    effect.deps.push(dep)
    // 开发环境下,触发相应的钩子函数
    if (__DEV__ && effect.options.onTrack) {
      effect.options.onTrack({
        effect,
        target,
        type,
        key
      })
    }
  }
}

通过上面的代码,基本印证了刚刚我们的猜想,不过有几个地方我们可能有点似懂非懂:

  • effectStack是一个什么结构,为什么从effectStack栈顶部effectStack[effectStack.length - 1]取到的就恰好是用于描述当前需要收集依赖的响应式对象的effect
  • effect的结构又是怎样的,是在哪里被初始化的?
  • 收集到的依赖deps,又是怎么在对应的响应式对象更新时,对应更新具有依赖的响应式对象的?

下面在针对上述三点可能的疑问,回到effect模块的源码来寻找答案:

看effect的结构

首先来看effectStackeffect的结构:

export interface ReactiveEffect<T = any> {
  (): T // ReactiveEffect是一个函数类型,其参数列表为空,返回值类型为T
  _isEffect: true // 标识为effect
  active: boolean // active是effect激活的开关,打开会收集依赖,关闭会导致收集依赖无效
  raw: () => T // 原始监听函数
  deps: Array<Dep> // 存储依赖的deps
  options: ReactiveEffectOptions // 相关选项
}

export interface ReactiveEffectOptions {
  lazy?: boolean // 延迟计算的标识
  computed?: boolean // 是否是computed依赖的监听函数
  scheduler?: (run: Function) => void // 自定义的依赖收集函数,一般用于外部引入@vue/reactivity时使用
  onTrack?: (event: DebuggerEvent) => void // 本地调试时使用的相关钩子函数
  onTrigger?: (event: DebuggerEvent) => void // 本地调试时使用的相关钩子函数
  onStop?: () => void // 本地调试时使用的相关钩子函数
}

// 判断一个函数是否是effect,直接判断_isEffect即可
export function isEffect(fn: any): fn is ReactiveEffect {
  return fn != null && fn._isEffect === true
}

通过上面的代码可以知道,effect是一个函数,其下挂载了一些属性,用于描述其依赖和状态。其中raw是保存其原始监听函数,这里我们可以猜想effect既然也是函数类型,那么其调用时,除了调用原始函数raw之外,还会进行依赖收集,下面来看effect的代码:

// effectStack是用于存放所有effect的数组
export const effectStack: ReactiveEffect[] = []

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  // fn已经是一个effect函数了,利用fn.raw重新创建effect
  if (isEffect(fn)) {
    fn = fn.raw
  }
  // 创建监听函数
  const effect = createReactiveEffect(fn, options)
  // 如果不是延迟执行,立刻调用一次effect来进行收集依赖
  if (!options.lazy) {
    effect()
  }
  return effect
}

// 停止收集依赖的函数
export function stop(effect: ReactiveEffect) {
  // 当前effect是active的
  if (effect.active) {
    // 清除effect的所有依赖
    cleanup(effect)
    // 如果有onStop钩子,调用钩子函数
    if (effect.options.onStop) {
      effect.options.onStop()
    }
    // active标记为false,标记这个effect已经停止收集依赖了
    effect.active = false
  }
}

// 创建effect
function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  // effect其实就是调用run,在下面可以看到run就是收集依赖的过程
  const effect = function reactiveEffect(...args: unknown[]): unknown {
    return run(effect, fn, args)
  } as ReactiveEffect
  // 初始化时,初始化effect的各项属性
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}
// 开始收集依赖
function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
  // 当active标记为false,直接调用原始监听函数
  if (!effect.active) {
    return fn(...args)
  }
  // 当前effect不在effectStack中,就开始收集依赖
  if (!effectStack.includes(effect)) {
    // 收集依赖前,先清理一次effect的依赖
    // 这里先清理的一次的目的是重新对同一个属性创建新的监听时,要先把原始的监听的依赖清空
    cleanup(effect)
    try {
      // effect放入effectStack中
      effectStack.push(effect)
      // 调用原始函数,在这里调用原始函数时,如果原始函数里面对响应式对象进行取值了,会触发这个响应式对象的getter,在其getter中调用了track,就收集到依赖了
      return fn(...args)
    } finally {
      // 调用完成后,出栈
      effectStack.pop()
    }
  }
}

// 清理依赖的方法,遍历deps,并清空
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
  }
}

上面的代码基本很好理解,在创建监听时就会调用一次effect,只要effectactive的,就会触发依赖收集。依赖收集的核心是在这里调用原始监听函数时,如果原始函数里面对响应式对象进行取值了,会触发这个响应式对象的getter,在其getter中调用了track

结合上面的代码,再理解track

  • track时,effectStack栈顶就是当前的effect,因为在调用原始监听函数前,执行了effectStack.push(effect),在调用完成最后,会执行effectStack.pop()出栈。
  • effect.activefalse时会导致effectStack.length === 0,这时不用收集依赖,在track函数调用开始时就做了此判断。
  • 判断effectStack.includes(effect)的目的是避免出现循环依赖:设想一下以下监听函数,在监听时,出现了递归调用原始监听函数修改依赖数据的情况,如果不判断effectStack.includes(effect)effectStack又会把相同的effect放入栈中,增加effectStack.includes(effect)避免了此类情况。
const counter = reactive({ num: 0 });
const numSpy = () => {
  counter.num++;
  if (counter.num < 10) {
    numSpy();
  }
}
effect(numSpy);

trigger

通过上面对effecttrack的解析,我们已经基本清楚了依赖收集的过程了,对于整个effect模块的理解,就只差trigger。既然track用于收集依赖,我们很容易知道trigger是响应式数据改变后,通知依赖其的响应式数据改变的方法,通过阅读trigger即可回答上面的问题:收集到的依赖deps,又是怎么在其依赖更新时,对应更新具有依赖的响应式对象的?

下面来看trigger

export function trigger(
  target: object,
  type: OperationTypes,
  key?: unknown,
  extraInfo?: DebuggerEventExtraInfo
) {
  // 通过原始对象,映射到对应的依赖depsMap
  const depsMap = targetMap.get(target)
  // 如果这个对象没有依赖,直接返回。不触发更新
  if (depsMap === void 0) {
    // never been tracked
    return
  }
  // effects集合
  const effects = new Set<ReactiveEffect>()
  // 用于comptuted的effects集合
  const computedRunners = new Set<ReactiveEffect>()
  // 如果是清除整个集合的数据,那就是集合每一项都会发生变化,调用addRunners将需要更新的依赖加入执行队列里面
  if (type === OperationTypes.CLEAR) {
    // collection being cleared, trigger all effects for target
    depsMap.forEach(dep => {
      addRunners(effects, computedRunners, dep)
    })
  } else {
    // SET | ADD | DELETE三种操作都是对于响应式对象某一个属性而言的,只需要通知依赖这一个属性的状态更新
    // 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 === OperationTypes.ADD || type === OperationTypes.DELETE) {
      // 数组是length,对象是ITERATE_KEY
      // 为什么这里要对length单独处理?原因是在对数组、Set等调用push/pop/delete/add等方法时,不会触发对应数组下标的set,而是通过劫持length和ITERATE_KEY的改变来实现的
      // 所以这里要把length或者ITERATE_KEY的依赖更新,这样就可以保证在调用push/pop/delete/add等方法时,也会通知依赖响应式数据的状态更新了
      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.
  // 进行更新
  // 计算属性的effect必须先执行,因为正常的响应式属性可能会依赖于计算属性的数据
  computedRunners.forEach(run)
  // 再执行正常监听函数
  effects.forEach(run)
}

// 将effect添加到执行队列中
function addRunners(
  effects: Set<ReactiveEffect>,
  computedRunners: Set<ReactiveEffect>,
  effectsToAdd: Set<ReactiveEffect> | undefined
) {
  // effectsToAdd是所有的依赖
  if (effectsToAdd !== void 0) {
    // 将一个effect的依赖都放入执行队列
    effectsToAdd.forEach(effect => {
      // 对computed的对象单独处理,computed是分开的队列
      if (effect.options.computed) {
        computedRunners.add(effect)
      } else {
        effects.add(effect)
      }
    })
  }
}

// 触发所有依赖更新
function scheduleRun(
  effect: ReactiveEffect,
  target: object,
  type: OperationTypes,
  key: unknown,
  extraInfo?: DebuggerEventExtraInfo
) {
  // 开发环境,触发对应钩子函数
  if (__DEV__ && effect.options.onTrigger) {
    const event: DebuggerEvent = {
      effect,
      target,
      key,
      type
    }
    effect.options.onTrigger(extraInfo ? extend(event, extraInfo) : event)
  }
  // 调用effect,即监听函数,进行更新
  if (effect.options.scheduler !== void 0) {
    effect.options.scheduler(effect)
  } else {
    effect()
  }
}

上面的代码根据注释也很好理解,trigger就是当响应式属性更新时,通知其依赖的数据进行更新。在trigger内部会维护两个队列effectscomputedRunners,分别是普通属性和计算属性的依赖更新队列,在trigger调用时,Vue 会找到更新属性对应的依赖,然后将需要更新的effect放到执行队列里面,执行队列是Set类型,可以很好地保证同一个effect不会被重复调用。在完成了依赖查找之后,对effectscomputedRunners进行遍历,调用scheduleRun进行更新。

本文讲述了effect模块的原理,通过track入手,了解到effect的结构,知道effect内部有一个deps的属性,这个属性是一个数组,用来存储监听函数的依赖。在响应式对象初始化时,getter调用,会调用track收集依赖,在对其属性进行更改、删除、增加时,会调用trigger来更新依赖,完成了数据通知和响应。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK