Callback
首发于Callback

vue3响应式系统源码解析-Reactive篇

前言

为了更好的做解释,我会调整源码中的接口、类型、函数等声明顺序,并会增加一些注释方便阅读

上一章中,我们介绍了ref,如果仔细看过,想必对ref应该已经了如指掌了。如果还没有,或着忘记了....可以先回顾一下上篇文章。

阅读本篇文章需要的前置知识有:

  1. Proxy
  2. WeakMap
  3. Reflect

Reactive

reactive这个文件代码其实不多,100 来行,很多逻辑其实是在handlerseffect中。我们先看这个文件的引入:

外部引用

import {
  isObject, // 判断是否是对象
  toTypeString // 获取数据的类型名称
} from '@vue/shared'
// 此处的handles最终会传递给Proxy(target, handle)的第二个参数
import {
  mutableHandlers, // 可变数据代理处理
  readonlyHandlers // 只读(不可变)数据代理处理
} from './baseHandlers'

// collections 指 Set, Map, WeakMap, WeakSet
import {
  mutableCollectionHandlers, // 可变集合数据代理处理
  readonlyCollectionHandlers // 只读集合数据代理处理
} from './collectionHandlers'

// 上篇文章中说了半天的泛型类型
import { UnwrapRef } from './ref'
// 看过单测篇的话,应该知道这个是被effect执行后返回的监听函数的类型
import { ReactiveEffect } from './effect'

所以不用怕,很多只是引了简单的工具方法跟类型,真正跟外部函数有关联的就是几个handlers

类型与常量

再来看类型的声明跟变量的声明,先看注释很多的targetMap

// The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class
// which maintains a Set of subscribers, but we simply store them as
// raw Sets to reduce memory overhead.
export type Dep = Set<ReactiveEffect>
export type KeyToDepMap = Map<string | symbol, Dep>
// 翻译自上述英文:利用WeakMap是为了更好的减少内存开销。
export const targetMap = new WeakMap<any, KeyToDepMap>()

traget的意思就是Proxy(target, handle)函数的第一个入参,也就是我们想转成响应式数据的原始数据。但这个KeyToDepMap其实看不明白具体是怎么样的映射。先放着,等到我们真正使用它时,再来看。

继续往下看,是一堆常量的声明。

// raw这个单词在ref篇我们见过,它在这个库的含义是,原始数据
// WeakMaps that store {raw <-> observed} pairs.
const rawToReactive = new WeakMap<any, any>()
const reactiveToRaw = new WeakMap<any, any>()
const rawToReadonly = new WeakMap<any, any>()
const readonlyToRaw = new WeakMap<any, any>()

// WeakSets for values that are marked readonly or non-reactive during
// observable creation.
const readonlyValues = new WeakSet<any>()
const nonReactiveValues = new WeakSet<any>()

// 集合类型
const collectionTypes = new Set<Function>([Set, Map, WeakMap, WeakSet])
// 用于正则判断是否符合可观察数据,object + array + collectionTypes
const observableValueRE = /^\[object (?:Object|Array|Map|Set|WeakMap|WeakSet)\]$/

如果读过单测篇(reactive 的第 8、9、10 个单测),可能会记得之前说过,内部需要两个WeakMap来实现原始数据跟响应数据的双向映射。明显的rawToReactivereactiveToRaw就是这两个WeakMaprawToReadonlyreadonlyToRaw顾名思义的就是映射原始数据跟只读的响应数据的两个WeakMap

readonlyValuesnonReactiveValues根据注释以及之前单测篇的记忆,可能是跟markNonReactivemarkReadonly(这个单测篇没讲到)有关。猜测是用来存储用这两个 api 构建的数据,具体也可以后面再看。

collectionTypesobservableValueRE看注释即可。

工具函数

在真正看reactive之前,我们把本文件内部的一些工具方案先过一遍,这样看源码时就不会东跳西跳比较乱。这部分比较简单,简单瞄两眼就好了。

// 数据是否可观察
const canObserve = (value: any): boolean => {
  return (
    // 整个vue3库都没搜到_isVue的逻辑,猜测是vue组件,不影响本库阅读
    !value._isVue &&
    // 虚拟dom的节点不可观察
    !value._isVNode &&
    // 属于上述常量中声明的可观察类型
    observableValueRE.test(toTypeString(value)) &&
    // 该集合中存储的数据不可观察
    !nonReactiveValues.has(value)
  )
}

// 如果reactiveToRaw或readonlyToRaw中存在该数据了,说明就是响应式数据
export function isReactive(value: any): boolean {
  return reactiveToRaw.has(value) || readonlyToRaw.has(value)
}

// 判断是否是只读的响应式数据
export function isReadonly(value: any): boolean {
  return readonlyToRaw.has(value)
}

