Vue3源码解读-reactivity非原始值响应式原理


💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4) 欢迎关注公众号:《前端Talkking》

1、前言

1.1 Vue2响应式介绍

Vue2的响应式是使用 Object.defineProperty实现的,智能拦截对象属性的的 getset方法,它存在以下缺点:

  • 对于已经代理的对象,新增和删除属性无法直接监听,需要通过官方提供的set和delete方法操作才行;
  • 通过索引修改数组的元素值,或者是使用某些会改变数组长度的方法,比如 push、pop、splice等方法,为了解决这个问题,前者使用 set方法,后者重写数组相关方法,覆盖数组原型上的方法,以此实现响应式;
  • 无法代理Set和Map类型 数据,不能实现响应式;
  • 将data中数据转换成响应式数据需要通过递归实现,当数据非常多或者数据层级比较多的时候,比较消耗性能。

1.2 Vue3响应式介绍

在Vue3中,响应式是通过es6提供的Proxy来实现的。Proxy是一个非常强大的功能,它具有以下特点:

  1. 粒度:可以代理整个对象,不需要对每个属性单独操作;
  2. 数组限制 :没有 Object.defineProperty 的限制,可以直接检测数组索引赋值和长度变化;
  3. 对象新增/删除属性:可以直接检测对象属性的添加或删除,不需要特殊方法;
  4. 性能开销:通常提供更好的性能,因为不需要初始化时递归遍历对象的所有属性;
  5. 功能:可以拦截更多类型的操作,包括属性读取、写入、枚举、函数调用、对象原型的查找等;
  6. 兼容性:只兼容支持 ES6 的环境,不支持 IE 浏览器。

总结来说,Proxy 提供了一种更为强大和灵活的方式来创建响应式数据。它解决了 Object.defineProperty 的一些限制,如对数组的限制和对象属性的新增/删除检测,同时也提高了性能。然而,Proxy 的缺点是它不能在所有浏览器中使用,特别是不支持旧版 IE 浏览器。Vue 3 选择使用 Proxy 作为其响应式系统的基础,这是因为它提供了更好的性能和更强大的功能,尽管牺牲了一些兼容性。

