浅谈Vue3的响应式原理与watch的实现(1)

前言

vue3从发布beta到现在已经有一段时间了,本次大版本的改动有不少地方,不管是使用了TypeScript开发或者是编译方面和composition api方面的优化来说,改动都是挺多的。这次我们对vue3的原理解析主要针对响应式原理watch的实现方式,代码是基于"3.1.0-beta.7"版本,这是我查看源码时最新的版本,也可能现在已经又更新了几个版本,但是小版本的更新不会影响整体实现方式,不影响学习。

Vue2.0+的版本源码相信大部分同学都看过,响应式对象的实现逻辑是通过Object.defineproperty()的方式,来达到劫持对象的set、get等动作,最终配合Watcher类与Dep类实现依赖的收集与触发。因为这篇文章是针对Vue3来做分析,Vue2的相关源码实现,感兴趣的可以自行查看。

下面我会用自己的语言对源码的实现逻辑进行描述,有小伙伴觉得不清楚的地方可以留言讨论,共同进步。

响应式对象

Vue与React有一大区别是它的数据都是响应式的,版本升级至今这个特点一直都在,因为这个特点我们在开发业务时只需要将精力放在数据层上面即可,DOM视图自然会根据数据的变化发生重新渲染。

reactive实现原理

Vue3中初始化一个响应式对象,不再像Vue2中那样的黑盒方式来对data、props、computed中的数据进行初始化。他需要我们手动引入reactive方法对我们需要响应式的数据进行初始化。

使用方式:

<template>
  <div>{{state.msg}}</div>
  <button @click="toggle">切换</button>
</template>

<script>
  import { reactive } from 'vue'

  export default {
    name: 'main',
    setup() {
      const state = reactive({
        msg: 'Hello World!'
      })

      const toggle = function () {
        state.msg = state.msg === 'Hello World!' ? 'Hello reactive!' : 'Hello World!'
      }

      return {
        state,
        toggle
      }
    }
  }
</script>
复制代码

我们看到上面的例子中,我们手动将需要转换为响应式的对象作为参数传递给了reactive函数进行初始化成响应式数据,当我们改变数据时页面也发生了改变。一起看下reactive函数是如何实现的。

reactive api

@reactivity/src/reactive.ts

export function reactive(target: object) {
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {

  // 是否是对象等类型,不是直接返回
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // 初始化是响应式对象直接返回本身
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // 缓存优化,初始化过的直接返回proxy对象
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // 判断是无效对象直接返回
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  
  // 按照我们例子来看,传入的是对象类型,所以Proxy第二个参数是baseHandlers。
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}
复制代码

我们看到reactive函数执行时会对传入的对象做一系列判断,并且使用了WeakMap缓存了初始化对象进行优化,最终使用new Proxy来劫持target对象。相比于vue2中Object.defineProperty的方式,Proxy不需要提前知道对象的key,而且可以拦截到任何对对象的修改。

mutableHandlers

@reactivity/src/baseHandlers.ts

export const mutableHandlers: ProxyHandler<object> = {
  get,
  set,
  deleteProperty,
  has,
  ownKeys
}
复制代码

mutableHandlers对象是Proxy初始化时传递的参数,内部对五个动作分别进行了劫持,涵盖能对数据做的所有操作。当然这几个动作在不同的对象时会有不同的实现逻辑,这里我们只get和set分析。看完内部实现可以清楚的了解到get和set触发时做了什么事情,是怎样实现响应式。

getter 依赖收集

@reactivity/src/baseHandlers.ts

const get = /*#__PURE__*/ createGetter()

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
  // 对__v_isReactive属性的代理
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      // 对__v_isReadonly属性的代理
      return isReadonly
    } else if (
      key === ReactiveFlags.RAW &&
      receiver ===
        (isReadonly
          ? shallow
            ? shallowReadonlyMap
            : readonlyMap
          : shallow
            ? shallowReactiveMap
            : reactiveMap
        ).get(target)
    ) {
      // 对__v_raw属性的代理
      return target
    }

    const targetIsArray = isArray(target)
    
    // arrayInstrumentations 是对 ['push', 'pop', 'shift', 'unshift', 'splice']和['includes', 'indexOf', 'lastIndexOf']这些数组上的函数进行重写。
    
    // 如果对象是数组,并且获取的key是这些重写的方法,那么就使用Reflect.get返回调用函数后返回的值,保证get的默认行为。然后会根据调用函数时传递的新值重新添加依赖,保证数组中的新值也是响应式对象。
    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }

    const res = Reflect.get(target, key, receiver)
    // 内部的Symbol key属性则不用收集依赖
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }
    // 不是只读对象的话对当前对象的key收集对应依赖。
    //按照主线流程,此处的依赖是组件在初始化时执行setupRenderEffect时的副作用函数(代码位置:@/runtime-core/src/renderer.ts)。此处理解为函数执行后有一个activeEffect的全局变量存储了当前最新的副作用函数,track内部对当前这个函数做了保存,下面可查看track的详细实现。
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    if (isObject(res)) {
      // 根据key拿到的属性如果是对象的话,执行reactive将返回的对象也初始化为响应式对象。
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}
复制代码