// 将响应式数据转为原始数据,如果不是响应数据,则返回源数据
export function toRaw<T>(observed: T): T {
  return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed
}

// 传递数据,将其添加到只读数据集合中
// 注意readonlyValues是个WeakSet,利用set的元素唯一性,可以避免重复添加
export function markReadonly<T>(value: T): T {
  readonlyValues.add(value)
  return value
}

// 传递数据,将其添加至不可响应数据集合中
export function markNonReactive<T>(value: T): T {
  nonReactiveValues.add(value)
  return value
}

核心实现

上述的代码都是佐料,下面看本文件的核心代码,首先看reactivereadonly函数

// 函数类型声明,接受一个对象,返回不会深度嵌套的Ref数据
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.
  // 如果传递的是一个只读响应式数据,则直接返回,这里其实可以直接用isReadonly
  if (readonlyToRaw.has(target)) {
    return target
  }
  // target is explicitly marked as readonly by user
  // 如果是被用户标记的只读数据,那通过readonly函数去封装
  if (readonlyValues.has(target)) {
    return readonly(target)
  }

  // 到这一步的target,可以保证为非只读数据

  // 通过该方法,创建响应式对象数据
  return createReactiveObject(
    target, // 原始数据
    rawToReactive, // 原始数据 -> 响应式数据映射
    reactiveToRaw, // 响应式数据 -> 原始数据映射
    mutableHandlers, // 可变数据的代理劫持方法
    mutableCollectionHandlers // 可变集合数据的代理劫持方法
  )
}

// 函数声明+实现,接受一个对象,返回一个只读的响应式数据。
export function readonly<T extends object>(
  target: T
): Readonly<UnwrapNestedRefs<T>> {
  // value is a mutable observable, retrieve its original and return
  // a readonly version.
  // 如果本身是响应式数据,获取其原始数据,并将target入参赋值为原始数据
  if (reactiveToRaw.has(target)) {
    target = reactiveToRaw.get(target)
  }
  // 创建响应式数据
  return createReactiveObject(
    target,
    rawToReadonly,
    readonlyToRaw,
    readonlyHandlers,
    readonlyCollectionHandlers
  )
}

两个方法代码其实很简单,主要逻辑都封装到了createReactiveObject,两个方法的主要作用是:

  1. 透传给createReactiveObject相应地的代理数据与响应式数据的双向映射 map。
  2. reactive会做readonly的相关校验,反之readonly方法也是。

下面继续看:

function createReactiveObject(
  target: any,
  toProxy: WeakMap<any, any>,
  toRaw: WeakMap<any, any>,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  // 不是一个对象,直接返回原始数据,在开发环境下会打警告
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // 通过原始数据 -> 响应数据的映射,获取响应数据
  let observed = toProxy.get(target)
  // target already has corresponding Proxy
  // 如果原始数据已被观察(劫持)过,则直接返回观察后的响应数据
  if (observed !== void 0) {
    return observed
  }
  // target is already a Proxy
  // 如果原始数据本身就是个响应数据了,直接返回自身
  if (toRaw.has(target)) {
    return target
  }
  // only a whitelist of value types can be observed.
  // 如果是不可观察的对象,则直接返回原对象
  if (!canObserve(target)) {
    return target
  }
  // 集合数据与(对象/数组) 两种数据的代理处理方式不同。
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  // 声明一个代理对象,也即是响应式数据
  observed = new Proxy(target, handlers)
  // 设置好原始数据与响应式数据的双向映射
  toProxy.set(target, observed)
  toRaw.set(observed, target)

  // 在这里用到了targetMap,但是它的value值存放什么我们依旧不知道
  if (!targetMap.has(target)) {
    targetMap.set(target, new Map())
  }
  return observed
}

我们可以看到一些细节:

  1. 如果传递的是非对象,只是开发环境报警告,并不会导致异常。这是因为生产环境极其复杂,由于 js 是一门动态语言,如果直接报错,必定直接影响各类线上应用。在这只是返回了原始数据,失去了响应性,但不会导致真实页面异常。
  2. 这个方法基本没有 TS 类型了。

reactive文件其实非常通俗易懂,看完以后,我们心中只有 2 个问题:

  1. baseHandlerscollectionHandlers的具体实现以及为什么要区分?
  2. targetMap到底是啥?

当然我们知道handlers肯定是做依赖收集跟响应触发的。那我们就先看着两个文件。

baseHandles

打开此文件,同样先看外部引用:

// 这些我们已经了解了
import { reactive, readonly, toRaw } from './reactive'
import { isRef } from './ref'
// 这些就是些工具方法,hasOwn 意为对象是否拥有某数据
import { isObject, hasOwn, isSymbol } from '@vue/shared'
// 这里定义了操作数据的行为枚举
import { OperationTypes } from './operations'
// LOCKED:global immutability lock
// 一个全局用来判断是否数据是不可变的开关
import { LOCKED } from './lock'
// 收集依赖跟触发监听函数的两个方法
import { track, trigger } from './effect'

