Vue3 源码解读之非原始值的响应式原理

在上个章节《Vue3 源码解读之原始值的响应式原理》,我们讲了 Vue3 原始值的响应式原理 ,这节我们来看看非原始值的响应式原理

我们知道,Vue 3 中的响应式数据是基于 ES6 中的 Proxy 实现的,Proxy 可以为其它对象创建一个代理对象。Proxy 除了可以代理 Object、Array、还可以代理 ES6 中新增的 Map、Set、WeakMap、WeakSet 等集合类型。本文将会深入解析Proxy如何对这些数据结构进行代理。

本文内容

  • 1、基本操作与复合操作的区分
  • 2、如何代理 Object
  • 3、如何代理数组
  • 4、代理 Set 和 Map

1、基本操作与复合操作

1.1 什么是基本操作

对一个对象进行读取、设置属性值的操作,就属于基本语义的操作,即基本操作,如下代码所示

js 复制代码
obj.foo; // 读取属性 foo 的值
obj.foo++; // 读取和设置属性 foo 的值

1.2 什么是复合操作

调用对象下的方法就是典型的非基本操作,即复合操作

js 复制代码
obj.fn();

调用一个对象下的方法,是由两个基本语义组成的。第一个语义是 get,即先通过 get 操作得到 obj.fn 属性。第二个基本语义是函数调用,即通过 get 得到 obj.fn 的值后再调用它。

2、代理 Object

2.1 属性读取操作的拦截

一个普通对象的读取操作有以下三种:

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

对于这些读取操作,Vue.js 的响应系统都会进行拦截,以便当数据变化时能够正确的触发响应。接下来,我们将分别介绍如何拦截这些读取操作。

2.1.1 访问属性的拦截

对于属性的读取,例如 obj.foo,可以通过 Proxyget 拦截函数实现:

js 复制代码
const obj = new Proxy(
  {},
  {
    // get 拦截函数: 拦截属性的读取操作
    get: function (target, propKey, receiver) {
      console.log(`getting ${propKey}!`);
      return Reflect.get(target, propKey, receiver);
    },
  }
);

Vue 3 中 get 拦截函数的实现如下代码所示:

BaseReactiveHandler

js 复制代码
// packages/reactivity/src/baseHandlers.ts

class BaseReactiveHandler implements ProxyHandler<Target> {
  constructor(
    protected readonly _isReadonly = false,
    protected readonly _shallow = false
  ) { }

  // get 操作的拦截
  get(target: Target, key: string | symbol, receiver: object) {
    const isReadonly = this._isReadonly,
      shallow = this._shallow
    // 访问对应标志位的处理逻辑
    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 (
      // receiver指向调用者,这里的判断是为了保证触发拦截handler的是proxy对象本身而非proxy的继承者。
      // 触发拦截器的两种途径:
      // 1 访问proxy对象本身的属性;
      // 2 访问对象原型链上有proxy对象的对象的属性,因为查询属性会沿着原型链向下游依次查询,因此同样会触发拦截器
      key === ReactiveFlags.RAW &&
      receiver ===
      (isReadonly
        ? shallow
          ? shallowReadonlyMap
          : readonlyMap
        : shallow
          ? shallowReactiveMap
          : reactiveMap
      ).get(target)
    ) {
      // 返回target本身,即响应式对象的原始值
      return target
    }

    // 访问重写后的 Array 对象的方法,这些方法存储在 arrayInstrumentations 工具集里
    const targetIsArray = isArray(target)

    if (!isReadonly) {
      if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
        // 这里在调用indexOf等数组方法时是通过proxy来调用的,因此
        // arrayInstrumentations[key]的this一定指向proxy实例,也即receiver
        return Reflect.get(arrayInstrumentations, key, receiver)
      }
      if (key === 'hasOwnProperty') {
        return hasOwnProperty
      }
    }

    // 使用 Reflect.get 返回读取的属性值,第三个参数 receiver 可以帮助分析 this 指向的是谁
    const res = Reflect.get(target, key, receiver)

    // key是symbol或访问的是__proto__属性不做依赖收集和递归响应式转化,直接返回结果
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }

    // target 为非只读时才需要收集依赖
    // 只读的因为属性不会变化,因此无法触发setter,也就不会触发依赖更新
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }


    // 如果是浅响应,则直接返回原始值结果
    if (shallow) {
      return res
    }

    // 访问属性已经是ref对象,保证访问ref属性时得到的是ref对象的value属性值,数组、NaN、空字符除外
    if (isRef(res)) {
      // ref unwrapping - skip unwrap for Array + integer key.
      return targetIsArray && isIntegerKey(key) ? res : res.value
    }

    // 如果原始值结果是一个对象,则继续将其包装成响应式数据
    if (isObject(res)) {
      // 如果数据为只读,则调用 readonly 对值进行包装
      // 否则调用 reactive 将结果包装成 响应式数据 并返回
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

get 拦截函数中,首先判断拦截的 key 是否是 Vue 内部定义的 ReactiveFlags 标志,如果是,则返回 createGetter 在不同场景中被调用时传入 isReadonlyshallow 参数的值。

然后是对数组元素或**属性 "读取"**操作的拦截。例如通过索引访问数组元素值 ( arr[0] )、访问数组的长度 (arr.length ) 或 使用 indexOf、includes 等数组方法查找元素时,都会触发 length 属性以及通过索引访问元素值等读取操作。

js 复制代码
// 访问重写后的 Array 对象的方法,这些方法存储在 arrayInstrumentations 工具集里
const targetIsArray = isArray(target);

if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
  // 这里在调用indexOf等数组方法时是通过proxy来调用的,因此
  // arrayInstrumentations[key]的this一定指向proxy实例,也即receiver
  return Reflect.get(arrayInstrumentations, key, receiver);
}