在Vue3中,reactive类底层就是基于Proxy实现的,reactive类有对象一共有四种api,分别为:

  • [reactive](https://github.com/vuejs/core/blob/v3.3.4/packages/reactivity/src/reactive.ts#L83-L95)创建可深入响应的可读写对象;
  • [readonly](https://github.com/vuejs/core/blob/v3.3.4/packages/reactivity/src/reactive.ts#L196-L206)创建可深入响应的只读对象;
  • [shallowReactive](https://github.com/vuejs/core/blob/v3.3.4/packages/reactivity/src/reactive.ts#L131-L141)创建只有浅层(一层)的浅可读写对象;
  • [shallowReadonly](https://github.com/vuejs/core/blob/v3.3.4/packages/reactivity/src/reactive.ts#L238-L246)创建只有浅层(一层)的浅只读对象;

2、reactive入口流程解析

2.1 不同类型的响应方法入口

我们先来看看不同类型的reactive对象入口源码:

javascript 复制代码
// reactive方法
export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}
// readonly方法
export function readonly<T extends object>(
  target: T
): DeepReadonly<UnwrapNestedRefs<T>> {
  return createReactiveObject(
    target,
    true,
    readonlyHandlers,
    readonlyCollectionHandlers,
    readonlyMap
  )
}
// shallowReactive方法
export function shallowReactive<T extends object>(
  target: T
): ShallowReactive<T> {
  return createReactiveObject(
    target,
    false,
    shallowReactiveHandlers,
    shallowCollectionHandlers,
    shallowReactiveMap
  )
}
// shallowReadonly方法
export function shallowReadonly<T extends object>(target: T): Readonly<T> {
  return createReactiveObject(
    target,
    true,
    shallowReadonlyHandlers,
    shallowReadonlyCollectionHandlers,
    shallowReadonlyMap
  )
}

我们发现,不同reactive对象调用的方法都是 createReactiveObject,只是入参不一样而已。我们来看看 createReactiveObject函数入参含义:

  • target:代理对象
  • isReadonly:是否是只读的
  • baseHandlers:常用类型(Object、Array)的代理拦截器
  • collectionHandlers:集合类型(Map、Set、WeakMap、WeakSet)代理拦截器
  • proxyMap:代理类型存储池,为了优化性能而设置的

其中,存储池proxyMap的定义有以下四种:

javascript 复制代码
export const reactiveMap = new WeakMap<Target, any>()
export const shallowReactiveMap = new WeakMap<Target, any>()
export const readonlyMap = new WeakMap<Target, any>()
export const shallowReadonlyMap = new WeakMap<Target, any>()

注意:存储池采用WeakMap存储的目的是为了当用户废弃代理和代理对象的时候会自动进行垃圾回收,从而提高代理性能。

2.2 createReactiveObject函数解析

我们继续深入 createReactiveObject函数,来分析它的源码实现流程:

createReactiveObject函数源码实现

javascript 复制代码
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
  }
  // 已经代理过的对象不需要进行二次代理
  // target is already a Proxy, return it.
  // exception: calling readonly() on a reactive object
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // 防止重复代理:如果target已经有对应的Proxy,返回对应的Proxy
  // target already has corresponding Proxy
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // only specific value types can be observed.
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  const proxy = new Proxy(
    target,
    //判断当前代理对象的类型,如果是Array、Object采用baseHandlers
    //如果是Map、Set、WeakMap、WeakSet采用collectionHandlers
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  // 缓存已经代理的对象
  proxyMap.set(target, proxy)
  // 返回代理成功的对象
  return proxy
}

从以上源码我们得知,createReactiveObject函数主要做了以下几件事情:

  1. 判断代理的数据是否是对象,如果是非对象,控制台会打印警告(开发环境),同时返回原对象;
  2. 判断传入的数据是否已经代理过了,则直接返回;
  3. 判断传入的对象是否之前已经创建过代理,如果创建过,则从存储池中取出来返回;
  4. 判断传入的对象是否可以创建代理,如果不能创建代理,则直接返回原对象;
  5. 判断传入对象的类型,选择代理类型:collectionHandlers或者baseHandlers;
  6. 缓存代理的对象到存储池中;
  7. 返回代理成功的对象。

3、不同数据类型代理分析

3.1 baseHandlers和collectionHandlers分析

根据第2.1节,我们分析 reactive入口函数的实现,我们可以得知,数组 (Array)和对象 (Object)采用的拦截器是 baseHandlers ,而集合(Map、Set、WeakMap、WeakSet )采用的拦截器是 collectionHandlers。而根据第2.1节分析:

baseHandlers包含:

  • mutableHandlers
  • readonlyHandlers
  • shallowReactiveHandlers
  • shallowReadonlyHandlers

collectionHandlers包含:

  • mutableCollectionHandlers
  • readonlyCollectionHandlers
  • shallowCollectionHandlers
  • shallowReadonlyCollectionHandlers

因此我们可以分为三类:对象、数组、集合,我们依次来看这三种类型的响应式原理。

3.2 对象代理

3.2.1 读取属性拦截

下面列出了对一个普通对象的所有可能读取操作:

  • 访问属性:obj.a;
  • 判断对象或者原型上是否存在给定的key:key in obj;
  • 使用for...in循环遍历对象:for(key in obj){}。

接下来我们逐步讨论如何拦截这些读取操作:

访问属性的拦截

对于对象属性的读取,都会触发get方法拦截,其源码如下:

javascript 复制代码
get(target: Target, key: string | symbol, receiver: object) {
    const isReadonly = this._isReadonly,
      shallow = this._shallow
    // 注意:此处的if判断逻辑处理Vue内部定义的ReactiveFlags,是先处理响应式对象标志位的逻辑
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      // 是否是只读
      return isReadonly
    } else if (key === ReactiveFlags.IS_SHALLOW) {
      // 是否是浅响应
      return shallow
    } else if (
      key === ReactiveFlags.RAW &&
      receiver ===
        (isReadonly
          ? shallow
            ? shallowReadonlyMap
            : readonlyMap
          : shallow
          ? shallowReactiveMap
          : reactiveMap
        ).get(target)
    ) {
      // 返回响应式对象的原始值
      return target
    }

    const targetIsArray = isArray(target)
    // 对数组属性读取的拦截操作
    if (!isReadonly) {
      if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
        return Reflect.get(arrayInstrumentations, key, receiver)
      }
      if (key === 'hasOwnProperty') {
        return hasOwnProperty
      }
    }
    // 读取key对应的属性值,第三个参数receiver可以帮助分析this指向的是谁
    const res = Reflect.get(target, key, receiver)
    // key是symbol或访问的是__proto__属性不做依赖收集和递归响应式转化,直接返回结果
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }
    // 只读属性值不会发生变化,无法触发setter,因此,target是非只读时才需要收集依赖
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }
    // 如果是浅响应,则直接返回原始值的结果
    if (shallow) {
      return res
    }
    // 如果是ref对象,则unwrap
    if (isRef(res)) {
      // ref unwrapping - skip unwrap for Array + integer key.
      return targetIsArray && isIntegerKey(key) ? res : res.value
    }
    // 如果原始值结果是一个对象,则继续包装成响应式数据
    if (isObject(res)) {
      // Convert returned value into a proxy as well. we do the isObject check
      // here to avoid invalid value warning. Also need to lazy access readonly
      // and reactive here to avoid circular dependency.
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

get拦截函数的处理过程拆解如下:

  1. 判断拦截的key是否是Vue内部定义的 ReactiveFlags,如果是,返回对应的值:#L96-L115
  2. 判断是否是对数组元素属性的读取操作,返回对应的值:#L117-L126,这一步我们在数组代理章节细说;
  3. 通过Reflect.get函数返回拦截key对应的属性值:#L128
    1. 如果被拦截的key是一个Symbol或者key是原型链上的属性,则直接返回属性的读取结果:#L130-L132
    2. 如果被拦截的属性是非只读的,则使用track函数追踪依赖,建立响应联系:#L134-L136;
    3. 如果代理的对象只是浅响应,则直接返回读取结果:#L138-L140
    4. 如果读取的结果是一个 ref 对象,则需要对其进行脱 ref ,返回 res.value#L142-L145
    5. 如果读取结果是一个对象,则继续将其包装成响应式数据: #L147-L152

in操作符拦截

根据ECMA规范,in操作符的访问是通过调用内部的[HasProperty]的抽象方法得到的,而[HasProperty]内部方法的拦截函数是 has,因此,我们可以通过 has拦截函数实现对 in操作符的拦截访问,我们来看源码实现:

in操作符拦截实现源码

javascript 复制代码
has(target: object, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  if (!isSymbol(key) || !builtInSymbols.has(key)) {
    // 触发更新
    track(target, TrackOpTypes.HAS, key)
  }
  return result
}

在该函数中,通过 Reflect.has 获取结果,如果拦截的 key不是 Symbol值,则使用track函数收集依赖,建立响应,最后把 Reflect.has结果值返回。

for ... in 拦截

根据ECMA规范,for...in的拦截可以通过 ownKeys拦截函数实现,我们来看源码实现:

for...in拦截函数源码

javascript 复制代码
function ownKeys(target: object): (string | symbol)[] {
  // 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系,否则使用 ITERATE_KEY 建立响应联系
  track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

在该函数中,使用 track 函数收集依赖,并将 Reflect.ownKeys结果返回。首先判断目标target是否是数组,如果是数组,则使用length属性作为key建立响应,否则使用 ITERATE_KEY建立响应。

相信大家注意到了,这里为什么使用 ITERATE_KEY建立响应联系呢?原因是set/get拦截函数中,我们可以得到具体操作的key,但是 ownKeys中,可以获取到操作对象所有的键值,这个操作明显不能与具体的键进行绑定,所有需要手动构造一个唯一的key作为标识,即 ITERATE_KEY

3.2.2 修改属性拦截

对象属性新增和修改都会触发set,因此可以通过拦截set函数实现修改属性的拦截的处理。

set函数拦截处理源码

javascript 复制代码
  
set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    let oldValue = (target as any)[key]
    // 如果旧值是只读的,且旧值为ref,并且新值不是ref,则不能设置值
    if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
      return false
    }
    // 深度代理的情况
    if (!this._shallow) {
      if (!isShallow(value) && !isReadonly(value)) {
        // 防止如果后面操作了value,引起二次setter
        oldValue = toRaw(oldValue)
        value = toRaw(value)
      }
      // target是对象且值为ref类型,当对这个值修改的时候应该修改ref.value
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value
        return true
      }
    } else {
      // in shallow mode, objects are set as-is regardless of reactive or not
    }
    // 判断当前访问的key是否存在,不存在则是设置新的值
    const hadKey =
      // 当前的target为数组且访问的是数字
      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) {
        // 操作类型是ADD,触发响应
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        // 操作类型是SET,修改老的值,触发响应
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
}

