阅读 377

Vue3源码解析-响应式原理

作者:秦志英

前言

Vue3对Vue2中的响应式原理使用Proxy进行了重写,本文我们将对Vue3响应式的源码进行分析。

Vue3中提供了四种创建不同类型的响应式数据的方式,分别是:

  • reactive 返回的是一个proxy对象,如果访问返回的proxy对象的属性是一个Object类型,会继续调用reactive,进行深度递归
  • shallowReactive 返回一个proxy对象,但是只有第一层的属性是响应式
  • readonly 返回一个proxy对象,如果属性是对象可以继续调用readonly,但是属性是只读的不可修改,所以在访问阶段也不会进行依赖收集
  • shallowReadonly 返回一个proxy对象,只有第一层的属性是响应式,属性只读不可修改

由于篇幅问题这里我们只对reactive类型的响应进行分析,其他流程和这个类似,只是根据不同的参数进入不同的处理流程。

Vue2 和 Vue3的对比

Vue3响应式源码解析

响应式流程

在Vue3中我们可以通过Composition API而不是Options API去显示的创建一个响应式对象.

setup(){
    const orginData = {count: 1}
    const state = reactive(orginData) 
    return {
      state
    }
  }
复制代码

当我们使用reactive函数创建一个响应式数据的流程如下: 下面我们对其中涉及的关键函数进行解析。

源码解析

reactive

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
 // if trying to observe a readonly proxy, return the readonly version.
 // 如果传入的是只读类型的响应式对象 直接返回该响应式对象
 if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
   return target
 }
 return createReactiveObject(
   target,
   false,
   mutableHandlers,
   mutableCollectionHandlers
 )
}
复制代码

首先对传入的参数进行类型判断,如果传入的是一个只读类型的响应式对象,则直接返回该对象,否则就进入createReactiveObject函数,这个函数的主要功能就是根据传入的不同参数,创建不用类型的响应式对象。接着我们来详细分析一下createReactiveObject的创建流程:

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  if (!isObject(target)) {
    return target
  }
  // 如果已经是proxy对象则直接返回,有个例外,如果是readOnly作用于响应式 则继续
  if (
    target[ReactiveFlags.RAW] && 
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE]) 
  ) {
    return target
  }
  const proxyMap = isReadonly ? readonlyMap : reactiveMap
  // 已经有了对应的proxy映射 直接
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  const targetType = getTargetType(target)
  // 只有在白名单中的数据类型才可以被响应式
  if (targetType === TargetType.INVALID) {
    return target
  }
  // 通过Proxy API劫持target对象,把它变成响应式
  const proxy = new Proxy(
    target,
    // Map Set WeakMap WeakSet用collectionhandlers代理 Object Array用baseHandlers代理
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  // 存储一个原始类型和proxy数据类型的映射 
  proxyMap.set(target, proxy)
  return proxy
}
复制代码
  • 首先判断是否是对象类型,如果不是就直接返回,否则继续
  • 判断如果目标对象已经是proxy了直接返回,否则继续
  • 然后获取当前的目标对象的ProxyMap,如果存在就返回对应的proxy(在函数最后,我们会使用一个WeakMap类型的对象存储原始数据类型和proxy数据类型的映射) 否则继续
  • 判断目标对象是否在可响应式数据类型的白名单中,如果不在直接返回目标对象,否则继续。
function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}
复制代码

在这个函数前面有对目标类型的限制判断函数,如果响应式的数据类型是以上六种则在可响应式处理的白名单中,否则目标对象的类型是无效的。

  • 最后根据不同的目标对象类型传入不同的处理函数,如果目标对象是ObjectArray数据类型则TargetType=COMMON对应的处理函数就是baseHandlers,如果目标对象是MapSetWeakMapWeakSet这四种数据类型则对应的处理函数是collectionHandlers

用一个流程图表示: 以上是对创建响应式函数的分析,接下来我们将分析针对Object的数据类型使用baseHandlers的处理流程。

对应的源码文件:  vue-next/packages/reactivity/src/reactive.ts

baseHandlers

const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
复制代码

前面的流程中我们分析到如果目标对象是ObjectArray会调用baseHandlers,我们是通过reactive函数向createReactiveObject中传入参数,实际上我们执行的是mutableHandlers

mutableHandlers

这个方法实际上就是对目标对象的一些访问、删除、查询、设置的操作的劫持,这里我们着重分析一些set和get函数,因为这两个函数中涉及到依赖收集和派发更新的操作。分析的时候我们会删除部分代码,只分析主流程。