只有tracktrigger的内部实现我们不知道,其他的要么已经了解了,要么点开看看一眼就明白。

然后是一个代表 JS 内部语言行为的描述符的集合,不明白的可以看相应MDN。具体怎么使用可以后面再看。

const builtInSymbols = new Set(
  Object.getOwnPropertyNames(Symbol)
    .map(key => Symbol[key])
    .filter(key => typeof key === 'symbol')
)

然后会发现下面就百来行代码,我们找到reactive中引用的mutableHandlersreadonlyHandlers。我们先看简单的mutableHandlers

export const mutableHandlers: ProxyHandler<any> = {
  get: createGetter(false),
  set,
  deleteProperty,
  has,
  ownKeys
}

这是一个ProxyHandle,关于Proxy如果忘记了,记得再看一遍MDN

然后终于到了整个响应式系统最关键的地方了,这五个trapsget,set,deleteProperty,has,ownKeys。当然Proxy能实现的trap并不仅是这五个。其中definePropertygetOwnPropertyDescriptor两个trap不涉及响应式,不需要劫持。还有一个enumerate已经被废弃。enumerate原本会劫持for-in的操作的,那你会想,那这个废弃了,我们的for-in怎么办?放心,它还是走到ownKeys这个trap,进而触发我们的监听函数的。

说远了,回到代码中,我们从负责收集依赖的get看。这个trap是通过createGetter函数生成,那我们来看看它。

get

createGetter接受一个入参:isReadonly。那自然在readonlyHandlers中就是传true

// 入参只有一个是否只读
function createGetter(isReadonly: boolean) {
  // 关于proxy的get,请阅读:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
  // receiver即是被创建出来的代理对象
  return function get(target: any, key: string | symbol, receiver: any) {
    // 如果还不了解Reflect,建议先阅读它的文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect
    // 获取原始数据的相应值
    const res = Reflect.get(target, key, receiver)
    // 如果是js的内置方法,不做依赖收集
    if (isSymbol(key) && builtInSymbols.has(key)) {
      return res
    }
    // 如果是Ref类型数据,其获取value值的过程已经注入了收集依赖的逻辑,直接返回其value值即可。
    if (isRef(res)) {
      return res.value
    }
    // 收集依赖
    track(target, OperationTypes.GET, key)
    // 通过get获取的值不是对象的话,则直接返回即可
    // 否则,根据isReadyonly返回响应数据
    return isObject(res)
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }

大致看下来会发现,get的方法的每个表达式其实都比较简单,不过却好像都有点儿懵。

问题 1: 为什么要通过Reflect, 而不是直接target[key]?

确实,target[key]好像就能实现效果了,为什么要用Reflect,还要传个receiver呢?原因在于原始数据的get并没有大家想的这么简单,比如这种情况:

const targetObj = {
  get a() {
    return this
  }
}
const proxyObj = reactive(targetObj)

这个时候,proxyObj.a在你想象中应该是proxyObj还是targetObj呢?我觉得合理来说应该是proxyObj。但a又不是一个方法,没法直接call/apply。要实现也可以,比较绕,约等于实现了Reflect的 polyfill。所以感谢 ES6,利用Reflect,可方便的把现有操作行为原模原样地反射到目标对象上,又保证真实的作用域(通过第三个参数receiver)。这个receiver即是生成的代理对象,在上述例子中即是proxyObj

问题 2: 为什么内置方法不需要收集依赖?

如果一个监听函数是这样的:

const origin = {
  a() {}
}
const observed = reactive(origin)
effect(() => {
  console.log(observed.a.toString())
})

很明显,当origin.a 变化时,observed.a.toString()也是应该会变的,那为什么不用监听了呢?很简单,因为已经走到了observed.a.toString()已经走了一次get的 trap,没必要重复收集依赖。故而类似的内置方法,直接 return。

问题 3: 为什么属性值为对象时,需要再用reactive|readonly执行?

注释中写了:

need to lazy access readonly and reactive here to avoid circular dependency

翻译成普通话是,需要延迟地使用reactive|readonly来避免循环依赖。这话需要品,细细品,品了一会儿以后终于品懂了。

因为由于Proxy这玩意儿吧,它的trap其实只能劫持对象的第一层访问与更新。如果是嵌套对象,其实是劫持不了的。那我们就有了两种方法:

方法一:当通过reactive|readonly转化原始对象时,一层一层的递归解套,如果是对象,就再用reactive执行、然后走ProxyHandle。以后访问这些嵌套属性时,自然也会走到 trap。但这样有个大问题,如果对象是循环引用的呢?那必然是要有个逻辑判断,如果发现属性值是自身则不递归了。那如果是半路循环引用的呢?比如这样:

const a = {
  b: {
    c: a
  }
}

const A = {
  B: {
    C: a
  }
}

想想都头大吧。

方法二:也即是源码中的方法,转化原始对象时,不递归。后续走到get的 trap 时,如果发现属性值是个对象,再继续转化、劫持。也就是注释中所讲到的lazy。利用这个办法,自然就可以避免循环引用了。另外还有个显而易见的好处是,可以优化性能。

除了这个三个问题外,还有一个小细节:

if (isRef(res)) {
  return res.value
}

如果是Ref类型的数据,则直接返回 value 值。因为在ref函数中,已经做了相关的依赖跟踪逻辑。另外,如果看过单测篇跟 ref 篇,我们知道就是此处代码实现了这样的能力:向reacitive函数传递一个嵌套的Ref类型数据,可返回一个递归解套了Ref类型的响应式数据。reactive函数的返回类型为UnwrapNestedRefs归功于此。

不过切记:向reactive传一个纯粹的Ref类型数据,是不会解套的,它只解套被嵌套着的Ref数据。示例如下:

reactive(ref(4)) // = ref(4);
reactive({ a: ref(4) }) // = { a: 4 }

那到此为止,除了track是外部引入的用来收集依赖的方法外(后面再看),get已经摸透了。

下面看set

set

function set(
  target: any,
  key: string | symbol,
  value: any,
  receiver: any
): boolean {
  // 如果value是响应式数据,则返回其映射的源数据
  value = toRaw(value)
  // 获取旧值
  const oldValue = target[key]
  // 如果旧值是Ref数据,但新值不是,那更新旧的值的value属性值,返回更新成功
  if (isRef(oldValue) && !isRef(value)) {
    oldValue.value = value
    return true
  }
  // 代理对象中,是不是真的有这个key,没有说明操作是新增
  const hadKey = 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)) {
    // istanbul 是个单测覆盖率工具
    /* istanbul ignore else */
    if (__DEV__) {
      // 开发环境下,会传给trigger一个扩展数据,包含了新旧值。明显的是便于开发环境下做一些调试。
      const extraInfo = { oldValue, newValue: value }
      // 如果不存在key时,说明是新增属性,操作类型为ADD
      // 存在key,则说明为更新操作,当新值与旧值不相等时,才是真正的更新,进而触发trigger
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key, extraInfo)
      } else if (value !== oldValue) {
        trigger(target, OperationTypes.SET, key, extraInfo)
      }
    } else {
      // 同上述逻辑,只是少了extraInfo
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key)
      } else if (value !== oldValue) {
        trigger(target, OperationTypes.SET, key)
      }
    }
  }
  return result
}