接下来通过 Reflect.get 函数来获取被拦截的 key 的属性值,根据被拦截的 key 的类型以及其属性值的类型来返回不同的结果。

js 复制代码
// 使用 Reflect.get 返回读取的属性值,第三个参数 receiver 可以帮助分析 this 指向的是谁
const res = Reflect.get(target, key, receiver);

如果被拦截的 key 是一个 Symbol 或者 key 是原型链上的属性,则不做依赖收集并且直接返回属性的读取结果,不对其做响应式处理

js 复制代码
// key是symbol或访问的是__proto__属性,则不做依赖收集和递归响应式转化,直接返回结果
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
  return res;
}

如果被拦截的属性是只读的,那么就没有必要为只读数据建立响应联系。因此,只有在被拦截属性是非只读时 才调用 track 函数收集依赖,建立响应联系。

js 复制代码
// target 为非只读时才需要收集依赖
// 只读的因为属性不会变化,因此无法触发setter,也就不会触发依赖更新
if (!isReadonly) {
  track(target, TrackOpTypes.GET, key);
}

在完成依赖收集之后,需要根据不同的情况来返回不同的读取结果。如果代理的对象只是浅响应,则直接返回读取结果。如果读取的结果是一个 ref 对象,则需要对其进行脱 ref ,返回 res.value 。如果读取结果是一个对象,则继续将其包装成响应式数据

js 复制代码
// 如果是浅响应,则直接返回原始值结果
if (shallow) {
  return res;
}

// 访问属性已经是ref对象,保证访问ref属性时得到的是ref对象的value属性值,数组、NaN、空字符除外
if (isRef(res)) {
  // ref unwrapping - does not apply for Array + integer key.
  const shouldUnwrap = !targetIsArray || !isIntegerKey(key);
  return shouldUnwrap ? res.value : res;
}

// 如果原始值结果是一个对象,则继续将其包装成响应式数据
if (isObject(res)) {
  // 如果数据为只读,则调用 readonly 对值进行包装
  // 否则调用 reactive 将结果包装成 响应式数据 并返回
  return isReadonly ? readonly(res) : reactive(res);
}

2.1.2 in 操作符的拦截

有时候,我们会通过 in 操作符来判断对象或原型上是否存在指定的 key。在 ECMA 规范中,in 操作符的运算结果是通过调用一个叫做 HasProperty 的抽象方法得到的。HasProperty 抽象方法的返回值是通过调用对象的内部方法 [[HasProperty]] 得到的,而 [[HasProperty]] 内部方法对应的拦截函数是 has,因此可以通过 has 拦截函数实现对 in 操作符的代理。

has

js 复制代码
// packages/reactivity/src/baseHandlers.ts

// 通过 has 拦截函数拦截 in 操作符
function has(target: object, key: string | symbol): boolean {
  // 通过 Reflect.has 函数获取结果
  const result = Reflect.has(target, key);
  if (!isSymbol(key) || !builtInSymbols.has(key)) {
    // 触发更新
    track(target, TrackOpTypes.HAS, key);
  }
  return result;
}

可以看到,对 in 操作符进行拦截时,通过 Reflect.has 函数来获取结果,如果被拦截的 key 不是 Symbol 值,则需要收集依赖,建立响应联系。

2.1.3 for...in 循环的拦截

对于 for...in 的拦截,可以使用 ownKeys 拦截函数来拦截。

ownKeys

js 复制代码
// packages/reactivity/src/baseHandlers.ts

// for...in 循环的拦截
function ownKeys(target: object): (string | symbol)[] {
  // 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系,
  // 否则使用 ITERATE_KEY 建立响应联系
  track(target, TrackOpTypes.ITERATE, isArray(target) ? "length" : ITERATE_KEY);
  // 使用 Reflect.ownKeys(obj) 来获取只属于对象自身拥有的键
  return Reflect.ownKeys(target);
}

在上面的代码中拦截 ownKeys 操作即可间接拦截 for...in 循环。在使用 track 函数进行追踪的时候,将操作类型设置为 TrackOpTypes.ITERATE,同时将 ITERATE_KEY 作为追踪的 key,是因为ownKeys 函数只能拿到目标对象target,不能像 get/set 那样可以得到具体操作的 key

ownKeys 用来获取一个对象的所有属于自己的键值,这个操作明显不与任何具体的键进行绑定,因此我们只能够构造唯一的 key 作为标识,即 ITERATE_KEY

2.2 设置属性操作的拦截

无论是添加新属性,还是修改已有的属性值,其基本的语义都是 [[Set]],因此都是通过 set 函数来拦截。

set

js 复制代码
// packages/reactivity/src/baseHandlers.ts