整个流程还是比较清晰的,我们对一个对象进行reactive响应式操作后,其get函数内部会对一些特殊属性进行代理,返回规定的属性值;当target是一个数组时,对数组所有方法进行了代理,当函数执行后产生新的值时会将其初始化为响应式对象;

如果最后返回值也是个对象的话,会对该对象再次调用reactive初始化为响应式对象。这操作跟vue2.0版本中还不太一样,在vue2中内部无法得知运行时会访问到具体哪个属性,所以在初始化的过程中会递归初始化整个对象,为每个属性都使用Object.defineproperty进行劫持,这样遇到data、computed比较复杂的组件时初始化过程就会比较耗时和增大性能的开销。但是在vue3中因为使用了proxy进行代理,proxy会对整个对象进行代理,只有当组件中获取某个对象时才会对该对象进行初始化响应式,而不是无脑递归。这样很大程度提升了组件的初始化速度,提高了性能

接下里看下getter中的核心函数track,该函数在getter过程中收集依赖,从而能在set触发时进行执行。

track

@reactivity/src/effect.ts

export function track(target: object, type: TrackOpTypes, key: unknown) {
  // 不需要收集或者activeEffect为空则直接返回,不收集依赖
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 以target对象为key,初始化一个Map对象为value
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    // 以key设置为depsMap的key,存储一个不重复的数据结构Set。
    depsMap.set(key, (dep = new Set()))
  }
  if (!dep.has(activeEffect)) {
    // 将当前对象target的属性key对象的依赖activeEffect存储到dep中。
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}
复制代码

查看track代码,通篇逻辑是根据当前传入的对象对应的属性存储作用域中的activeEffect(此处按照主流程,activeEffect副作用函数是重新渲染组件的方法)。以target作为targetMap的key,value是depsMap结构也是个Map类型,在depsMap中以属性名作为key往Set中存储依赖函数,这样当我们要触发依赖时可以根据对象本身以及改变的属性key就可以找到其依赖的函数来做指定操作。

整个getter执行逻辑基本分析结束,其核心逻辑就是track依赖收集,将当前的activeEffect存储到相关结构中。

setter 派发通知

@reactivity/src/baseHandlers.ts

const set = /*#__PURE__*/ createSetter()

function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {

    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
    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) {
        // 如果设置的key是新的调用trigger的ADD
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        // 如果key已经存在则代表是做修改,判断原来的值和要设置的值是否一致,不一致则调用trigger的SET。判断值是否改变可以过滤掉一些例如数组改变后leegth也改变导致的无效触发。
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}
复制代码

查看setter的代码逻辑,主线流程比较简单,主要是对修改的响应式对象属性进行一个派发通知,一起看下trigger中做了什么。

trigger

trigger函数由setter中触发,去通知对应属性在getter时收集的依赖进行执行,达到响应式的效果。