const get = /*#__PURE__*/ createGetter()
const set = /*#__PURE__*/ createSetter()
export const mutableHandlers: ProxyHandler<object> = {
  get, // 对数据的读取属性进行拦截 包含target.语法和target[]
  set, // 对数据的存入属性进行拦截
  deleteProperty, // delete操作符进行拦截 可以监听到属性的删除操作
  has, // 对对象的in操作符进行属性拦截
  ownKeys // 访问对象属性名的时候会触发ownKeys函数
}
复制代码

createGetter

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // 求值 
    const res = Reflect.get(target, key, receiver)

    if (!isReadonly) {
      // 依赖收集
      track(target, TrackOpTypes.GET, key)
    }
    // 递归调用响应式
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }
    // 返回结果
    return res
  }
}
复制代码

当我们访问对象的属性的时候会触发get函数,这个函数中的主要步骤有三个,首先会使用Reflect.get进行求值, 然后判断是否是只读的,如果不是就调用track进行依赖收集,然后对求值的结果进行判断,如果是对象则递归调用reactive或者readonly对结果继续进行响应式处理,最后将获取的结果返回。

注意:这里和Vue2响应式处理的方式有所不同,这也是Vue3响应式在初始化的时候性能优化的一个点。

  • Vue2在实现响应式的时候会在初始化阶段判断对象的属性是否是Object类型,如果是的话就会递归的调用Observer将子对象也变成响应式。
  • Vue3的实现流程则是在初始化阶段的时候只对第一层的属性进行响应式,当返回proxy的属性被访且是对象的话再进行递归响应式,Proxy劫持的是对象本身,并不能劫持子对象的变化,正是利用这种特性可以延时定义子对象响应式的实现,在初始化的时候性能也会得到提升。

createSetter

function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    // 1.先获取oldValue
    const oldValue = (target as any)[key]
    // 2.设置新值
    const result = Reflect.set(target, key, value, receiver)
    // don't trigger if target is something up in the prototype chain of original
    // 派发更新
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}
复制代码

当我们更新响应式对象的属性的时候会触发set函数,set函数内部的主要步骤也是三个,首先获取这个属性的oldValue,然后通过Reflect.set对属性进行赋值操作,最后调用trigger进行派发更新,在派发更新阶段如果是新增属性则trigger的type是add,如果value!==oldValuetrigger的type是set

源代码文件:vue-next/packages/reactivity/src/baseHandlers.ts

track - 依赖收集

在进行分析依赖收集的流程之前我们要先弄明白一个概念targetMap, 它是一个WeakMap的数据结构,主要用于存放用来存储原始数据->key->deps这样的一个映射关系,比如:

const orginData = {count:1,number:0}
    const state = reactive(orginData) 
    const ef1 = effect(() => {
      console.log('ef1:',state.count)
    })
    const ef2 = effect(() => {
      console.log('ef2:',state.number)
    })
    const ef3 = effect(() => {
      console.log('ef3:', state.count)
    })
    state.number = 2
复制代码

首先orginData作为targetMap的键存储,value是一个depsMap它是一个Map数据结构,用于存放对这个原始数据的所有依赖,depsMap中的key是原始数据对应的key,value是deps它是一个Set数据结构,用于存放所有对这个Key的依赖。我们用一张图来表示上面的依赖映射关系: 理清完上述对应关系之后我们来分析一下track内部的实现机制:

export function track(target: object, type: TrackOpTypes, key: unknown) {
  // 获取当前target对象对应depsMap
  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()))
  }
  if (!dep.has(activeEffect)) {
    // 收集当前的effect作为依赖
    dep.add(activeEffect)
    // 当前的effect收集dep集合作为依赖
    activeEffect.deps.push(dep)
  }
}
复制代码

依赖收集的流程很简单,先获取当前target对应的依赖映射,如果没有就以当前的target为键,Map数据结构为值设置一个, 然后根据depsMap获取当前key所对应的依赖集合,如果没有就以当前的key为键,Set数据结构为值设置一个,然后判断当前正在激活的副作用函数在不在当前key对应的依赖集合中,如果不在,就将当前激活的副作用函数(activeEffect)push到当前key对应的依赖集合中,弄清楚依赖映射表的对应关系之后,在分析依赖收集的流程就简单很多。