// set操作符的拦截
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
  }
  // 如果是浅响应并且 value 为非只读
  if (!this._shallow) {
    // value 为浅响应
    if (!isShallow(value) && !isReadonly(value)) {
      oldValue = toRaw(oldValue)
      value = toRaw(value)
    }
    // target不是数组,且旧值为ref,新值非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
  }

  const hadKey =
    isArray(target) && isIntegerKey(key)
      ? Number(key) < target.length
      : hasOwn(target, key)
  const result = Reflect.set(target, key, value, receiver)
  // 这里判断receiver是proxy实例才更新派发,防止通过原型链触发拦截器触发更新。
  // target === receiver.raw ,说明 receiver 就是 target 的代理对象
  // 只有当 receiver 是target 的代理对象时才触发更新
  if (target === toRaw(receiver)) {
    if (!hadKey) {
      // 操作类型为 ADD 时,触发响应
      trigger(target, TriggerOpTypes.ADD, key, value)
    } else if (hasChanged(value, oldValue)) {
      // 比较新值与旧值,只有当它们不全等,并且都不是 NaN 的时候才触发响应
      trigger(target, TriggerOpTypes.SET, key, value, oldValue)
    }
  }
  return result
}

set 拦截函数中,对设置属性操作的拦截可以分为四个部分:

1、值为只读时的拦截

如果旧值为只读,且旧值为 ref 对象,并且新值不是 ref 对象,则不能设置值,返回 false,表示设置值失败。

js 复制代码
// 如果旧值为只读,且旧值为 ref,并且新值不是 ref,则不能设置值
if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
  return false;
}

2、数据为浅响应且值为非只读时的拦截

如果数据为浅响应 ,并且值为非只读 时,如果要设置的值也是浅响应 ,那么调用 toRaw 方法获取原始值,即通过代理对象的 raw 属性读取原始数据。只有当代理对象 target 和要设置的值value不是 ref 对象时,才能将旧值更新为新值。

js 复制代码
// 如果是浅响应并且 value 为非只读
if (!shallow && !isReadonly(value)) {
  // value 为浅响应
  if (!isShallow(value)) {
    // 获取原始值
    value = toRaw(value);
    oldValue = toRaw(oldValue);
  }
  // target不是数组,且旧值为ref,新值非ref,直接将ref.value更新为新值
  if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
    oldValue.value = value;
    return true;
  }
}

3、正常属性的新增和修改的拦截

对于属性正常的新增和拦截,则直接调用 Reflect.set 方法将值赋值给对象的属性,将其设置结果存储到 result 变量中。

js 复制代码
const result = Reflect.set(target, key, value, receiver);

4、触发响应

根据 ECMA 规范,[[Set]] 内部方法的执行流程中,如果设置的属性不存在于对象上,那么会取得其原型,并调用原型的 [[Set]] 方法,这就会产生由原型引起更新的问题 。为了避免这个问题,需要判断 receiver 是否是 target 的代理对象, 即 proxy 实例,只有当 receivertarget 的代理对象时才触发更新。在源码里调用toRaw方法判断 receiver是否是proxy实例,即通过代理对象的 raw 属性读取原始数据,确定receiver 是否是 target 的代理对象。

然后比较新值与旧值,只有当它们不全等,并且都不是 NaN 的时候才调用 trigger 触发响应。

js 复制代码
// 这里判断receiver是proxy实例才更新派发,防止通过原型链触发拦截器触发更新。
// target === receiver.raw ,说明 receiver 就是 target 的代理对象
// 只有当 receiver 是target 的代理对象时才触发更新
if (target === toRaw(receiver)) {
  if (!hadKey) {
    // 操作类型为 ADD 时,触发响应
    trigger(target, TriggerOpTypes.ADD, key, value);
  } else if (hasChanged(value, oldValue)) {
    // 比较新值与旧值,只有当它们不全等,并且都不是 NaN 的时候才触发响应
    trigger(target, TriggerOpTypes.SET, key, value, oldValue);
  }
}

2.3 delete 操作符的拦截

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

deleteProperty 实现

js 复制代码
// packages/reactivity/src/baseHandlers.ts

// delete 操作符的拦截
deleteProperty(target: object, key: string | symbol): boolean {
  // 检查被操作的属性是否是对象自己的属性
  const hadKey = hasOwn(target, key)
  const oldValue = (target as any)[key]
  // 使用 Reflect.deleteProperty 完成属性的删除
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    // 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}

delete 操作符的拦截函数 deleteProperty 中,调用了 Reflect.deleteProperty 来完成属性的删除。如果当前被删除的属性是对象自己的属性并且成功删除 时,调用 trigger 触发更新。由于删除操作会使得对象的键变少,它会影响 for...in 循环的次数,因此当操作类型为 DELETE 时,也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。如下面源码中 trigger 函数中的代码:

js 复制代码
// packages/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)
  if (!depsMap) {
    // 不收集依赖
    return
  }

  let deps: (Dep | undefined)[] = []

  // 省略部分代码

  else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {

      // 省略部分代码

      // 操作类型为 DELETE 时,触发与 ITERATE_KEY 相关联的副作用函数重新执行
      case TriggerOpTypes.DELETE:
        if (!isArray(target)) {
          // 取得与 ITERATE_KEY 相关联的副作用函数
          // 将与 ITERATE_KEY 相关联的副作用函数也添加到 deps
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break

     // 省略部分代码
    }
  }

  // 省略部分代码
}

3、代理数组

3.1 数组元素或属性的读取拦截

对数组元素或属性的读取操作有以下几种:

  • 通过索引访问数组元素值:arr[0]
  • 访问数组的长度:arr.length
  • 把数组作为对象,使用 for...in 循环遍历
  • 使用 for...of 迭代遍历数组
  • 数组的原型方法,如 concat/join/every/some/find/findIndex/includes 等,以及其它所有不改变原数组的原型方法

当这些操作发生时,Vue.js 的响应系统都会进行拦截,以便当数据变化时能够正确的触发响应。

3.1.1 for...in 操作符的拦截