@reactivity/src/effect.ts

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {

  const depsMap = targetMap.get(target)
  // depsMap是在track时以target作为key存储的依赖,如果没有则直接返回。
  if (!depsMap) {
    // never been tracked
    return
  }

  // add 函数是整段逻辑的核心,将Set结构的依赖集合以参数形式传入后,将符合条件的副作用函数都添加进effects中。
  const effects = new Set<ReactiveEffect>()
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }

  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 {
    // schedule runs for SET | ADD | DELETE
    // 查看当前的key !== undefined,将当前key对应依赖的Set数据结构从depsMap获取出来出来。
    if (key !== void 0) {
      add(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {
      case TriggerOpTypes.ADD:
        if (!isArray(target)) {
          add(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            add(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          // new index added to array -> length changes
          add(depsMap.get('length'))
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          add(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }

  const run = (effect: ReactiveEffect) => {
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }
  // effects是该key对应的依赖集合,这里对集合进行遍历执行,如果有scheduler调度函数则执行调度函数,否则直接执行副作用函数。
  effects.forEach(run)
}
复制代码

查看以上代码,trigger主要是对某个对象中的指定属性派发通知,执行在getter阶段收集的依赖。

此处我们分析的流程是以组件初始化的场景为主,在整个组件初始化过程中会执行mountComponent执行实例化组件、初始化等流程,其中包括了setupRenderEffect函数生成全局的activeEffect副作用函数,该函数内部逻辑是patch函数将组件模板转换为vnode类型后按照数据结构和类型生成dom元素添加到页面上,在挂载的过程中,会对所有用到的响应式对象获取从而触发其getter,getter内部逻辑会收集当前的activeEffect。在本场景中activeEffect对应的是setupRenderEffect也就是触发组件重新渲染的函数,当我们在method中对响应式数据进行改变时会执行到setter中的trigger逻辑,对指定的响应式属性派发通知从而执行回调,本场景的回调是setupRenderEffect函数会让组件重新渲染。当然在Vue3中对其渲染逻辑也进行了优化,并不是修改某个值会导致整个dom都进行渲染的可怕操作,内部复杂的diff处理感兴趣可以自己看看。

响应式流程图

响应式流程.png

effect 实现原理

从上面的响应式原理来看,我们从reactive生成一个响应式对象,内部对getter和setter都进行了劫持操作,effect在执行之前会将当前函数置位内部栈最上层位置,同时将activeEffect标记为当前函数,再由getter与setter的内部逻辑进行依赖收集等操作

个人感觉vue3的effect+track+trigger模式要比vue2的Watcher+Dep模式要更加容易理解,代码逻辑上也比较友好。effect被独立封装后可以给computed、watch、watchEffect等逻辑进行使用,effect函数内部有清空依赖、暂停收集、重置收集状态、派发通知等自成体系,所以也可单独export出来作为工具函数进行调用。

接下来我们以初始化组件的主流程来看下effect的内部逻辑

@runtime-core/src/renderer.ts

const setupRenderEffect = () => {
    effect(function componentEffect() {
        // do sth...
        patch() // 渲染组件
    })
}
复制代码

组件在初始化过程时会创建一个componentEffect副作用函数,内部一套逻辑会执行渲染或更新组件。

@reactivity/src/effect.ts

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的代码,接收一个函数,和一个配置对象作为参数。如果传入的对象也是一个初始化过的effect的话,则取出它内部的原始函数作为参数传入。如果options中有lazy:true的配置的话则延迟执行effect函数,否则立刻执行。lazy一般在computed的时候有使用。接下来我们看下真正生成effect的createReactiveEffect函数。

@reactivity/src/effect.ts

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    // 未正在执行的effect首先会执行一次fn()
    if (!effect.active) { 
      // 本流程中fn就是componentEffect()函数
      return fn()
    }
    // effectStack栈是正在执行的副作用函数栈
    if (!effectStack.includes(effect)) {
      // 将上次getter时收集的依赖全部清空  activeEffect.deps.push(dep)
      cleanup(effect)
      try {
        // 打开收集依赖的开关
        enableTracking()
        effectStack.push(effect)
        // 将当前effect设置为activeEffect,方便收集
        activeEffect = effect
        // 执行函数并返回函数的结果。
        return fn()
      } finally {
        // 执行结束后将effect从栈中弹出
        effectStack.pop()
        resetTracking()
        // 取栈中最上层的effect作为activeEffect,因为组件渲染顺序由父到子,当最底层子组件渲染完毕后依次从栈中弹出,保证每个组件中的依赖正确。
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
  effect.id = uid++
  effect.allowRecurse = !!options.allowRecurse
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}
复制代码

查看上面effect的代码,逻辑一目了然还是比较简单的,内部最终返回一个effect函数,该函数初始化了一些状态属性,函数内部判断如果是首次执行则会执行fn()函数,并且设置acctiveEffect = effect。这里执行fn()函数就是组件渲染函数,渲染函数会获取所有用到的响应式数据,会触发对应属性的getter逻辑,从而在setter时能够触发组件渲染逻辑,至此为止整个组件响应式渲染逻辑流程分析结束。

effect作为响应式实现的重要组成部分,跟vue2的逻辑有很大区别,熟悉effect后对我们查看watch、computed的实现都有帮助。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享