set拦截函数的处理过程拆解如下:

  1. 值为只读对象的拦截:如果旧值为只读的 ref对象,并且新值不是 ref对象,则不能设置值,直接返回 false#L168-L171
  2. 值为非浅响应的拦截:#L172-L183
  3. 正常属性修改的拦截:直接使用 Reflect.set将新值设置到对象的属性上#L189
  4. 触发响应更新:判断是 ADD还是 SET类型,使用 trigger触发依赖更新。

3.2.3 删除属性拦截

在ECMA规范中,delete操作依赖[Delete]内部方法,该方法可以使用 deleteProperty拦截。

删除属性拦截实现源码

javascript 复制代码
 deleteProperty(target: object, key: string | symbol): boolean {
  // 判断删除的属性是否存在
  const hadKey = hasOwn(target, key)
  // 获取旧值
  const oldValue = (target as any)[key]
  // 删除属性返回值为是否删除成功
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    // 只有被删除属性时对象自己的属性并且成功删除的时候,触发更新
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}

deleteProperty函数处理步骤拆解如下:

  1. 检查key属性是否是被操作对象target的属性;
  2. 使用 Reflect.deleteProperty完成属性的删除;
  3. 如果删除成功并且被操作对象target上有key属性,则触发更新(trigger)。

当删除属性后,会影响for...in的循环的次数,此时,trigger的时候需要触发 ITERATE_KEY 相关联的副作用函数重新执行。 trigger实现