trigger - 派发更新

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  // 获取当前target的依赖映射表
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // never been tracked
    return
  }
  // 声明一个集合和方法,用于添加当前key对应的依赖集合
  const effects = new Set<ReactiveEffect>()
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => effects.add(effect))
    }
  }
 // 根据不同的类型选择使用不同的方式将当前key的依赖添加到effects
 if (type === TriggerOpTypes.CLEAR) {
    ...判断逻辑省略
  }
  // 声明一个调度方法
  const run = (effect: ReactiveEffect) => {
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }
  // 循环遍历 按照一定的调度方式运行对应的依赖
  effects.forEach(run)
}
复制代码

当响应是对象的属性值发生改变的话,就会触发set函数,在对属性设置新值的时候会调用trigger进行派发更新,派发更新的逻辑也很简单:

  • 首先获取当前target对应的依赖映射表,如果没有,说明这个target没有依赖,直接返回,否则进行下一步
  • 然后声明一个集合和一个向集合中添加元素的方法
  • 根据不同类型选择使用不同的方式向effects中添加当前key对应的依赖
  • 声明一个调度方式,根据我们传入effect函数中不同的参数选择使用不同的调度方式
  • 将当前key的所有依赖集合循环遍历,按照对应的调度方式运行

effect - 副作用函数

上述的示例代码我们打开控制台可以发现: 当我们向effect中传递一个原始的函数的时候,会立即执行一次,如果函数中有对响应式数据的访问操作的话,此时会将当前的effect作为依赖收集到访问属性的依赖集合中。那么effect内部的运行机制究竟如何呢?下面我们来具体分析一下。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}
复制代码

通过上述源码我们可以看到,effect在响应式中起到了桥梁的作用, 主要功能就是对原始函数进行包装,将它变成响应式的副作用函数。主要接受两个参数,第一个参数就是我们上面传递的函数,第二个参数是配置参数,用于控制第一个参数的调度行为。首先判断当前的函数是否已经是副作用函数,如果是就将获取当前响应式函数的原始函数。然后将参数传递给createReactiveEffect来创建响应式副作用函数。最后执行返回的响应式副作用函数。下面我们来看一下createReactiveEffect函数是如何创建响应式副作用函数的:

const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined // 用于保存当前激活的副作用函数
function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    if (!effectStack.includes(effect)) {
      cleanup(effect)
      try {
        // 开启全局shouldtrack,允许依赖收集
        enableTracking()
        // 将当前的effect压栈
        effectStack.push(effect)
        activeEffect = effect
        // 执行原始的函数
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
  effect.id = uid++
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}
复制代码
  • 首先我们在最开始声明一个effectStack栈用于存放被激活的effect和activeEffect用于存放当前被激活的effect。
  • 然后判断当前的effect是否在栈内,如果不在就执行cleanup清空当前响应式函数的所有依赖。
  • 接着将当前的effect压入栈内,将当前的effect赋值给activeEffect,并将传入的原始函数执行一遍, 执行完毕之后将当前的effect从effectStack栈中弹出,并将activeEffect的值改为栈中的最后一个。
  • 最后再给effect函数添加一些属性raw用于保存当前副作用函数的原始函数,_isEffect是否是副作用函数,deps副作用函数中订阅的属性。

为什么要用一个栈保存激活的副作用函数呢?直接使用一个变量保存被当前的effect不是更好吗? 我们先看一下下面的示例:

const parent = effect(() => {
     const child=  effect(() => {
        console.log('child:', state.count)
      })
      console.log('parent:', state.number)
    })
    state.count = 2
复制代码

当一个副作用函数嵌套一个副作用函数的时候,当我们执行到内部的副作用函数的时候,如果仅仅是使用一个变量保存当前的激活副作用函数,当内部的副作用函数执行完毕之后,当我们执行下面的代码的时候此时的activeEffect指向就不正确了,所以我们这里使用一个栈的结构来保存激活的副作用函数, 因为函数的执行也是一个出栈入栈的顺序,因此我们设计一个栈来保存激活的副作用函数,当我们执行这个副作用函数内部嵌套的副作用函数时候,此时的activeEffect就是里面嵌套的副作用函数,内部的副作用函数执行完毕之后,从effectStack栈中将内部的副作用函数弹出,此时的activeEffect就指向了外部的副作用函数,这样指向就是正确的。

对应的源码文件: vue-next/packages/reactivity/src/effect.ts

总结

至此对于一个Object类型的响应式数据的创建流程已经分析完毕,在分析源码的过程中,我们可以发现:

  • 采用了函数式编程,分析每个函数功能的时候实现流程非常清晰
  • 对子对象的采用了延时递归响应,在初始化阶段提高了性能
  • 使用WeakMap数据结构建立原始数据和依赖集合之间的一个弱映射,有利于垃圾回收
文章分类
前端
文章标签