把数组作为对象,使用 for...in 循环遍历时,使用 ownKeys 拦截函数来拦截。如下面的源码所示:

ownKeys 实现

js 复制代码
// packages/reactivity/src/baseHandlers.ts

// for...in 循环的拦截
function ownKeys(target: object): (string | symbol)[] {
  // 如果操作目标 target 是数组,则使用 length 属性作为 key并建立响应联系,
  // 否则使用 ITERATE_KEY 建立响应联系
  track(target, TrackOpTypes.ITERATE, isArray(target) ? "length" : ITERATE_KEY);
  // 使用 Reflect.ownKeys(obj) 来获取只属于对象自身拥有的键
  return Reflect.ownKeys(target);
}

可以看到,在 ownKeys 拦截函数内,判断当前操作目标 target 是否是数组,如果是,则使用 length 作为 key 去建立响应联系。

3.1.2 for...of 操作符的拦截

for...of 是用来遍历可迭代对象的,由于数组内建了 Symbol.iterator 方法,因此默认情况下数组可以使用 for...of 遍历。在使用 for...of 遍历时,会读取数组的 Symbol.iterator 属性。该属性是一个 symbol 值,为了避免发生意外的错误,以及性能上的考虑,在 get 拦截函数内不应该在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系。如下面的代码所示:

js 复制代码
// packages/reactivity/src/baseHandlers.ts

// get 操作的拦截
class BaseReactiveHandler implements ProxyHandler<Target> {
  constructor(
    protected readonly _isReadonly = false,
    protected readonly _shallow = false
  ) {}

  get(target: Target, key: string | symbol, receiver: object) {

    // 省略部分代码

    // 使用 Reflect.get 返回读取的属性值,第三个参数 receiver 可以帮助分析 this 指向的是谁
    const res = Reflect.get(target, key, receiver)

    // key是symbol或访问的是__proto__属性不做依赖收集和递归响应式转化,直接返回结果
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }

    // target 为非只读时才需要收集依赖
    // 只读的因为属性不会变化,因此无法触发setter,也就不会触发依赖更新
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    // 省略部分代码

    return res
  }
}

在上面的源码中,判断 key 的类型是否是 symbol ,如果是,则直接返回读取结果,不在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系,即不调用 track 函数收集依赖。

3.1.3 数组的查找方法的拦截

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

js 复制代码
// packages/reactivity/src/baseHandlers.ts

// get 操作的拦截
function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // 省略部分代码

    // 访问重写后的 Array 对象的方法,这些方法存储在 arrayInstrumentations 工具集里
    const targetIsArray = isArray(target);

    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      // 这里在调用indexOf等数组方法时是通过proxy来调用的,因此
      // arrayInstrumentations[key]的this一定指向proxy实例,也即receiver
      return Reflect.get(arrayInstrumentations, key, receiver);
    }

    // 省略部分代码
  };
}

在上面的源码中,判断代理对象 target 是否是数组,如果是数组并且读取的键值是 includes 、indexOf、lastIndexOf 等,则返回定义在 arrayInstrumentations 对象中相应键值的方法,从而对这些方法进行重写。自定义的 includes/indexOf/lastIndexOf 方法的实现代码如下所示:

createArrayInstrumentations 实现

js 复制代码
// packages/reactivity/src/baseHandlers.ts

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[]) {
      // toRaw 通过代理对象的 raw 属性读取原始数组对象
      const arr = toRaw(this) as any
      // 遍历数组,按照数组的下标收集依赖
      for (let i = 0, l = this.length; i < l; i++) {
        // 依赖收集
        track(arr, TrackOpTypes.GET, i + '')
      }
      // 优先使用原始参数来执行 Array 原型上的方法来查找值
      const res = arr[key](...args)
      // indexOf、lastIndexOf 方法没有找到指定的值,会返回 -1
      // includes 方法没有找到指定的值,会返回 false
      if (res === -1 || res === false) {
        // 没有找到对应的值,args 有可能是包装后的响应式数据,因此获取原始数据后再尝试去查询
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })

  // 省略部分代码

  return instrumentations
}

可以看到,在重写 includes/indexOf/lastIndexOf 等方法时,加入了依赖收集,使得这些方法具有响应的能力。

3.2.4 隐式修改数组长度的原型方法的拦截

数组的栈方法,例如 push/pop/shift/unshift 以及 splice 方法会隐式地修改数组长度。

根据 ECMAScript 规范,这些方法在执行的过程中,既会读取数组的 length 属性值,也会设置数组的 length 属性值。

这些方法在调用时会间接读取/设置 length 属性,循环往复,就会导致调用栈溢出。为了避免这个问题,需要重写这些方法,屏蔽对 length 属性的读取,从而避免在它与副作用函数之间建立响应联系。如下面的代码所示:

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

function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}

  // 省略部分代码

  // 重写 改变数组长度的方法
  // push/pop/shift/unshift 以及splice 方法会隐式地修改数组长度
  // 这些方法在执行的过程中,既会读取数组的 length 属性值,也会设置数组的 length 属性值
  // 因此需要重写这些方法,屏蔽对 length 属性的读取,从而避免在它与副作用函数之间建立响应联系。
  ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      // 执行前禁用依赖收集,
      /**
       * 这里主要是为了避免改变数组长度时,会set length,形成track - trigger的死循环
       * 因此要暂停改变数组长度时的执行期间收集依赖
       */
      pauseTracking()
      // push/pop/shift/unshift/splice 方法的默认行为
      const res = (toRaw(this) as any)[key].apply(this, args)
      // 在调用原始方法之后,恢复原来的行为,即允许追踪
      resetTracking()
      return res
    }
  })
  return instrumentations
}