javascript 复制代码
case TriggerOpTypes.DELETE:
  if (!isArray(target)) {
    deps.push(depsMap.get(ITERATE_KEY))
    if (isMap(target)) {
      deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
    }
  }
  break

3.3 数组代理

下面总结了所有对数组元素或属性的"读取"操作:

  • 使用for...in遍历数组;
  • 使用for...of迭代遍历数组;
  • 访问数组原型上的方法(不会改变原数组),如concat、join、every、some、find、includes等;
  • 访问数组原型上的方法(会改变原数组),如push、pop、shift、unshift、splice等;
  • 通过索引访问数组元素:arr[0];
  • 访问数组的长度:arr.length。

3.3.1 读取属性拦截

for ... in 拦截

根据ECMA规范,for...in的拦截可以通过ownKeys拦截函数实现。

for...in拦截函数源码

javascript 复制代码
function ownKeys(target: object): (string | symbol)[] {
  // 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系,否则使用 ITERATE_KEY 建立响应联系
  track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

在该函数中,使用track函数收集依赖,并将 Reflect.ownKeys结果返回。判断目标target是否是数组,如果是数组,则使用length属性作为key建立响应。

for ... of拦截

for...of是用来遍历可迭代对象的。迭代数组时,只需要在副作用函数与数组的长度和索引之间建立响应联系,就能够实现响应式的 for...of 迭代。但是需要注意一点,当使用for...of遍历时,会读取数组的 Symbol.iterator 属性,该属性是一个 symbol 值,为了避免发生意外的错误,以及性能上的考虑,在 get 拦截函数内不应该在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系。

for...of拦截实现特殊处理

javascript 复制代码
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
  return res
}

从上面代码可以看出,如果key是symbol类型,则不会调用track函数建立响应联系。

数组查找方法的拦截

数组的 includes、indexOf、lastIndexOf 等方法都属于查找方法。这类查找方法在执行的过程中,它会访问数组的 length 属性以及数组的索引,它会通过索引读取数组元素的值。当执行 arr.includes 时,可以理解为读取代理对象 arrincludes 属性,这会触发 get 拦截函数,因此需要在 get 拦截函数中对这些方法进行拦截,如下面的代码所示:

数组查找方法拦截源码实现

javascript 复制代码
 const targetIsArray = isArray(target)
  // 对数组属性读取的拦截操作
  if (!isReadonly) {
    if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }
  }

如果访问的代理对象target是一个数组,则会使用arrayInstrumentations中定义的方法去重写includes、indexOf、lastIndexOf方法:

javascript 复制代码
const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations()

function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}
  // instrument identity-sensitive Array methods to account for possible reactive
  // values
  ;(['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 + '')
      }
      // we run the method using the original args first (which may be reactive)
      const res = arr[key](...args)
      if (res === -1 || res === false) {
        // if that didn't work, run it again using raw values.
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })
  return instrumentations
}