setget一样,每句表达式都很清晰,但我们依旧存在疑问。

问题 1:isRef(oldValue) && !isRef(value)这段是什么逻辑?

// 如果旧值是 Ref 数据,但新值不是,那更新旧的值的 value 属性值,返回更新成功
if (isRef(oldValue) && !isRef(value)) {
  oldValue.value = value
  return true
}

什么情况下 oldValue 会是个Ref数据呢?其实看get部分的时候,我们就知道啦,reactive有解套嵌套 ref 数据的能力,如:

const a = {
  b: ref(1)
}
const observed = reactive(a) // { b: 1 }

此时,observed.b输出的是 1,当做赋值操作 observed.b = 2时。oldValue由于是a.b,是一个Ref类型数据,而新的值并不是,进而直接修改a.b的 value 即可。那为什么直接返回,不需要往下触发 trigger 了呢?是因为在ref函数中,已经有劫持 set 的逻辑了(不贴代码了)。

问题 2:什么时候会target !== toRaw(receiver)

在之前的认知中,receiver有点儿像是this一样的存在,指代着被 Proxy 执行后的代理对象。那代理对象用toRaw转化,也就是转为原始对象,自然跟target是全等的。这里就涉及了一个偏门的知识点,详细介绍可以看MDN。其中有说到:

Receiver:最初被调用的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上或以其他方式被间接地调用(因此不一定是 proxy 本身)

这就是代码中的注释背后的意义:

don't trigger if target is something up in the prototype chain of original.

举个实例来说就像这样:

const child = new Proxy(
  {},
  {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      Reflect.set(target, key, value, receiver)
      console.log('child', receiver)
      return true
    }
  }
)

const parent = new Proxy(
  { a: 10 },
  {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      Reflect.set(target, key, value, receiver)
      console.log('parent', receiver)
      return true
    }
  }
)

Object.setPrototypeOf(child, parent)

child.a = 4