在上面的源码中,在执行 push/pop/shift/unshift/splice 方法的默认行为之前,调用 pauseTracking 函数来停止依赖收集 ,当这些方法的默认行为执行完毕后,再执行 resetTracking 函数来恢复依赖收集

pauseTracking 函数和 resetTracking 函数的定义如下:

js 复制代码
// packages/reactivity/src/effect.ts

export let shouldTrack = true;
const trackStack: boolean[] = [];

export function pauseTracking() {
  trackStack.push(shouldTrack);
  shouldTrack = false;
}

export function resetTracking() {
  const last = trackStack.pop();
  shouldTrack = last === undefined ? true : last;
}

可以看到,在上面的代码中,定义了一个标记变量 shouldTrack,它是一个布尔值,代表是否允许执行依赖收集。当 pauseTracking 函数被调用,即在执行 push/pop/shift/unshift/splice 方法的默认行为之前,标记变量 shouldTrack 的值会被设为 false,即停止依赖收集。当 resetTracking 函数被调用,即在执行 push/pop/shift/unshift/splice 方法的默认行为之后,标记变量 shouldTrack 的值会被设为 true,即恢复依赖收集。

在收集依赖的 track 函数中,就会根据标记变量 shouldTrack 的值来决定是否收集依赖。如下面的代码所示:

track 实现

js 复制代码
export function track(target: object, type: TrackOpTypes, key: unknown) {
  // 标记变量 shouldTrack,代表是否允许执行依赖收集
  if (shouldTrack && activeEffect) {
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = createDep()));
    }

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

    trackEffects(dep, eventInfo);
  }
}

可以看到,只有当标记变量 shouldTrack 的值为 true 时,才会执行依赖收集。这样,当 push/pop/shift/unshift/splice 方法间接读取 length 属性时,由于此时 shouldTrack 的值是 false,是停止依赖收集的状态,所以 length 属性与副作用函数之间不会建立响应联系。

3.2 数组元素或属性的设置拦截

通过索引设置数组元素的值时,会执行数组对象的内部方法 [[Set]],而内部方法 [[Set]] 依赖于 [[DefineOwnProperty]]

根据 ECMA 规范对数组对象的内部方法[[DefineOwnProperty]]的逻辑定义,如果设置的索引值大于数组当前的长度,那么要更新数组的 length 属性。所以当通过索引设置元素值时,可能会隐式地修改 length 属性。

lua 复制代码
arr[index] -> [[Set]] -> [[DefineOwnProperty]] -> index 大于 length -> update length

所以通过索引设置元素值时要触发响应,也应该触发与 length 属性相关联的副作用函数重新执行。

js 复制代码
// set操作符的拦截
class MutableReactiveHandler extends BaseReactiveHandler {
  constructor(shallow = false) {
    super(false, shallow)
  }
  set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    let oldValue = (target as any)[key]

    // 省略部分代码

    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
    // 这里判断receiver是proxy实例才更新派发,防止通过原型链触发拦截器触发更新。
    // target === receiver.raw ,说明 receiver 就是 target 的代理对象
    // 只有当 receiver 是target 的代理对象时才触发更新
    if (target === toRaw(receiver)) {
      // 如果属性不存在,则说明是在添加新的属性,否则是设置已有属性
      if (!hadKey) {
        // 操作类型为 ADD 时,触发响应
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        // 比较新值与旧值,只有当它们不全等,并且都不是 NaN 的时候才触发响应
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

set 拦截函数内,如果代理的目标对象是数组,其被设置的索引值如果小于数组长度,说明被设置的索引值已经存在,则将其视作 SET 操作,因为它不会改变数组长度;如果设置的索引值大于数组的当前长度,则通过 hasOwn 方法判断索引是否存在,如果不存在,则视作 ADD 操作,因为这会隐式第改变数组的 length 属性值。

然后根据 type 的类型和 target 的类型,在 trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行。

trigger 实现

js 复制代码
// packages/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)
  if (!depsMap) {
    // never been tracked
    return
  }

  let deps: (Dep | undefined)[] = []
  if (type === TriggerOpTypes.CLEAR) {
    // collection being cleared
    // trigger all effects for target
    deps = [...depsMap.values()]
  } else if (key === 'length' && isArray(target)) {
    // 目标对象是数组时,取出与length 属性相关联的副作用函数,添加到依赖中
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        deps.push(dep)
      }
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    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)) {
          // 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数
          // 取出与length 属性相关联的副作用函数,添加到依赖中
          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
    }
  }

  // 省略部分代码
}

4、代理 Set 和 Map

MapSet 这两个数据类型的操作方法相似,它们之间最大的不同体现在,Set 类型使用 add(value) 方法添加元素,Map 类型使用 set(key, value) 方法设置键值对,并且 Map 类型可以使用 get(key) 方法读取相应的值。

4.1 Set

ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。

Set 结构的实例有以下属性:

  • Set.prototype.constructor:构造函数,默认就是 Set 函数。
  • Set.prototype.size:返回 Set 实例的成员总数。

Set 结构的实例有以下操作方法:

  • Set.prototype.add(value):添加某个值,返回 Set 结构本身。
  • Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
  • Set.prototype.has(value):返回一个布尔值,表示该值是否为 Set 的成员。
  • Set.prototype.clear():清除所有成员,没有返回值。

Set 结构的实例有四个遍历方法,用于遍历成员:

  • Set.prototype.keys():返回键名的遍历器
  • Set.prototype.values():返回键值的遍历器
  • Set.prototype.entries():返回键值对的遍历器
  • Set.prototype.forEach():使用回调函数遍历每个成员

4.2 Map

Map 结构的实例有以下属性:

  • Map.prototype.constructor:构造函数,默认就是 Map 函数。
  • Map.prototype.size:返回 Map 实例的成员总数。

Map 结构的实例有以下操作方法:

  • Map.prototype.get(key):读取 key 对应的键值,如果找不到 key,返回 undefined。
  • Map.prototype.set(key, value):设置键名 key 对应的键值为 value,然后返回整个 Map 结构。
  • Map.prototype.delete(key):删除某个键,返回 true。如果删除失败,返回 false。
  • Map.prototype.has(key):返回一个布尔值,表示某个键是否在当前 Map 对象之中。
  • Map.prototype.clear():清除所有成员,没有返回值。

Map 结构的实例有四个遍历方法,用于遍历成员:

  • Map.prototype.keys():返回键名的遍历器
  • Map.prototype.values():返回键值的遍历器
  • Map.prototype.entries():返回键值对的遍历器
  • Map.prototype.forEach():使用回调函数遍历每个成员

4.3 size 属性的拦截

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

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

// size 属性的拦截
function size(target: IterableCollections, isReadonly = false) {
  // 获取原始对象
  target = (target as any)[ReactiveFlags.RAW]
  // 只有非只读时才收集依赖,响应联系需要建立在 ITERATE_KEY 和副作用函数之间
  !isReadonly && track(toRaw(target), TrackOpTypes.ITERATE, ITERATE_KEY)
  // 指定第三个参数 receiver 为原始对象 target,
  // 从而修复拦截 size 属性时访问器属性的 getter 函数执行时的 this 指向问题
  return Reflect.get(target, 'size', target)
}

在上面的 size 属性拦截函数中,第一个参数 target 指向的是代理对象,因此首先需要通过代理对象的 raw 属性获取原始对象。然后在调用 Reflect.get 函数时指定第三个参数为原始对象,这样访问器属性 sizegetter 函数在执行时,其 this 指向的就是原始对象而非代理对象了。

4.4 get 方法的拦截

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

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 (key !== rawKey) {
    !isReadonly && track(rawTarget, TrackOpTypes.GET, key)
  }
  // 追踪依赖,建立响应联系
  !isReadonly && track(rawTarget, TrackOpTypes.GET, rawKey)
  const { has } = getProto(rawTarget)
    // wrap 函数用来把可代理的值转换为响应式数据
  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 方法获取原始对象的属性值,并调用 warp 函数将其转换成响应式数据并返回。

4.5 set 方法的拦截

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

function set(this: MapTypes, key: unknown, value: unknown) {
  // 获取原始数据,由于value本身可能已经是原始数据,所以此时value.raw 不存在,则直接使用 value
  value = toRaw(value)
  // 获取原始对象
  const target = toRaw(this)
  const { has, get } = getProto(target)

  // 先判断要设置的 key 是否存在
  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)
  //如果不存在,则说明是ADD 类型的操作,意味着新增
  if (!hadKey) {
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) {
    // 如果存在,并且值变了,则是 SET 类型的操作,意味着修改
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
  return this
}

在 set 拦截函数中,其中一个关键点在于首先通过 raw 属性获取原始数据,然后再把原始数据设置到 target 上,这就避免了由于 value 可能是响应式数据而污染原始数据。

而另一个关键点在于,需要判断设置的 key 是否存在,以便区分操作的类型是 SET 还是 ADD。对于 SET 类型和 ADD 类型的操作来说,它们最终触发的副作用函数是不同的。因为 ADD 类型的操作会对数据的 size 属性产生影响。所以任何依赖 size 属性的副作用函数都需要在 ADD 类型的操作发生时重新执行。

在 trigger 函数中,根据 type 的类型和 target 的类型,正确地触发与 Map 类型相关联的副作用函数重新执行,如下代码所示:

js 复制代码
export 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) {
    // never been tracked
    return
  }

  let deps: (Dep | undefined)[] = []

  // 省略部分代码

  else {

    // 省略部分代码

    // also run for iteration key on ADD | DELETE | Map.SET
    // 将与 Map 相关的副作用函数从 depsMap 中取出来,添加到依赖集合中
    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
    }
  }

  // 省略部分代码
}

4.6 has 方法的拦截

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

// has 操作符的拦截
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 (key !== rawKey) {
    !isReadonly && track(rawTarget, TrackOpTypes.HAS, key)
  }
  // 追踪依赖,建立响应联系
  !isReadonly && track(rawTarget, TrackOpTypes.HAS, rawKey)
  return key === rawKey
    ? target.has(key)
    : target.has(key) || target.has(rawKey)
}

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

4.7 add 方法的拦截

在调用 Set.prototype.add 函数向集合中添加数据时,会间接改变集合的 size 属性,因此,需要在访问 size 属性时调用 track 函数进行依赖追踪,然后在 add 方法执行时调用 trigger 函数触发响应。size 属性的拦截上文已有介绍,这里我们来看看 add 方法的拦截。

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

// add 方法的拦截
function add(this: SetTypes, value: unknown) {
  // 获取原始数据
  value = toRaw(value)
  // add 拦截函数里的 this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象
  const target = toRaw(this)
  const proto = getProto(target)
  // 先判断值是否已经存在
  const hadKey = proto.has.call(target, value)
  // 只有在值不存在的情况下,才需要触发响应
  if (!hadKey) {
    // 通过原始数据对象执行 add 方法添加具体的值
    // 注意,这里不再需要 .bind 了,因为是直接通过原始数据对象 target 调用并执行的
    target.add(value)
    // 调用 trigger 函数触发响应,并指定从操作类型为 ADD
    trigger(target, TriggerOpTypes.ADD, value, value)
  }
  return this
}

在 add 拦截函数中,会先判断值是否已经存在,因为只有在值不存在的情况下,才需要触发响应,这样做对性能更好。值得注意的是,在调用 trigger 函数触发响应时,指定了操作类型为 ADD,这一点很重要。在 trigger 函数的实现中,当操作类型是 ADD 或 DELETE 时,会取出与 ITERATE_KEY 相关联的副作用函数执行,这样就可以触发通过访问 size 属性所收集的副作用函数来执行了。

4.8 delete 方法的拦截

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

// delete 操作的拦截
function deleteEntry(this: CollectionTypes, key: unknown) {
  // delete 拦截函数里的 this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象
  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
  // 通过原始数据对象执行 delete 方法删除具体的值
  // 注意,这里不再需要 .bind 了,因为是直接通过原始数据对象 target 调用并执行的
  const result = target.delete(key)
  // 当要删除的元素确实存在时,才触发响应
  if (hadKey) {
    // 调用 trigger 函数触发响应,并指定操作类型为 DELETE
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  // 返回操作结果
  return result
}

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

4.9 clear 方法的拦截

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

// clear  操作的拦截
function clear(this: IterableCollections) {
   // clear 拦截函数里的 this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象
  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
  // 通过原始数据对象执行 clear 方法清除所有成员
  const result = target.clear()
  // 还存在元素时才触发响应
  if (hadItems) {
    // 调用 trigger 函数触发响应,并指定操作类型为 CLEAR
    trigger(target, TriggerOpTypes.CLEAR, undefined, undefined, oldTarget)
  }
  return result
}

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

4.10 forEach 遍历方法的拦截

js 复制代码
// packages/reactivity/src/collectionHandlers.ts

// forEach 遍历的拦截
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)
    })
  }
}

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

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

forEach 遍历 Map 类型的数据时,即关心键,又关心值。 如果操作类型是 SET ,并且目标对象是 Map 类型的数据,应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。如下面 trigger 函数中的代码所示:

js 复制代码
export 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) {
    // never been tracked
    return
  }

  let deps: (Dep | undefined)[] = []

  // 省略部分代码

  else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {
      // 省略部分代码

      // 操作类型是 SET ,并且目标对象是 Map 类型的数据,
      // 应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }

  // 省略部分代码
}

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

4.11 迭代器方法的拦截

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

  • entries
  • keys
  • values

调用这些方法会得到相应的迭代器,并且可以使用 for...of 进行循环迭代。由于 MapSet 类型本身部署了 Symbol.iterator 方法,因此可以使用 for...of 进行迭代。

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

js 复制代码
// 迭代器方法
function createIterableMethod(
  method: string | symbol,
  isReadonly: boolean,
  isShallow: boolean
) {
  return function (
    this: IterableCollections,
    ...args: unknown[]
  ): Iterable & Iterator {
    // 获取原始数据对象 target
    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
    // 获取原始迭代器方法
    // 分别通过 'keys', 'values', 'entries', Symbol.iterator 方法获取原始迭代器方法
    const innerIterator = target[method](...args)
     // wrap 函数用来把可代理的值转换为响应式数据
    const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive
    // 调用 track 函数建立响应联系
    // keys 方法只关心 Map 数据的键的变化,因此应该使用 MAP_KEY_ITERATE_KEY 来建立依赖关系
    !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 }
          : {
              // 如果 isPair 不是 undefined,则对 value 进行包装
              // isPair 是 entries 或者是 Symbol.iterator,处理的是键值对 [wrap(value[0]), wrap(value[1])]
              // isPair 是 values 或者是 keys,则处理的是值,即  wrap(value)
              value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
              done
            }
      },
      // iterable protocol
      // 可迭代协议
      [Symbol.iterator]() {
        return this
      }
    }
  }
}

4.11.1 建立响应联系

在使用 for...of 循环遍历 keys 时 ( for (let key of map.keys()) {} ),Map 类型数据的所有健都没有发生变化,在理想情况下,副作用函数是不应该执行的。因此,在调用 track 函数建立响应联系时,使用 MAP_KEY_ITERATE_KEY 来建立响应联系。而 values、entries、Symbol.iterator 三者则使用 ITERATE_KEY 来建立响应联系。

js 复制代码
// 调用 track 函数建立响应联系
// keys 方法只关心 Map 数据的键的变化,因此应该使用 MAP_KEY_ITERATE_KEY 来建立依赖关系
!isReadonly &&
  track(
    rawTarget,
    TrackOpTypes.ITERATE,
    isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY
  )

在触发副作用函数重新执行时,当操作类型为 ADDDELETE 类型,除了触发与 ITERATE_KEY 相关联的副作用函数重新执行,还需要触发与 MAP_KEY_ITERATE_KEY 相关联的副作用函数重新执行。如下面 trigger 函数中的代码所示:

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

  // 省略部分代码

  // 操作类型为 ADD 和 DELETE 类型,
  // 除了触发与 ITERATE_KEY 相关联的副作用函数重新执行,
  // 还需要触发与 MAP_KEY_ITERATE_KEY 相关联的副作用函数重新执行

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {
      case TriggerOpTypes.ADD:
        if (!isArray(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            // 操作类型为 ADD 时触发Map 数据结构的 keys 方法的副作用函数重新执行
            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)) {
            // 操作类型为 DELETE 时触发Map 数据结构的 keys 方法的副作用函数重新执行
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }、

  // 省略部分代理
}

4.11.2 返回响应式数据

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

在自定义的迭代器实现中,通过原始对象的迭代器方法获取原始迭代器方法,执行原始迭代器的 next 方法获取值 value 以及代表是否结束的 done。如果值不为 undefined,则对其进行包装。

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

javascript 复制代码
const isPair =
method === 'entries' || (method === Symbol.iterator && targetIsMap)
const isKeyOnly = method === 'keys' && targetIsMap
// 获取原始迭代器方法
// 分别通过 'keys', 'values', 'entries', Symbol.iterator 方法获取原始迭代器方法
const innerIterator = target[method](...args)
// wrap 函数用来把可代理的值转换为响应式数据
const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive


return {
    // iterator protocol
    // 迭代器协议
    next() {
      const { value, done } = innerIterator.next()
      return done
        ? { value, done }
        : {
            // 如果 isPair 不是 undefined,则对 value 进行包装
            // isPair 是 entries 或者是 Symbol.iterator,处理的是键值对 [wrap(value[0]), wrap(value[1])]
            // isPair 是 values 或者是 keys,则处理的是值,即  wrap(value)
            value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
            done
          }
    },

  }

4.11.3 返回可迭代对象

具有 Symbol.iterator() 方法的数据结构称为可迭代对象。例如,数组、字符串、集合等。迭代器是由 Symbol.iterator() 方法返回的对象。

js 复制代码
const obj = {
  [Symbol.iterator] : function () {
    return {
      next: function () {
        return {
          value: 1,
          done: true
        };
      }
    };
  }
};

上面代码中,对象 obj 是可遍迭代的(iterable),因为具有 Symbol.iterator 属性。执行这个属性,会返回一个迭代器对象。该对象的根本特征就是具有 next 方法。每次调用 next 方法,都会返回一个代表当前成员的信息对象,具有 valuedone 两个属性。

可迭代协议 :指的是一个对象实现了 Symbol.iterator 方法

迭代器协议 :指的是一个对象实现了 next 方法

一个对象可以同时实现可迭代协议和迭代器协议,例如:

js 复制代码
const obj = {
  // 迭代器协议
  next() {
    // ...
  }

  // 可迭代协议
  [Symbol.iterator]() {
    return this
  }
}

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

js 复制代码
return {
  // iterator protocol
  // 迭代器协议
  next() {
    const { value, done } = innerIterator.next()
    return done
      ? { value, done }
      : {
          // 如果 isPair 不是 undefined,则对 value 进行包装
          // isPair 是 entries 或者是 Symbol.iterator,处理的是键值对 [wrap(value[0]), wrap(value[1])]
          // isPair 是 values 或者是 keys,则处理的是值,即  wrap(value)
          value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
          done
        }
  },
  // iterable protocol
  // 可迭代协议
  [Symbol.iterator]() {
    return this
  }
}

5、总结

Vue 3 的响应式数据是基于 Proxy 实现的,Proxy 可以为其它对象创建一个代理对象。所谓代理,指的是对一个对象基本语义的代理 ,它允许我们拦截并重新定义对一个对象的基本操作。本文分别介绍了 Proxy 对于对象 Object、数组 Array 以及 Map、Set、WeakMap、WeakSet 等集合的代理。

Proxy 代理对象的本质,就是从 ECMA 规范中找到可拦截的基本操作的方法。例如对于属性的读取,通过 get 拦截函数来实现代理,对于 in 操作符,通过 has 拦截函数实现对 in 操作符的代理。对于设置属性,可以通过 set 拦截函数来实现拦截。对于 delete 操作符,可以使用 deleteProperty 拦截。

使用 Proxy 代理数组时,对于数组元素或属性的读取及设置,仍然可以使用普通对象的拦截函数来拦截。对于数组的查找方法以及栈方法,则是重写这些方法,从而实现拦截。

对于 Map、Set、WeakMap、WeakSet 等集合类型,在使用 Proxy 实现代理时,需要通过重写集合的方法来实现自定义的能力。

相关推荐
gqkmiss几秒前
Chrome 浏览器 131 版本开发者工具(DevTools)更新内容
前端·chrome·浏览器·chrome devtools
Summer不秃6 分钟前
Flutter之使用mqtt进行连接和信息传输的使用案例
前端·flutter
旭日猎鹰10 分钟前
Flutter踩坑记录(二)-- GestureDetector+Expanded点击无效果
前端·javascript·flutter
Viktor_Ye17 分钟前
高效集成易快报与金蝶应付单的方案
java·前端·数据库
hummhumm19 分钟前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
乐闻x1 小时前
Vue.js 性能优化指南:掌握 keep-alive 的使用技巧
前端·vue.js·性能优化
一条晒干的咸魚1 小时前
【Web前端】创建我的第一个 Web 表单
服务器·前端·javascript·json·对象·表单
Amd7941 小时前
Nuxt.js 应用中的 webpack:compiled 事件钩子
前端·webpack·开发·编译·nuxt.js·事件·钩子
生椰拿铁You1 小时前
09 —— Webpack搭建开发环境
前端·webpack·node.js
狸克先生1 小时前
如何用AI写小说(二):Gradio 超简单的网页前端交互
前端·人工智能·chatgpt·交互