从上面的源码我们得知,Vue3重写了数组的includes、indexOf、lastIndexof方法,并且都加入了依赖收集,建立了响应式联系。

数组修改方法的拦截

数组修改方法的拦截实现

javascript 复制代码
function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}
  // 省略部分代码
 
  // instrument length-altering mutation methods to avoid length being tracked
  // which leads to infinite loops in some cases (#2137)
  ;(['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
}

执行数组的push、pop、shift、unshift、splice方法既会读取数组的长度也会改变数组的长度length,同时读取和设置可能会形成track - trigger的死循环,导致调用栈溢出。因此,调用前使用 pauseTracking方法暂停依赖收集,调用后使用 resetTracking恢复依赖收集。

3.3.2 修改属性拦截

当通过索引设置数组元素的值时,会执行数组内部方法[Set],当设置的索引值大于数组当前的长度时,那么就要更新数组的length属性,因此,通过索引设置元素值时要触发响应,也应该触发与length属性相关的副作用函数重新执行。

数组修改属性拦截源码实现

javascript 复制代码
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) {
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) {
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
}

这一段源码的处理流程拆解如下:

判断代理的对象是数组并且索引值key也是整数:

  1. 如果设置的索引值key小于数组的长度,则说明是 SET操作,触发 SET响应更新;
  2. 如果设置的索引值key大于数组的长度,则说明是 ADD操作,触发 ADD响应更新,此时需要触发数组对象length属性相关的副作用函数,重新执行: 数组length属性副作用函数执行
javascript 复制代码
switch (type) {
    case TriggerOpTypes.ADD:
      if (!isArray(target)) {
        deps.push(depsMap.get(ITERATE_KEY))
        if (isMap(target)) {
          deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
        }
      } else if (isIntegerKey(key)) {
        // new index added to array -> length changes
        deps.push(depsMap.get('length'))
      }
      break
    case TriggerOpTypes.DELETE:
      if (!isArray(target)) {
        deps.push(depsMap.get(ITERATE_KEY))
        if (isMap(target)) {
          deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
        }
      }
      break
    case TriggerOpTypes.SET:
      if (isMap(target)) {
        deps.push(depsMap.get(ITERATE_KEY))
      }
      break
  }
}

3.4 集合代理

集合类型包括Map、Set以及WeakMap、WeakSet。使用Proxy代理集合类型的数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在很大的不同。以下总结了Set和Map这两个数据类型的原型属性和方法。

Set类型的原型属性和方法如下:

  • size: 返回集合中元素的数量;
  • add(value):向集合中添加给定的值;
  • clear():清空集合;
  • delete(value):从集合中删除给定的值;
  • has(value):判断集合中是否存在给定的值;
  • keys():返回一个迭代器对象,可用于for...of循环;
  • values():与keys()方法等价;
  • entries():返回一个迭代器对象;
  • forEach():遍历集合中的所有元素。

Map类型的原型属性和方法如下:

  • size: 返回集合中元素的数量;
  • set(value):为Map设置新的键值对;
  • clear():清空集合;
  • delete(value):从集合中删除给定的值;
  • has(value):判断集合中是否存在给定的值;
  • keys():返回一个迭代器对象,可用于for...of循环;
  • values():与keys()方法等价;
  • entries():返回一个迭代器对象;
  • forEach():遍历集合中的所有元素。

观察上面的方法可以发现,Map和Set这两个数据类型的操作方法类似。最大的不同是Set类型使用add(value)方法添加元素,而Map类型使用set(key, value)方法设置键值对,并且使用get(key)读取相应的值。因此,我们可以相同的方法来实现对它们的代理。

3.4.1 size属性拦截

在拦截size属性的时候需要修正访问器属性的getter函数执行时的this指向:

size属性拦截源码实现

javascript 复制代码
function size(target: IterableCollections, isReadonly = false) {
  target = (target as any)[ReactiveFlags.RAW]
  !isReadonly && track(toRaw(target), TrackOpTypes.ITERATE, ITERATE_KEY)
  return Reflect.get(target, 'size', target)
}

在上面的代码中,调用 Reflect.get函数制定第三个对象为原始target对象,这样访问器属性size的getter在执行时,其this指向的就是原始对象而非代理对象了,解决执行报错的问题。

3.4.2 get方法拦截

