Vue3源码学习——响应式原理

432 阅读8分钟

最近一段时间一直在学习Vue3相关的内容,本篇是Vue3源码学习系列的第一篇,响应式原理。

样例

这里我们先来看一下Vue3中的基本语法是怎么书写的:

<template>
  <button @click="increment">
    {{ state.count }}
  </button>
</template>

<script setup>
import { reactive } from 'vue'

const state = reactive({ count: 0 })

function increment() {
  state.count++
}
</script>

这是官网的一个简单的例子,我们通过 reactive 函数定义了响应式对象,当 state.count 发生改变的时候页面展示也会即时的变化。

reactive

这里我们先看一下 vue 提供的 reactive 函数内部是什么样子的,为什么用了 reactive 就可以成为响应式的呢?

function reactive(target: object) {
  // 如果对象是只读,则直接返回,不需要进行响应式 
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}

这里我们可以看到,reactive 函数的关键则在于 createReactiveObject

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  ...
  // 如果不是对象,直接返回  开发环境下会给出报警提示
  // 如果已经是响应式对象,则直接返回
  ... 
  
  // proxyMap 中已经存入过 target,直接返回
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  
 const targetType = getTargetType(target)
  // 是某些特定类型也直接返回
  if (targetType === TargetType.INVALID) {
    return target
  }
  
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

这里对于 createReactiveObject 函数只保留了一些关键逻辑,其他内容在注释中已经说明,主要就是判断一些特定情况,在特定情况下直接返回。其中有一个 proxyMap 的容器,这个容器主要用于缓存target,如果我们已经对target代理过了,那么如果再次代理同一个target对象时,则直接从容器中返回不需要再重新进行new Proxy操作了。

具体例子可以是这样的:

var obj = {a: 1}
var x = reactive(obj)
var y = reactive(obj)  // 这里y的创建过程走的就是缓存,是从proxyMap中直接取出来的。

关于targetType的值,我们可以看看源码这方面的设定:

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
  }
}

这里就可以看出,如果传入的 targetObject 类型,对应的 TargetType 就是TargetType.COMMON,那么传入 proxy 的第二个参数 handler 就是 baseHandlers;如果是 Map 这些,则传入的 handlercollectionHandlers

我们先关注 Object 的情况,看一下传入的 baseHandlers

// 这里mutableHandlers就是createReactiveObject函数中的baseHandlers参数
const mutableHandlers: ProxyHandler<object> = {
  get,
  set,
  deleteProperty,
  has,
  ownKeys
}

这里我们重点关注get和set:

get

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

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {

    // 对key的一些判断
    ...

    // 判断是否为数组
    const targetIsArray = isArray(target)

    if (!isReadonly) {
      /*
      如果是数组,同时key是存在于arrayInstrumentations中,arrayInstrumentations是一个汇总了
      操作数组方法的对象
      */
      if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
        return Reflect.get(arrayInstrumentations, key, receiver)
      }
      
      // 如果是hasOwnProperty,则返回一个重写后的hasOwnProperty函数
      if (key === 'hasOwnProperty') {
        return hasOwnProperty
      }
    }

    const res = Reflect.get(target, key, receiver)

    // Symbol Key 不做依赖收集
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }

    if (!isReadonly) {
      // 进行依赖收集
      track(target, TrackOpTypes.GET, key)
    }

    // 如果是shallow则只追踪一层,直接返回
    if (shallow) {
      return res
    }

    if (isRef(res)) {
      // 如果访问的是数组,key是整数
      return targetIsArray && isIntegerKey(key) ? res : res.value
    }

    if (isObject(res)) {
      /*
          如果是对象,则根据readonly判断,如果readonly为false则将res转变为响应式,
          从而能够实现深层次的依赖追踪
      */ 
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

getter函数中,隐藏掉一些代码只保留下主干内容,我们可以看到这个函数中主要就是对target的类型进行了判断:

  • 如果是对象,在非 readonly 的情况下,则会递归的调用 reactive 函数,从而实现深层次的依赖追踪
  • 如果是数组,会额外判断一次是否访问了 arrayInstrumentations 中的 keyarrayInstrumentations 部分的代码如下:
const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations()

function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}
  ;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      const arr = toRaw(this) as any
      for (let i = 0, l = this.length; i < l; i++) {
        // 对数组中的每一项进行依赖收集
        track(arr, TrackOpTypes.GET, i + '')
      }
      const res = arr[key](...args)
      if (res === -1 || res === false) {
       // 如果没有查到,那么将传入的数据还原为原始数据再执行一次
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })
  ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      // 暂停追踪
      pauseTracking()
      const res = (toRaw(this) as any)[key].apply(this, args)
      // 继续追踪
      resetTracking()
      return res
    }
  })
  return instrumentations
}

这里的数组方法会分为两类:

  • 一类是includes, indexOf, lastIndexOf,这三个方法是数组的查询方法,不会对数组本身造成影响,当调用这些方法的时候,会对数组中的每一项进行依赖收集。
  • 第二类是push, pop, shift, unshift, splice,这一类方法会改变原数组,为防止出现死循环,所以在执行前暂停了依赖的追踪,在方法执行之后,恢复追踪功能。

这里提一下 proxy 的优点,在我们使用 push 等方法改变数组的时候,proxy 是可以追踪到数组的 length 的。

track:

根据前面,我们可以发现,target不管是对象,还是数组,在进行逻辑处理的时候都会用到 track 函数,我们大概根据名字也已经猜到 track 就是依赖收集(副作用收集)的函数,那接下来我们具体看一下这个函数是如何工作的:

const targetMap = new WeakMap<any, KeyToDepMap>()

function track(target: object, type: TrackOpTypes, key: unknown) {
  // shouldTrack 是否应该收集依赖, activeEffect 当前激活的effect
  if (shouldTrack && activeEffect) {
  
   // targetMap是一个存放有所有reactive的依赖容器的容器
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      // 如果不存在,就创建一个
      targetMap.set(target, (depsMap = new Map()))
    }
    
    // dep: 从depsMaps中取出某个属性key所导致的副作用函数集合,如果不存在,那就创建一个,是一个Set
    let dep = depsMap.get(key)
    if (!dep) {
      depsMap.set(key, (dep = createDep()))
    }

    const eventInfo = __DEV__
      ? { effect: activeEffect, target, type, key }
      : undefined

    trackEffects(dep, eventInfo)
  }
}

track 函数的主要逻辑,就是追踪、收集:

  • 当追踪某个对象的某个属性时,首先判断全局容器 targetMap 中是否有这个对象,如果有就取出来,如果没有就创建一个该对象的容器 depsMap,(depsMapkey 就是对象的属性,每个 key 对应的value一个收集副作用函数的容器)。
  • 然后对于具体访问的是某个属性,判断 depsMap 上是否有这个属性,如果有则取出来,如果没有就创建一个用于收集这个属性所有副作用函数的容器dep,这个dep是一个Set类型。
  • 最后就是通过trackEffects将副作用添加进dep中,完成依赖收集。

看一下trackEffects

function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  ...

  if (shouldTrack) {
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
    
    ...
  }
}

到这里,在get阶段的依赖收集逻辑基本完成,下面可以看一下set阶段的逻辑:

set

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

function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    let oldValue = (target as any)[key]
    // 只读不可修改
    if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
      return false
    }
    if (!shallow) {
     // 不是浅响应式
      if (!isShallow(value) && !isReadonly(value)) {
        oldValue = toRaw(oldValue)
        value = toRaw(value)
      }
      // ...
    } else {
      // 在浅响应式下,对象被设置为原始值
    }

    const hadKey =
     // 是数组的话,判断key是否超出长度,不是数组判断是否存在于对象上
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
        
        // 设置修改值
    const result = Reflect.set(target, key, value, receiver)
    if (target === toRaw(receiver)) {
      if (!hadKey) {
       // 如果key不存在于原数据上
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
      // 如果key存在于原数据上
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

setter函数整体的逻辑是:

  • 首先,判断属性值是否只读,是否浅响应式,如果不是浅响应式,则将原值和要修改的值还原为原始数据
  • 然后,判断属性是否存在于对象 target 上,根据存在与否,调用 trigger 函数,并传入不同的参数,去派发通知,触发执行副作用函数

trigger函数

function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }

  let deps: (Dep | undefined)[] = []
  if (type === TriggerOpTypes.CLEAR) {
    deps = [...depsMap.values()]
  } else if (key === 'length' && isArray(target)) {
    const newLength = Number(newValue)
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= newLength) {
        deps.push(dep)
      }
    })
  } else {
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }

    switch (type) {
      case TriggerOpTypes.ADD:
        if (!isArray(target)) {
          ...
          // 暂时可以先不管
        } else if (isIntegerKey(key)) {
          deps.push(depsMap.get('length'))
        }
        break
      case TriggerOpTypes.DELETE:
        if (!isArray(target)) {
          ...
          // 暂时可以先不管
        }
        break
      case TriggerOpTypes.SET:
        ...
        // 暂时可以先不管
        break
    }
  }

  const eventInfo = __DEV__
    ? { target, type, key, newValue, oldValue, oldTarget }
    : undefined

 // triggerEffects 触发deps中副作用函数的执行
  if (deps.length === 1) {
    if (deps[0]) {
      if (__DEV__) {
        triggerEffects(deps[0], eventInfo)
      } else {
        triggerEffects(deps[0])
      }
    }
  } else {
    const effects: ReactiveEffect[] = []
    for (const dep of deps) {
      if (dep) {
        effects.push(...dep)
      }
    }
    if (__DEV__) {
      triggerEffects(createDep(effects), eventInfo)
    } else {
      triggerEffects(createDep(effects))
    }
  }
}

trigger函数代码比较长,但整体在做的事情是明确的:

  • 首先,看容器 targetMap 中有没有收集过 target 相关的内容,如果没有收集过,说明还没有关于target副作用函数需要执行,直接返回
  • 然后,根据传入的 type 类型,分情况来维护 deps 这个用于放置副作用函数的数组
  • 最后,通过 triggerEffects 函数,来执行 deps 中的副作用函数。

总结

这里我们对 reactive函数 的整体逻辑做了分析,其实大体上在做的事情就是在get阶段进行副作用函数的收集,在set阶段去执行收集到的副作用函数,从而实现响应式。

我们平时对于响应式最直观的感受就是,页面和数据的双向绑定。这一功能中涉及到的数据修改,即时反映在界面的逻辑是:

  • 首先,将 template模板 编译为 AST,之后会将 AST 转为 render函数
  • 然后,会将 render函数 会作为副作用函数执行,得到vnode节点,在这个过程就会对一些字段进行访问,从而实现了在get阶段对这些字段所导致的副作用函数进行收集
  • 之后,当我们对这些字段进行修改时,也就是在set阶段,会对收集到的副作用函数effect进行依次的执行,而这其中会有上面的render函数重新运行,得到一个新的vnode树。
  • 最后,经过patch新老节点,这样,当我们数据层改变之后就能即时的反应在界面上了。

参考文章:

官网

大佬的小册