// 打印结果
// parent Proxy {child: true, a: 4}
// Proxy {child: true, a: 4}

在这种情况下,这个父对象parentset竟然也会被触发一次,只不过传递的receiver都是child,进而被更改数据的也一直是child。在这种情况下,parent其实并没有变更,按道理来说,它确实不应该触发它的监听函数。

问题 3: 数组可能通过方法更新数据,这过程的监听逻辑是怎么样的?

对于一个对象来说,我们可以直接赋值属性值,但对于数组呢?假使const arr = [],那它既可以arr[0] = 'value',也可以arr.push('value'),但并没有一个trap是劫持 push 的。但是当你真正去调试时,发现push还会触发两次set

const proxy = new Proxy([], {
  set(target, key, value, receiver) {
    console.log(key, value, target[key])
    return Reflect.set(target, key, value, receiver)
  }
})
proxy.push(1)
// 0 1 undefined
// length 1 1

其实push的内部逻辑就是先给下标赋值,然后设置length,触发了两次set。不过还有个现象是,虽然push带来的length操作会触发两次set,但走到 length 逻辑时,获取老的 length 也已经是新的值了,所以由于value === oldValue,实际只会走到一次trigger。但是!如果是shiftunshift,这样的逻辑又不成立了,而且如果数组长度是 N,shift|unshift就会带来 N 次的trigger。这里其实涉及了Array的底层实现与规范,我也无法简单的阐述明白,建议可以自己去看ECMA-262中关于Array的相关标准。

不过这里确实留下一个小坑,shift|unshift以及splice,会带来多次的 effect 触发。在reacivity系统中,目前还没看到相关的优化。当然,真实在使用 vue@3 的过程中,runtime-core还是会针对渲染做批量更新的。

那到这,set本身的逻辑我们也摸透了,除了一个外部引入的trigger。不过我们知道它是当数据变更时触发监听函数的就好,后面再看。

接下来就比较简单了。

其他 traps

// 劫持属性删除
function deleteProperty(target: any, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    /* istanbul ignore else */
    if (__DEV__) {
      trigger(target, OperationTypes.DELETE, key, { oldValue })
    } else {
      trigger(target, OperationTypes.DELETE, key)
    }
  }
  return result
}
// 劫持 in 操作符
function has(target: any, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  track(target, OperationTypes.HAS, key)
  return result
}
// 劫持 Object.keys
function ownKeys(target: any): (string | number | symbol)[] {
  track(target, OperationTypes.ITERATE)
  return Reflect.ownKeys(target)
}

这几个trap基本没啥难点了,一眼能看明白。

最后看下readonly的特殊逻辑:

readonly

export const readonlyHandlers: ProxyHandler<any> = {
  // 创建get的trap
  get: createGetter(true),
  // set的trap
  set(target: any, key: string | symbol, value: any, receiver: any): boolean {
    if (LOCKED) {
      // 开发环境操作只读数据报警告。
      if (__DEV__) {
        console.warn(
          `Set operation on key "${String(key)}" failed: target is readonly.`,
          target
        )
      }
      return true
    } else {
      // 如果不可变开关已关闭,则允许设置数据变更
      return set(target, key, value, receiver)
    }
  },
  // delete的trap,逻辑跟set差不多
  deleteProperty(target: any, key: string | symbol): boolean {
    if (LOCKED) {
      if (__DEV__) {
        console.warn(
          `Delete operation on key "${String(
            key
          )}" failed: target is readonly.`,
          target
        )
      }
      return true
    } else {
      return deleteProperty(target, key)
    }
  },
  has,
  ownKeys
}

readonly也很简单啦,createGetter的逻辑之前已经看过了。不过有些没绕过来的同学可能会想,get的 trap 又不改变数据,为什么要跟reactive的做区分,传个isReadonly呢?那是因为上文中讲到的,通过get做依赖收集时,对于嵌套的对象数据,是延迟劫持的,所以只能透传了isReadonly,让后续劫持的子对象知道自身是否应该只读。

hasownKeys由于不改变数据,也不用递归收集依赖,自然就不用跟可变数据的逻辑做区分了。

看完以后,依赖收集跟触发监听函数的时机,我们就能基本了解了。

小总结

关于 baseHandles 我们做个小总结:

  1. 对于原始对象数据,会通过 Proxy 劫持,返回新的响应式数据(代理数据)。
  2. 对于代理数据的任何读写操作,都会通过Refelct反射到原始对象上。
  3. 在这个过程中,对于读操作,会执行收集依赖的逻辑。对于写操作,会触发监听函数的逻辑。

总结下来,其实还是比较简单的。但是我们还落了对于集合数据的 handlers 没看,这块才是真正的硬骨头。

collectionHandlers

打开这个文件,发现这个文件比reactivebaseHandlers可长了不少。没想到对于这种平常不怎么用的数据类型的处理,才是最麻烦的。

为什么单独处理

看源码前,其实会有个疑问,为什么Set|Map|WeakMap|WeakSet这几个数据需要特殊处理呢?跟其他数据有什么区别吗?我们点开文件,看看这个handlers,发现竟然是这样:

export const mutableCollectionHandlers: ProxyHandler<any> = {
  get: createInstrumentationGetter(mutableInstrumentations)
}
export const readonlyCollectionHandlers: ProxyHandler<any> = {
  get: createInstrumentationGetter(readonlyInstrumentations)
}

只有get,没有sethas这些。这就懵了,说好的劫持setget呢?为什么不劫持set了?原因是没法这么做,我们可以简单的做个尝试:

const set = new Set([1, 2, 3])
const proxy = new Proxy(set, {
  get(target, key, receiver) {
    console.log(target, key, receiver)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log(target, key, value, receiver)
    return Reflect.set(target, key, value, receiver)
  }
})
proxy.add(4)

这段代码,一跑就会出一个错误:

Uncaught TypeError: Method Set.prototype.add called on incompatible receiver [object Object]

发现只要劫持set,或者直接引入了Reflect,反射行为到target上,就会报错。为什么会这样呢?这其实也是跟Map|Set这些的内部实现有关,他们内部存储的数据必须通过this来访问,被成为所谓的“internal slots”,而通过代理对象去操作时,this其实是 proxy,并不是 set,于是无法访问其内部数据,而数组呢,由于一些历史原因,又是可以的。详细解释可以见这篇关于 Proxy 的限制的介绍。这篇文章中也提到了解决办法:

let map = new Map()
let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments)
    return typeof value == 'function' ? value.bind(target) : value
  }
})
proxy.set('test', 1)

大致原理就是,当获取的是一个函数的时候,将this绑定为原始对象,也即是想要劫持的map|set。这样就避免了this的指向问题。

那我们就有点儿明白,为什么collection数据需要特殊处理,只劫持一个get了。具体怎么做呢?我们来看代码。

按惯例先看引用与工具方法:

import { toRaw, reactive, readonly } from './reactive'
import { track, trigger } from './effect'
import { OperationTypes } from './operations'
import { LOCKED } from './lock'
import {
  isObject,
  capitalize, // 首字母转成大写
  hasOwn
} from '@vue/shared'

// 将数据转为reactive数据,如果不是对象,则直接返回自身
const toReactive = (value: any) => (isObject(value) ? reactive(value) : value)
const toReadonly = (value: any) => (isObject(value) ? readonly(value) : value)

这些引用,我们应该基本不用看注释都能明白了,除了一个工具方法capitalize需要点开看看外,基本一眼明白。然后我们需要调整下阅读顺序,先大致看看到底如何通过一个gettrap,劫持写操作。

插桩

// proxy handlers
export const mutableCollectionHandlers: ProxyHandler<any> = {
  // 创建一个插桩getter
  get: createInstrumentationGetter(mutableInstrumentations)
}

首先,我们要读懂它的函数名,createInstrumentationGetter。唔,像我一样英文比较差的同学可能是不太懂Instrumentation是什么意思的。这里是表达“插桩”的意思。关于“插桩”我不多介绍啦,常见的单测覆盖率往往就是通过插桩实现的。

在本代码中,插桩即指向某个方法被注入一段有其他作用的代码,目的就是为了劫持这些方法,增加相应逻辑,那我们看看此处是如何“插桩”(劫持)的。

// 可变数据插桩对象,以及一系列相应的插桩方法
const mutableInstrumentations: any = {
  get(key: any) {
    return get(this, key, toReactive)
  },
  get size() {
    return size(this)
  },
  has,
  add,
  set,
  delete: deleteEntry,
  clear,
  forEach: createForEach(false)
}
// 迭代器相关的方法
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator]
iteratorMethods.forEach(method => {
  mutableInstrumentations[method] = createIterableMethod(method, false)
  readonlyInstrumentations[method] = createIterableMethod(method, true)
})
// 创建getter的函数
function createInstrumentationGetter(instrumentations: any) {
  // 返回一个被插桩后的get
  return function getInstrumented(
    target: any,
    key: string | symbol,
    receiver: any
  ) {
    // 如果有插桩对象中有此key,且目标对象也有此key,
    // 那就用这个插桩对象做反射get的对象,否则用原始对象
    target =
      hasOwn(instrumentations, key) && key in target ? instrumentations : target
    return Reflect.get(target, key, receiver)
  }
}

从上文中知道,由于Proxycollection数据的原生特性,无法劫持set或者直接反射。所以在这里,创建了一个新的对象,它具有setmap一样的方法名。这些方法名对应的方法就是插桩后,注入了依赖收集跟响应触发的方法。然后通过Reflect反射到这个插桩对象上,获取的是插桩后的数据,调用的是插桩后的方法。

而对于一些自定义的属性或方法,Reflect反射的就不是插桩过后的,而是原数据,对于这些情况,也不会做响应式的逻辑,比如单测中的:

it('should not observe custom property mutations', () => {
  let dummy
  const map: any = reactive(new Map())
  effect(() => (dummy = map.customProp))

  expect(dummy).toBe(undefined)
  map.customProp = 'Hello World'
  expect(dummy).toBe(undefined)
})

插桩读操作

接下来看这个插装器mutableInstrumentations,从上往下,我们先看get

const mutableInstrumentations: any = {
  get(key: any) {
    // this 即是调用get的对象,现实情况就是Proxy代理对象
    // toReactive是一个将数据转为响应式数据的方法
    return get(this, key, toReactive)
  }
  // ...省略其他
}
function get(target: any, key: any, wrap: (t: any) => any): any {
  // 获取原始数据
  target = toRaw(target)
  // 由于Map可以用对象做key,所以key也有可能是个响应式数据,先转为原始数据
  key = toRaw(key)
  // 获取原始数据的原型对象
  const proto: any = Reflect.getPrototypeOf(target)
  // 收集依赖
  track(target, OperationTypes.GET, key)
  // 使用原型方法,通过原始数据去获得该key的值。
  const res = proto.get.call(target, key)
  // wrap 即传入的toReceive方法,将获取的value值转为响应式数据
  return wrap(res)
}

注意:在get方法中,第一个入参target不能跟Proxy构造函数的第一个入参混淆。Proxy函数的第一个入参target指的原始数据。而在get方法中,这个target其实是被代理后的数据。也即是Reflect.get(target, key, receiver)中的receiver

然后我们就比较清晰了,本质就是通过原始数据的原型方法+call this,避免了上述的问题,返回真正的数据。

const mutableInstrumentations: any = {
  // ...
  get size() {
    return size(this)
  },
  has
  // ...
}
function size(target: any) {
  // 获取原始数据
  target = toRaw(target)
  const proto = Reflect.getPrototypeOf(target)
  track(target, OperationTypes.ITERATE)
  return Reflect.get(proto, 'size', target)
}

function has(this: any, key: any): boolean {
  // 获取原始数据
  const target = toRaw(this)
  key = toRaw(key)
  const proto: any = Reflect.getPrototypeOf(target)
  track(target, OperationTypes.HAS, key)
  return proto.has.call(target, key)
}

sizehas,都是“查”的逻辑。只是size是一个属性,不是方法,所以需要以get size()的方式去劫持。而has是个方法,不需要专门绑定 this,两者内部逻辑也简单,跟get基本一致。不过这里有个关于 TypeScript 的小细节。has函数第一个入参是this,这个在 ts 里是假的参数,真正调用这个函数的时候,是不需要传递的,所以依旧是这样使用someMap.has(key)就好。

那除了这两个查方法,还有迭代器相关的“查”方法。

插桩迭代器

关于迭代器,如果没什么了解,建议先阅读相关文档,比如MDN

// 迭代器相关的方法
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator]
iteratorMethods.forEach(method => {
  mutableInstrumentations[method] = createIterableMethod(method, false)
})
function createIterableMethod(method: string | symbol, isReadonly: boolean) {
  return function(this: any, ...args: any[]) {
    // 获取原始数据
    const target = toRaw(this)
    // 获取原型
    const proto: any = Reflect.getPrototypeOf(target)
    // 如果是entries方法,或者是map的迭代方法的话,isPair为true
    // 这种情况下,迭代器方法的返回的是一个[key, value]的结构
    const isPair =
      method === 'entries' ||
      (method === Symbol.iterator && target instanceof Map)
    // 调用原型链上的相应迭代器方法
    const innerIterator = proto[method].apply(target, args)
    // 获取相应的转成响应数据的方法
    const wrap = isReadonly ? toReadonly : toReactive
    // 收集依赖
    track(target, OperationTypes.ITERATE)
    // return a wrapped iterator which returns observed versions of the
    // values emitted from the real iterator
    // 给返回的innerIterator插桩,将其value值转为响应式数据
    return {
      // iterator protocol
      next() {
        const { value, done } = innerIterator.next()
        return done
          ? // 为done的时候,value是最后一个值的next,是undefined,没必要做响应式转换了
            { value, done }
          : {
              value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
              done
            }
      },
      // iterable protocol
      [Symbol.iterator]() {
        return this
      }
    }
  }
}

这段逻辑其实还好,核心就是劫持迭代器方法,将每次next返回的 value 用reactive转化。唯一会让人不清楚的,其实是对于Iterator以及Map|Set的不熟悉。如果确实不熟悉,建议还是先看下它们的相关文档。

迭代器相关的还有一个forEach方法。

function createForEach(isReadonly: boolean) {
  // 这个this,我们已经知道了是假参数,也就是forEach的调用者
  return function forEach(this: any, callback: Function, thisArg?: any) {
    const observed = this
    const target = toRaw(observed)
    const proto: any = Reflect.getPrototypeOf(target)
    const wrap = isReadonly ? toReadonly : toReactive
    track(target, OperationTypes.ITERATE)
    // important: create sure the callback is
    // 1. invoked with the reactive map as `this` and 3rd arg
    // 2. the value received should be a corresponding reactive/readonly.
    // 将传递进来的callback方法插桩,让传入callback的数据,转为响应式数据
    function wrappedCallback(value: any, key: any) {
      // forEach使用的数据,转为响应式数据
      return callback.call(observed, wrap(value), wrap(key), observed)
    }
    return proto.forEach.call(target, wrappedCallback, thisArg)
  }
}

forEach的逻辑并不复杂,跟上面迭代器部分差不多,也是劫持了方法,将原本的传参数据转为响应式数据后返回。

插桩写操作

然后看写操作。

function add(this: any, value: any) {
  // 获取原始数据
  value = toRaw(value)
  const target = toRaw(this)
  // 获取原型
  const proto: any = Reflect.getPrototypeOf(this)
  // 通过原型方法,判断是否有这个key
  const hadKey = proto.has.call(target, value)
  // 通过原型方法,增加这个key
  const result = proto.add.call(target, value)
  // 原本没有key的话,说明真的是新增,则触发监听响应逻辑
  if (!hadKey) {
    /* istanbul ignore else */
    if (__DEV__) {
      trigger(target, OperationTypes.ADD, value, { value })
    } else {
      trigger(target, OperationTypes.ADD, value)
    }
  }
  return result
}

我们发现写操作倒简单多了,其实跟baseHandlers的逻辑是差不多的,只不过对于那些base数据,可以通过Reflect方便的反射行为,而在此处,需要手动获取原型链并绑定this而已。查看setdeleteEntry的代码,逻辑也差不多,就不多阐述了。

关于readonly相关的也很简单了,我也不贴了代码了,纯粹增加文章字数。它就是将add|set|delete|clear这几个写方法再包一层,开发环境下抛个 warning。

到这里,终于看完了collcetionsHandlers的全部逻辑了。

小总结

再总结一下它是如何劫持 collcetion 数据的。

  1. 由于Set|Map等集合数据的底层设计问题,Proxy无法直接劫持set或直接反射行为。
  2. 劫持原始集合数据的get,对于它的原始方法或属性,Reflect反射到插桩器上,否则反射原始对象。
  3. 插装器上的方法,会先通过toRaw,获取代理数据的原始数据,再获取原始数据的原型方法,然后绑定this为原始数据,调取相应方法。
  4. 对于getter|has这类查询方法,插入收集依赖的逻辑,并将返回值转为响应式数据(has 返回 boolean 值故不需要转换)。
  5. 对于迭代器相关的查询方法,插入收集依赖逻辑,并将迭代过程的数据转为响应式数据。
  6. 对于写操作相关方法,插入触发监听的逻辑。

其实原理还是好理解的,只是写起来比较麻烦。

总结

那到此为止,终于把reactive的逻辑完全理完了。阅读本部分的代码有点儿不容易,因为涉及的底层知识比较多,不然会处处懵逼,不过这也是一种学习,探索的过程也是挺有意思的。

在这过程中,我们发现,数组的劫持目前还是存在一点点不足的,直接通过反射,会在一些情况下重复触发监听函数。感觉通过类似collection数据的处理方式可以解决。但是这又增加了程序复杂度,而且也不知道会不会有一些其他的坑。

另外,我们发现阅读reactivity相关的代码时,ts 涉及的没我们想象中的多,内部很多情况下是 any 的,但这是要辩证的看的。首先如小右所说,“这些数据是用户数据,本身就是any的。勉强要声明,没有什么意义”。而且那一路下来都是非常多的泛型加推导,成本非常高。反正我自己尝试了下,是无能为力的。另外当前代码还是非正式的阶段,如果维护起来过于麻烦。那对于我这种 ts 半吊子的人,如果真的想再贡献一点代码,也是举步维艰。

这篇文章有点儿繁杂,如果是慢慢看下来的,非常感谢你的阅读~~

下篇是最后的effect相关的源码解析,终于能解开最开始targetMap的谜团,看到tracktrigger的内部实现,凑上最后一块拼图了。

编辑于 2019-11-15

文章被以下专栏收录