get方法拦截源码实现

javascript 复制代码
function get(
  target: MapTypes,
  key: unknown,
  isReadonly = false,
  isShallow = false
) {
  // #1772: readonly(reactive(Map)) should return readonly + reactive version
  // of the value
  target = (target as any)[ReactiveFlags.RAW]
  const rawTarget = toRaw(target)
  const rawKey = toRaw(key)
  if (!isReadonly) {
    if (key !== rawKey) {
      track(rawTarget, TrackOpTypes.GET, key)
    }
    track(rawTarget, TrackOpTypes.GET, rawKey)
  }
  const { has } = getProto(rawTarget)
  const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive
  if (has.call(rawTarget, key)) {
    return wrap(target.get(key))
  } else if (has.call(rawTarget, rawKey)) {
    return wrap(target.get(rawKey))
  } else if (target !== rawTarget) {
    // #3602 readonly(reactive(Map))
    // ensure that the nested reactive `Map` can do tracking for itself
    target.get(key)
  }
}

在get拦截函数中,对于代理对象的key和原始对象的key都会收集依赖建立响应联系,然后通过 target.get方法获取原始对象的属性值,并调用wrap函数转成响应式对象并返回。

3.4.3 set方法拦截

set方法拦截源码实现

javascript 复制代码
function set(this: MapTypes, key: unknown, value: unknown) {
  value = toRaw(value)
  const target = toRaw(this)
  const { has, get } = getProto(target)

  let hadKey = has.call(target, key)
  if (!hadKey) {
    key = toRaw(key)
    hadKey = has.call(target, key)
  } else if (__DEV__) {
    checkIdentityKeys(target, has, key)
  }

  const oldValue = get.call(target, key)
  target.set(key, value)
  if (!hadKey) {
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) {
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
  return this
}

在set属性拦截函数中,首先通过raw属性获取原始数据,然后再把原始数据设置到target上,这就避免了由于value可能是响应式数据而污染原始数据。同时,需要判断设置的key值是否存在,以区分操作类型是 SET还是 ADD。因为, ADD类型操作会对size属性产生影响,任何依赖size属性的副作用函数都需要在 ADD类型的操作发生时重新执行。

3.4.4 has方法拦截

has方法拦截源码实现

javascript 复制代码
function has(this: CollectionTypes, key: unknown, isReadonly = false): boolean {
  const target = (this as any)[ReactiveFlags.RAW]
  const rawTarget = toRaw(target)
  const rawKey = toRaw(key)
  if (!isReadonly) {
    if (key !== rawKey) {
      track(rawTarget, TrackOpTypes.HAS, key)
    }
    track(rawTarget, TrackOpTypes.HAS, rawKey)
  }
  return key === rawKey
    ? target.has(key)
    : target.has(key) || target.has(rawKey)
}

has拦截函数中,会对代理对象的 key和原始对象的 key进行依赖收集,建立响应联系。然后,调用原始对象的 has 方法判断一个值是否是 Set的成员或者一个键是否在 Map中,并将其结果返回。

3.4.5 add方法拦截

add方法拦截源码实现

javascript 复制代码
function add(this: SetTypes, value: unknown) {
  value = toRaw(value)
  const target = toRaw(this)
  const proto = getProto(target)
  const hadKey = proto.has.call(target, value)
  if (!hadKey) {
    target.add(value)
    trigger(target, TriggerOpTypes.ADD, value, value)
  }
  return this
}

在add方法拦截拦截函数中,首先判断值是否已经存在,因为只有在值不存在的时候才需要触发响应,这样性能更好。

3.4.6 delete方法拦截

delete方法拦截源码实现

javascript 复制代码
function deleteEntry(this: CollectionTypes, key: unknown) {
  const target = toRaw(this)
  const { has, get } = getProto(target)
  let hadKey = has.call(target, key)
  if (!hadKey) {
    key = toRaw(key)
    hadKey = has.call(target, key)
  } else if (__DEV__) {
    checkIdentityKeys(target, has, key)
  }

  const oldValue = get ? get.call(target, key) : undefined
  // forward the operation before queueing reactions
  const result = target.delete(key)
  if (hadKey) {
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}

delete拦截函数里面this指向的是代理对象,为了保证通过使用原始数据对象的delete方法删除具体的值,首先需要通过代理对象的raw属性来获取原始数据对象,然后再调用原始对象的delete方法删除具体的值,当要删除的值存在时,调用trigger函数触发响应,指定操作类型为 DELETE

3.4.7 clear方法拦截

clear方法拦截源码实现

javascript 复制代码
function clear(this: IterableCollections) {
  const target = toRaw(this)
  const hadItems = target.size !== 0
  const oldTarget = __DEV__
    ? isMap(target)
      ? new Map(target)
      : new Set(target)
    : undefined
  // forward the operation before queueing reactions
  const result = target.clear()
  if (hadItems) {
    trigger(target, TriggerOpTypes.CLEAR, undefined, undefined, oldTarget)
  }
  return result
}

clear()拦截函数中,首先通过代理对象的raw属性获取原始对象,然后判断原始对象中是否还有元素。在执行原始对象的 clear()方法清除所有成员时,只有原始对象中还存在元素时才调用trigger函数触发响应,并指定操作类型为 CLEAR

3.4.8 forEach方法拦截

遍历操作至于键值对的数量有关,因此任何会修改Map对象键值对数量的操作都应该触发副作用函数重新执行,例如delete和add方法等。所以当forEach函数被调用时,我们应该让副作用函数与 ITERATE_KEY建立响应联系。

在forEach拦截函数中执行原始对象的forEach方法,手动调用callback函数时,传入thisArg参数指定callback函数执行时this,并用wrap函数将value和key包装成响应式数据后再传递给callback,保证了执行forEach方法时在callback调用函数中拿到的数据都是响应式的。

forEach方法拦截源码实现

javascript 复制代码
function createForEach(isReadonly: boolean, isShallow: boolean) {
  return function forEach(
    this: IterableCollections,
    callback: Function,
    thisArg?: unknown
  ) {
    const observed = this as any
    // 获取原始数据对象
    const target = observed[ReactiveFlags.RAW]
    const rawTarget = toRaw(target)
    // wrap 函数用来把可代理的值转换为响应式数据
    const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive
    // 与 ITERATE_KEY 建立响应联系
    !isReadonly && track(rawTarget, TrackOpTypes.ITERATE, ITERATE_KEY)
    // 通过原始数据对象target调用 forEach 方法进行遍历
    return target.forEach((value: unknown, key: unknown) => {
      // important: make 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,用 wrap 函数包裹 value 和 key 后再传给 callback,这样就实现了深响应
      return callback.call(thisArg, wrap(value), wrap(key), observed)
    })
  }
}

forEach遍历Map类型数据的时候,同时关心键和值,所以如果是SET类型,并且目标对象是Map类型时,应该触发与 ITERATE_KEY 相关联的副作用函数重新执行,如下所示:

forEach trigger函数实现

javascript 复制代码
 switch (type) {
      case TriggerOpTypes.ADD:
        if (!isArray(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          // new index added to array -> length changes
          deps.push(depsMap.get('length'))
        }
        break
      case TriggerOpTypes.DELETE:
        if (!isArray(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }

在上面的代码中,如果操作的目标对象是Map类型的数据,则SET类型的操作需要触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。

3.4.9 迭代器属性拦截

集合类型有三个迭代器方法:

  • entries
  • keys
  • values

调用这些方法会得到相应的迭代器,并且可以使用for...of进行循环迭代。

Vue3中,使用 createIterableMethod 函数实现了 entries、keys、values 三个迭代器方法和 Symbol.iterator方法的拦截,如下所示:

createIterableMethod源码实现

javascript 复制代码
function createIterableMethod(
  method: string | symbol,
  isReadonly: boolean,
  isShallow: boolean
) {
  return function (
    this: IterableCollections,
    ...args: unknown[]
  ): Iterable & Iterator {
    const target = (this as any)[ReactiveFlags.RAW]
    const rawTarget = toRaw(target)
    const targetIsMap = isMap(rawTarget)
    const isPair =
      method === 'entries' || (method === Symbol.iterator && targetIsMap)
    const isKeyOnly = method === 'keys' && targetIsMap
    const innerIterator = target[method](...args)
    const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive
    !isReadonly &&
      track(
        rawTarget,
        TrackOpTypes.ITERATE,
        isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY
      )
    // return a wrapped iterator which returns observed versions of the
    // values emitted from the real iterator
    return {
      // iterator protocol
      next() {
        const { value, done } = innerIterator.next()
        return done
          ? { value, done }
          : {
              value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
              done
            }
      },
      // iterable protocol
      [Symbol.iterator]() {
        return this
      }
    }
  }
}

上面的代码可以拆解为以下几步:

1、建立响应式联系

前文中提到,当操作类型是 SET时会触发与 ITERATE_KEY相关的副作用函数重新执行,这对于values或entries等方法来说是必需的,但对于keys方法来说则没有必要,因为 keys 方法只关心Map类型数据的键的变化,而不关心值的变化。因此,在调用track函数建立响应联系时,使用 MAP_KEY_ITERATE_KEY来建立响应联系:L190-L195

javascript 复制代码
!isReadonly &&
  track(
    rawTarget,
    TrackOpTypes.ITERATE,
    isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY
  )

values 和 entries 等方法仍然依赖 ITERATE_KEY,而 keys 方法则依赖 MAP_KEY_ITERATE_KEY。当 SET 类型的操作只会触发ITERATE_KEY 相关联的副作用函数重新执行时,自然就会忽略那些与 ITERATE_KEY相关联的副作用函数。但当 ADD 和 DELETE类型的操作发生时除了触发与 ITERATE_KEY 相关联的副作用函数重新执行之外,还需要触发与 MAP_KEY_ITERATE_KEY 相关联的副作用函数重新执行:

trigger实现

javascript 复制代码
switch (type) {
    //处理添加新的值
    case TriggerOpTypes.ADD:
      if (!isArray(target)) {
        deps.push(depsMap.get(ITERATE_KEY))
        if (isMap(target)) {
          deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
        }
      } else if (isIntegerKey(key)) {
        //当前修改的是数组且是新增值
        //例如 arr.length = 3 arr[4] = 8
        //此时数组长度会发生改变所以当前数组的
        //length属性依然需要被放入依赖
        // new index added to array -> length changes
        deps.push(depsMap.get('length'))
      }
      break
    case TriggerOpTypes.DELETE:
      if (!isArray(target)) {
        deps.push(depsMap.get(ITERATE_KEY))
        if (isMap(target)) {
          deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
        }
      }
      break
    case TriggerOpTypes.SET:
      if (isMap(target)) {
        deps.push(depsMap.get(ITERATE_KEY))
      }
      break
  }
}

2、返回响应数据

在使用 for...of 循环遍历 entries、keys、values、Symbol.iterator 时,为了使得获取到的值是响应式的数据,需要将 key 和 value 转换成响应式数据。

如果迭代器方法是 entries 或者是 Symbol.iterator,那么需要对键值对进行包装,即 [wrap(value[0]), wrap(value[1])]。如果迭代器方法是 values 或者是 keys,那么需要对值进行包装,即 wrap(value),最后返回包装后的代理对象。

返回响应式数据源码实现

javascript 复制代码
return {
  // iterator protocol
  next() {
    const { value, done } = innerIterator.next()
    return done
      ? { value, done }
      : {
        value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
        done
      }
  }
}

3、返回可迭代对象

  • 可迭代协议:指一个对象实现了 Symbol.iterator方法
  • 迭代器协议:只一个对象实现了 next方法。

一个对象可以同时实现可迭代协议和迭代器协议。在对 entries、keys、values、Symbol.iterator 的拦截中,返回的对象就实现了可迭代协议和迭代器协议,如下面的代码所示:

可迭代协议源码拦截实现

javascript 复制代码
return {
  // 省略部分代码

  // iterable protocol
  [Symbol.iterator]() {
    return this
  }
}

4、总结

本文详细介绍了Vue3对对象、数组、集合(Set、Map)响应式处理的实现,都是通过Proxy代理拦截,然后拦截ECMA底层对应的方法实现的。大致说来:

  • 普通对象类型:找到ECMA规范中可拦截的操作方法,例如属性的读取是拦截get操作方法,in操作符是拦截has方法实现的,delete是拦截deleteProperty方法实现的;
  • 数组类型:读取和数组元素设置数组元素和普通对象的操作方法一样的,而对于查找和修改方法,重写了这些方法;
  • 集合类型:重写了集合方法来实现自定义的能力。

5、参考资料

[1] Vue官网

[2] Vuejs设计与实现

[3] Vue3源码

相关推荐
GIS程序媛—椰子7 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00113 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端16 分钟前
Content Security Policy (CSP)
前端·javascript·面试
木舟100920 分钟前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤439130 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落1 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt