精读《Vuejs设计与实现》第 5 章(非原始值响应式方案)

5.1 理解 Proxy 和 Reflect

Proxy 可以创建一个代理对象,实现对其他对象的代理,拦截并重新定义对对象的基本操作。

注意,Proxy 只能代理对象,不能代理非对象值(如字符串、布尔值等)。

基本操作包括读取属性值、设置属性值等。例如:

javascript 复制代码
obj.foo // 读取属性 foo 的值
obj.foo++ // 读取并设置属性 foo 的值

可以使用 Proxy 拦截基本操作:

javascript 复制代码
const p = new Proxy(obj, {
  // 拦截读取属性操作
  get() { /*...*/ },
  // 拦截设置属性操作
  set() { /*...*/ }
})

Proxy 构造函数接收两个参数:被代理对象和一个包含一组拦截函数的对象(trap夹子)。get 函数用于拦截读取操作,set 函数用于拦截设置操作。

在 JS 中,函数也是对象,所以调用函数也是对一个对象的基本操作:

javascript 复制代码
const fn = (name) => {
  console.log('我是:', name)
}

// 调用函数
fn()

我们可以用 Proxy 里的 apply 函数进行拦截:

javascript 复制代码
const p2 = new Proxy(fn, {
  // 使用 apply 拦截函数调用
  apply(target, thisArg, argArray) {
    target.call(thisArg, ...argArray)
  }
})

p2('hcy') // 输出:'我是:hcy'

Proxy 只能拦截对象的基本操作。

非基本操作,如调用对象下的方法(称为复合操作):

javascript 复制代码
obj.fn()

复合操作实际上由两个基本操作组成的:首先是 get 操作得到 obj.fn 属性,其次是函数调用。即获得 obj.fn 值后再调用它,这就是我们刚才提到的 apply。

理解 Proxy 只能代理对象的基本操作对于后续实现数组或 Map、Set 等数据类型的代理至关重要。

我们来看 Reflect。Reflect 是一个全局对象,提供了一些方法,例如:

  1. Reflect.get()
  2. Reflect.set()
  3. Reflect.apply()

Reflect 中的方法与 Proxy 的拦截器方法同名。它们提供了对象操作的默认行为。例如,以下两个操作是等价的:

javascript 复制代码
const obj = { foo: 1 }

// 直接读取
console.log(obj.foo) // 1

// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo')) // 1

如果两种操作等价,Reflect 存在的意义是什么呢?

Reflect.get() 还接受第三个参数,也就是 receiver,你可以将它看作函数调用中的 this,例如:

javascript 复制代码
const obj = { foo: 1 }

console.log(Reflect.get(obj, 'foo', { foo: 2 }))  // 输出的是 2 而不是 1

在这段代码中,我们指定第三个参数 receiver 为一个对象 { foo: 2 },这时读取到的值是 receiver 对象的 foo 属性值。

事实上,Reflect 的各个方法都有很多其他用途,但在此我们只关注与响应式数据实现相关的部分,我们回顾一下上一节的响应式代码:

javascript 复制代码
const obj = { foo: 1 }

const p = new Proxy(obj, {
  get(target, key) {
    track(target, key)
    // 注意,这里我们没有使用 Reflect.get 完成读取
    return target[key]
  },
  set(target, key, newVal) {
    // 这里同样没有使用 Reflect.set 完成设置
    target[key] = newVal
    trigger(target, key)
  }
})

在 get 和 set 拦截函数中,我们都是直接使用原始对象 target 来完成对属性的读取和设置操作的,其中原始对象 target 就是上述代码中的 obj 对象。

然而,这段代码存在一些问题。通过 effect 可以看出。首先,我们修改一下 obj 对象,为其添加一个 bar 属性:

javascript 复制代码
const obj = {
  foo: 1,
  get bar() {
    return this.foo
  }
}

上述代码 bar 是一个访问器属性,它返回了 this.foo 的值。接下来,我们在 effect 的副作用函数中通过代理对象 p 访问 bar 属性:

javascript 复制代码
effect(() => {
  console.log(p.bar) // 1
})

这个过程中发生了什么?当执行 effect 注册的副作用函数时,会读取 p.bar 属性。

因为 p.bar 是一个访问器属性,所以会执行 getter 函数。

getter 函数通过 this.foo 读取了 foo 属性值,所以我们认为副作用函数和 foo 属性之间会建立联系。当我们尝试改变 p.foo 的值时:

javascript 复制代码
p.foo++

副作用函数并没有重新执行。问题在哪里呢?

实际上,问题出在 bar 属性的 getter 函数里:

javascript 复制代码
const obj = {
  foo: 1,
  get bar() {
    // 这里的 this 指向的是谁?
    return this.foo
  }
}

当我们使用 this.foo 读取 foo 属性值时,这里的 this 指向的是谁呢?

我们回顾一下整个流程。首先,我们通过代理对象 p 访问 p.bar,这会触发代理对象的 get 拦截函数:

javascript 复制代码
const p = new Proxy(obj, {
  get(target, key) {
    track(target, key)
    // 注意,这里我们没有使用 Reflect.get 完成读取
    return target[key]
  },
  // 省略部分代码
})

在 get 拦截函数内,通过 target[key] 返回属性值。这里的 target 是原始对象 obj,key 是字符串 'bar',所以 target[key] 相当于 obj.bar。

因此,当我们使用 p.bar 访问 bar 属性时,getter 函数内的 this 指向的其实是原始对象 obj,这意味着我们实际上是在访问 obj.foo。很明显,通过原始对象访问属性无法建立响应联系,相当于下面:

javascript 复制代码
effect(() => {
  // obj 是原始数据,不是代理对象,这样的访问不能够建立响应联系
  obj.foo
})

这就是问题所在,无法触发响应。那么该如何解决这个问题呢?这时 Reflect.get 函数就派上用场了。我们可以修改代码如下:

javascript 复制代码
const p = new Proxy(obj, {
  // 拦截读取操作,接收第三个参数 receiver
  get(target, key, receiver) {
    track(target, key)
    // 使用 Reflect.get 返回读取到的属性值
    return Reflect.get(target, key, receiver)
  },
  // 省略部分代码
})

以上代码中,代理对象的 get 拦截函数接收了第三个参数 receiver,它代表了谁在读取属性。

例如,当我们使用代理对象 p 访问 bar 属性时,receiver 就是 p。你可以将其理解为函数调用中的 this。

我们使用 Reflect.get(target, key, receiver) 代替之前的 target[key]。

关键在于这个第三个参数 receiver。我们已经知道 receiver 是代理对象 p,所以在访问器属性 bar 的 getter 函数内的 this 就指向了代理对象 p:

javascript 复制代码
const obj = {
  foo: 1,
  get bar() {
    // 现在这里的 this 为代理对象 p
    return this.foo
  }
}

可以看到,this 从原始对象 obj 变成了代理对象 p。这会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集的效果。

如果此时再对 p.foo 进行自增操作,副作用函数就会被重新执行。

5.2 JavaScript 对象和 Proxy 的工作原理

根据规范,JavaScript中有两种对象:常规对象(ordinary object)和异质对象(exotic object)。这两种对象涵盖了JavaScript世界中的所有对象。

任何非常规对象都是异质对象。要理解常规对象和异质对象的区别,我们需要了解对象的内部方法和内部槽。

在 JS 中,函数也是对象。假设我们有一个对象 obj,如何判断它是普通对象还是函数呢?

在 JS 中,对象的实际语义由其内部方法(internal method)定义。

所谓内部方法,是指在对对象进行操作时,引擎内部调用的方法。这些方法对 JavaScript 使用者来说是不可见的。例如,当我们访问对象属性时:

javascript 复制代码
obj.foo

引擎内部会调用 [[Get]] 这个内部方法来读取属性值。

在ECMAScript规范中,使用 [[xxx]] 表示内部方法或内部槽。一个对象不仅部署了 [[Get]] 这个内部方法,规范还要求部署一系列其他必要的内部方法。

包括 [[Get]] 在内,一个对象必须部署 11 个必要的内部方法:


还有两个额外的必要内部方法

如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法 [[Call]]。

我们可以通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [[Call]],而普通对象则不会。

内部方法具有多态性,类似于面向对象编程中的多态概念。这意味着不同类型的对象可能部署了相同的内部方法,但具有不同的逻辑。

例如,普通对象和 Proxy 对象都部署了 [[Get]] 这个内部方法,但它们的逻辑是不同的。

所有不符合这三点要求的对象都是异质对象:

  • 对于表 5-1 列出的内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义实现;
  • 对于内部方法 [[Call]],必须使用 ECMA 规范 10.2.1 节给出的定义实现;
  • 对于内部方法 [[Construct]],必须使用 ECMA 规范 10.2.2 节给出的定义实现;

由于 Proxy 对象的内部方法[[Get]] 没有使用 ECMA 规范的 10.1.8 节给出的定义实现,所以 Proxy 是一个异质对象。

既然 Proxy 也是对象,那么它本身也部署了上述必要的内部方法,当我们通过代理对象访问属性值时:

javascript 复制代码
const p = new Proxy(obj, {/* ... */})
p.foo

引擎会调用部署在对象 p 上的内部方法 [[Get]]。

如果我们没有指定 get() 拦截函数,通过代理对象访问属性值时,代理对象的内部方法 [[Get]] 会调用原始对象的内部方法 [[Get]] 来获取属性值。

所以实质上创建代理对象时指定的拦截函数,是用来自定义代理对象本身的内部方法和行为的,而不是指定被代理对象的内部方法和行为的。

下面是 Proxy 对象部署的所有内部方法和对应的拦截器明仔:

内部方法 处理器函数
[[GetPrototypeOf]] getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf
[[IsExtensible]] isExtensible
[[PreventExtensions]] preventExtensions
[[GetOwnProperty]] getOwnPropertyDescriptor
[[DefineOwnProperty]] defineProperty
[[HasProperty]] has
[[Get]] get
[[Set]] set
[[Delete]] deleteProperty
[[OwnPropertyKeys]] ownKeys
[[Call]] apply
[[Construct]] construct

当被代理的对象是函数和构造函数时,才会部署内部方法 [[Call]] 和 [[Construct]]。

当我们需要拦截删除属性操作时,可以使用 deleteProperty 拦截函数实现:

javascript 复制代码
const obj = { foo: 1 }
const p = new Proxy(obj, {
  deleteProperty(target, key) {
    return Reflect.deleteProperty(target, key)
  }
})

console.log(p.foo) // 1
delete p.foo
console.log(p.foo) // 未定义

这里需要强调的是,deleteProperty 实现的是代理对象 p 的内部方法和行为。

为了删除被代理对象上的属性值,我们需要使用 Reflect.deleteProperty(target, key) 来完成。

5.3 如何代理 Object

之前我们使用了 get 拦截函数来拦截属性的读取操作实现响应式数据,

然而,在响应系统中,"读取"是一个广泛的概念。例如,使用 in 操作符检查对象上的 key 也属于"读取"操作,如下面的代码所示:

javascript 复制代码
effect(() => {
  'foo' in obj
});

这本质上也是在进行"读取"操作。响应系统应该拦截所有读取操作,以便在数据变化时正确地触发响应。以下是普通对象所有可能的读取操作:

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

首先,可以通过 get 拦截器实现属性访问:

javascript 复制代码
const obj = { foo: 1 }

const p = new Proxy(obj, {
  get(target, key, receiver) {
    // 建立联系
    track(target, key)
    // 返回属性值
    return Reflect.get(target, key, receiver)
  }
})

为拦截 in 操作符,我们需要使用 has 拦截器:

javascript 复制代码
const obj = { foo: 1 }
const p = new Proxy(obj, {
  has(target, key) {
    track(target, key)
    return Reflect.has(target, key)
  }
})

这样,当我们在副作用函数中通过 in 操作符操作响应式数据时,就能够建立依赖关系:

javascript 复制代码
effect(() => {
  'foo' in p; // 将会建立依赖关系
});

要拦截 for...in 循环,我们使用 ownKeys 拦截器:

javascript 复制代码
const obj = { foo: 1 }
const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {
  ownKeys(target) {
    // 将副作用函数与 ITERATE_KEY 关联
    track(target, ITERATE_KEY)
    return Reflect.ownKeys(target)
  }
})

因为 ownKeys 拦截器是获取所有 key,无法获取具体操作的 key。在这里,我们使用 ITERATE_KEY 作为追踪的 key。

在触发响应时,也要触发 ITERATE_KEY:

javascript 复制代码
trigger(target, ITERATE_KEY)

在什么情况下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行?我们用一段代码来说明:

javascript 复制代码
const obj = { foo: 1 }
const p = new Proxy(obj, {/* ... */})

effect(() => {
  for (const key in p) {
    console.log(key) // foo
  }
})

执行副作用函数后,会与 ITERATE_KEY 建立响应联系。然后,我们尝试为对象 p 添加新属性 bar:

javascript 复制代码
p.bar = 2

由于对象 p 原本只有 foo 属性,因此 for...in 循环只会执行一次。现在为它添加了新的属性 bar,所以 for...in 循环就会由执行一次变成执行两次。

也就是说,当为对象添加新属性时,会对 for...in 循环产生影响,所以需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。但目前的实现还做不到这一点。

当我们为对象 p 添加新的属性 bar 时,并没有触发副作用函数重新执行,这是为什么呢?我们来看一下现在的 set 拦截函数的实现:

javascript 复制代码
const p = new Proxy(obj, {
  // 拦截设置操作
  set(target, key, newVal, receiver) {
    // 设置属性值
    const res = Reflect.set(target, key, newVal, receiver)
    // 把副作用函数从桶里取出并执行
    trigger(target, key)

    return res
  }
  // 省略其他拦截函数
})

当为对象 p 添加新的 bar 属性时,会触发 set 拦截函数执行。

此时 set 拦截函数接收到的 key 就是字符串 'bar',因此最终调用 trigger 函数时也只是触发了与'bar' 相关联的副作用函数重新执行。

但是 for...in 循环是在副作用函数与 ITERATE_KEY 之间建立联系,这和 'bar' 一点儿关系都没有,,因此当我们尝试执行 p.bar = 2 操作时,并不能正确地触发响应。

因此我们需要当添加属性时,将那些与 ITERATE_KEY 相关联的副作用函数也取出来执行就可以了:

javascript 复制代码
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 取得与 key 相关联的副作用函数
  const effects = depsMap.get(key)
  // 取得与 ITERATE_KEY 相关联的副作用函数
  const iterateEffects = depsMap.get(ITERATE_KEY)

  const effectsToRun = new Set()
  // 将与 key 相关联的副作用函数添加到 effectsToRun
  effects &&
    effects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })
  
  // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun
  iterateEffects &&
    iterateEffects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })

  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

如上所示,当 trigger 函数执行时,除了把那些直接与具体操作的 key 相关联的副作用函数取出来执行外,还要把那些与 ITERATE_KEY 相关联的副作用函数取出来执行。

添加新的属性来说,这么做没有什么问题,但修改已有属性,就有问题了,看如下代码:

javascript 复制代码
const obj = { foo: 1 }
const p = new Proxy(obj, {
  /* ... */
})

effect(() => {
  // for...in 循环
  for (const key in p) {
    console.log(key) // foo
  }
})

当我们修改 p.foo 的值时:

javascript 复制代码
p.foo = 2

修改属性其实不会对 for...in 循环产生影响。因为无论怎么修改一个属性的值,对于 for...in 循环来说都只会循环一次。

所以在这种情况下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。

然而无论是添加新属性,还是修改已有的属性值,其基本语义都是 [[Set]],我们都是通过 set 拦截函数来实现拦截的,如以下代码所示:

javascript 复制代码
const p = new Proxy(obj, {
  // 拦截设置操作
  set(target, key, newVal, receiver) {
    // 设置属性值
    const res = Reflect.set(target, key, newVal, receiver)
    // 把副作用函数从桶里取出并执行
    trigger(target, key)

    return res
  }
  // 省略其他拦截函数
})

解决上述问题,我们可以在 set 拦截函数内去区分操作的类型,到底是添加新属性还是设置已有属性:

javascript 复制代码
const p = new Proxy(obj, {
  // 拦截设置操作
  set(target, key, newVal, receiver) {
    // 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
    const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'

    // 设置属性值
    const res = Reflect.set(target, key, newVal, receiver)

    // 将 type 作为第三个参数传递给 trigger 函数
    trigger(target, key, type)

    return res
  }
  // 省略其他拦截函数
})

以上代码,我们优先使用 Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上。

如果存在,则说明当前操作类型为 'SET',即修改属性值;否则认为当前操作类型为 'ADD',即添加新属性。

最后,我们把类型结果 type 作为第三个参数传递给 trigger 函数。

trigger 函数内就只有当操作类型 type 为 'ADD' 时,才会触发与 ITERATE_KEY 相关联的副作用函数重新执行就行了,避免不必要性能损耗:

javascript 复制代码
function trigger(target, key, type) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects &&
    effects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })

  console.log(type, key)
  // 只有当操作类型为 'ADD' 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行
  if (type === 'ADD') {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    iterateEffects &&
      iterateEffects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn)
        }
      })
  }

  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

通常我们会将操作类型封装为一个枚举值,例如:

javascript 复制代码
const TriggerType = {
  SET: 'SET',
  ADD: 'ADD'
}

这样代码比较清晰,对后期代码的维护,是非常有帮助的。

关于对象的代理,还有最后删除属性操作的代理:

javascript 复制代码
delete p.foo

delete 操作符的行为依赖 [[Delete]] 内部方法,该内部方法可以使用 deleteProperty 拦截:

javascript 复制代码
const p = new Proxy(obj, {
  deleteProperty(target, key) {
    // 检查被操作的属性是否是对象自己的属性
    const hadKey = Object.prototype.hasOwnProperty.call(target, key)
    // 使用 Reflect.deleteProperty 完成属性的删除
    const res = Reflect.deleteProperty(target, key)

    if (res && hadKey) {
      // 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新
      trigger(target, key, 'DELETE')
    }

    return res
  }
})

上述代码,首先检查被删除的属性是否属于对象自身,然后调用Reflect.deleteProperty 函数完成属性的删除工作。

只有当这两步的结果都满足条件时,才调用 trigger 函数触发副作用函数重新执行。

注意的是,在调用trigger 函数时,我们传递了新的操作类型 'DELETE'。由于删除操作会使得对象的键变少,它会影响 for...in 循环的次数,因此当操作类型为 'DELETE' 时,我们也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行:

javascript 复制代码
function trigger(target, key, type) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects &&
    effects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })

  // 当操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数重新执行
  if (type === 'ADD' || type === 'DELETE') {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    iterateEffects &&
      iterateEffects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn)
        }
      })
  }

  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

上述代码,我们添加了 type === 'DELETE' 判断,使得删除属性操作能够触发与 ITERATE_KEY 相关联的副作用函数重新执行。

5.4 合理触发响应

为了合理触发响应,我们需要处理一些问题。

首先,当值没有变化时,我们不应该触发响应:

javascript 复制代码
const obj = { foo: 1 }
const p = new Proxy(obj, { /* ... */ })

effect(() => {
  console.log(p.foo)
})

// 设置 p.foo 的值,但值没有变化
p.foo = 1

上述代码,p.foo 的初始值为 1,当为 p.foo 设置新的值时,如果值没有发生变化,则不需要触发响应。

为了满足需求,在调用 trigger 函数触发响应之前,我们需要检查值是否发生了变化:

javascript 复制代码
const p = new Proxy(obj, {
  set(target, key, newVal, receiver) {
    // 先获取旧值
    const oldVal = target[key]

    const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
    const res = Reflect.set(target, key, newVal, receiver)
    // 比较新值与旧值,只要当不全等的时候才触发响应
    if (oldVal !== newVal) {
      trigger(target, key, type)
    }
    return res
  }
})

在 set 函数内,先获取旧值 oldVal,比较新旧值,只有不全等时才触发响应。

但是,全等比较对 NaN 的处理有缺陷,因为 NaN === NaN 返回 false,为了解决这个问题,需要加一个条件:

javascript 复制代码
const p = new Proxy(obj, {
  set(target, key, newVal, receiver) {
    // 先获取旧值
    const oldVal = target[key]

    const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
    const res = Reflect.set(target, key, newVal, receiver)
    // 比较新值与旧值,只有当它们不全等,并且不都是 NaN 的时候才触发响应
    if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
      trigger(target, key, type)
    }

    return res
  }
})

现在,我们已经解决了对 NaN 的处理问题。当新旧值不全等且不都是 NaN 时,才触发响应。

我们还需要处理从原型上继承属性的情况。首先,我们封装一个 reactive 函数,接受一个对象作为参数,返回创建的响应式数据:

javascript 复制代码
function reactive(obj) {
  return new Proxy(obj, {
    // 省略拦截函数
  })
}

接下来,创建一个例子:

javascript 复制代码
const obj = {}
const child = reactive(obj)
const proto = { bar: 1 }
const parent = reactive(proto)
// 使用 parent 作为 child 的原型
Object.setPrototypeOf(child, parent)

effect(() => {
  console.log(child.bar) // 1
})
// 修改 child.bar 的值
child.bar = 2 // 会导致副作用函数重新执行两次

在这个例子中,我们创建了两个响应式对象 child 和 parent,并将 parent 设置为 child 的原型。

在副作用函数中访问 child.bar 时,值是从原型上继承的。当我们执行 child.bar = 2 时,副作用函数会执行两次,导致不必要的更新。

我们分析下整个过程,访问 child.bar 时,触发 child 代理对象的 get 拦截函数。在拦截函数中,引擎使用 Reflect.get(target, key, receiver) 得到结果。如果对象自身不存在该属性,会获取对象的原型,并调用原型的 [[Get]] 方法得到最终结果。

在这个例子中,由于 child 自身没有 bar 属性,所以最终得到的实际上是 parent.bar 的值。但 parent 本身也是响应式数据,因此在副作用函数中访问 parent.bar 的值时,会建立响应联系。所以,child.bar 和 parent.bar 都与副作用函数建立了响应联系。

当设置 child.bar 的值时,我们需要弄清楚为什么副作用函数会连续执行两次。在设置过程中,会先触发 child 代理对象的 set 拦截函数。由于 obj 上不存在 bar 属性,会取得 obj 的原型 parent,并执行 parent 代理对象的 set 拦截函数。这导致副作用函数被触发两次。

为了解决这个问题,我们可以在 set 拦截函数内区分这两次更新。当我们设置 child.bar 的值时,receiver 始终是 child,而 target 则会变化:

javascript 复制代码
// child 的 set 拦截函数
set(target, key, value, receiver) {
  // target 是原始对象 obj
  // receiver 是代理对象 child
}

// parent 的 set 拦截函数
set(target, key, value, receiver) {
  // target 是原始对象 proto
  // receiver 仍然是代理对象 child
}

我们只需要判断 receiver 是否是 target 的代理对象即可。只有当 receiver 是 target 的代理对象时才触发更新,从而屏蔽原型引起的更新。

这就需要我们为 get 拦截函数添加一个能力,使代理对象可以通过 raw 属性访问原始数据:

javascript 复制代码
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 代理对象可以通过 raw 属性访问原始数据
      if (key === 'raw') {
        return target
      }

      track(target, key)
      return Reflect.get(target, key, receiver)
    }
    // 省略其他拦截函数
  })
}

然后,在 set 拦截函数中判断 receiver 是不是 target 的代理对象:

javascript 复制代码
function reactive(obj) {
  return new Proxy(obj, {
    set(target, key, newVal, receiver) {
      const oldVal = target[key]
      const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
      const res = Reflect.set(target, key, newVal, receiver)

      // target === receiver.raw 说明 receiver 就是 target 的代理对象
      if (target === receiver.raw) {
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          trigger(target, key, type)
        }
      }

      return res
    }
    // 省略其他拦截函数
  })
}

通过这种方式,我们只在 receiver 是 target 的代理对象时触发更新,从而避免了由原型引起的不必要的更新操作。

5.5 浅响应与深响应

事实上,我们目前实现的 reactive 是浅响应的。看以下代码:

javascript 复制代码
const obj = reactive({ foo: { bar: 1 } })

effect(() => {
  console.log(obj.foo.bar)
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2

首先,创建了 obj 代理对象,该对象的 foo 属性值是另一个对象,即 { bar: 1 }。

然后,在副作用函数内访问 obj.foo.bar 的值。但我们发现,后续对 obj.foo.bar 的修改无法触发副作用函数的重新执行。

为什么呢?让我们看一下现有的实现:

javascript 复制代码
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }

      track(target, key)
      // 当读取属性值时,直接返回结果
      return Reflect.get(target, key, receiver)
    }
    // 省略其他拦截函数
  })
}

上述代码显示,当我们读取 obj.foo.bar 时,首先要读取 obj.foo 的值。

这里我们直接使用 Reflect.get 函数返回 obj.foo 的结果。

由于通过 Reflect.get 得到的 obj.foo 结果是一个普通对象,即 { bar: 1 },它不是响应式对象,因此在副作用函数中访问 obj.foo.bar 时,无法建立响应联系。

为解决此问题,我们需要对 Reflect.get 返回结果进行一层包装:

javascript 复制代码
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }

      track(target, key)
      // 得到原始值结果
      const res = Reflect.get(target, key, receiver)
      if (typeof res === 'object' && res !== null) {
        // 调用 reactive 将结果包装成响应式数据并返回
        return reactive(res)
      }
      // 返回 res
      return res
    }
    // 省略其他拦截函数
  })
}

如上述代码所示,当读取属性值时,我们首先检测该值是否是对象。如果是对象,就递归地调用 reactive 函数将其包装成响应式数据并返回。

这样,当使用 obj.foo 读取 foo 属性值时,得到的结果就是一个响应式数据。因此,再通过 obj.foo.bar 读取 bar 属性值时,就会自然地建立响应联系。这样,当修改 obj.foo.bar 的值时,就能触发副作用函数重新执行。

然而,并非所有情况下我们都希望深响应。这就产生了 shallowReactive,即浅响应。浅响应的是只有对象的第一层属性是响应的,例如:

javascript 复制代码
const obj = shallowReactive({ foo: { bar: 1 } })

effect(() => {
  console.log(obj.foo.bar)
})
// obj.foo 是响应的,可以触发副作用函数重新执行
obj.foo = { bar: 2 }
// obj.foo.bar 不是响应的,不能触发副作用函数重新执行
obj.foo.bar = 3

在这个例子中,我们使用 shallowReactive 函数创建了一个浅响应的代理对象 obj。

可以发现,只有对象的第一层属性是响应的,第二层及更深层次的属性则不是响应的。

实现此功能并不难,如下面的代码所示:

javascript 复制代码
// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
function createReactive(obj, isShallow = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }

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

      track(target, key)

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

      if (typeof res === 'object' && res !== null) {
        return reactive(res)
      }

      return res
    }
    // 省略其他拦截函数
  })
}

// 使用 createReactive 函数轻松实现 reactive 和 shallowReactive 函数
function reactive(obj) {
  return createReactive(obj)
}
function shallowReactive(obj) {
  return createReactive(obj, true)
}

在上述代码中,我们将对象创建的工作封装到一个新的函数 createReactive 中。

该函数除了接收原始对象 obj 之外,还接收参数 isShallow,它是一个布尔值,代表是否创建浅响应对象。

有了 createReactive 函数后,我们就可以使用它轻松地实现 reactive 和 shallowReactive 函数。

5.6 只读和浅只读

有时我们希望某些数据是只读的,即用户尝试修改时会收到警告。

例如,组件接收到的 props 应该是只读的。这时我们可以使用 readonly 函数将数据设为只读:

javascript 复制代码
const obj = readonly({ foo: 1 })
// 尝试修改数据,会得到警告
obj.foo = 2

只读本质上也是对数据对象的代理,我们可以为 createReactive 函数增加第三个参数 isReadonly 来实现:

javascript 复制代码
// 增加第三个参数 isReadonly,代表是否只读,默认为 false,即非只读
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截设置操作
    set(target, key, newVal, receiver) {
      // 如果是只读的,则打印警告信息并返回
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      const oldVal = target[key]
      const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
      const res = Reflect.set(target, key, newVal, receiver)
      if (target === receiver.raw) {
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          trigger(target, key, type)
        }
      }

      return res
    },
    deleteProperty(target, key) {
      // 如果是只读的,则打印警告信息并返回
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      const hadKey = Object.prototype.hasOwnProperty.call(target, key)
      const res = Reflect.deleteProperty(target, key)

      if (res && hadKey) {
        trigger(target, key, 'DELETE')
      }

      return res
    }
    // 省略其他拦截函数
  })
}

当使用 createReactive 创建代理对象时,可以通过第三个参数指定是否创建一个只读的代理对象

同时,我们还修改了 set 拦截函数和 deleteProperty 拦截函数的实现,因为对于一个对象来说,只读意味着既不可以设置对象的属性值,也不可以删除对象的属性。

当然,如果一个数据是只读的,那就意味着任何方式都无法修改它,所以也就不需要调用 track 函数追踪响应:

javascript 复制代码
const obj = readonly({ foo: 1 });
effect(() => {
  obj.foo; // 可以读取值,但是不需要在副作用函数与数据之间建立响应联系
});

为了实现该功能,我们需要修改 get 拦截函数的实现:

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
      // 非只读的时候才需要建立响应联系
      if (!isReadonly) {
        track(target, key)
      }

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

      if (isShallow) {
        return res
      }

      if (typeof res === 'object' && res !== null) {
        return reactive(res)
      }

      return res
    }
    // 省略其他拦截函数
  })
}

如上面的代码所示,只有非只读的时候才需要建立响应联系。基于此,我们就可以实现 readonly 函数了:

javascript 复制代码
function readonly(obj) {
  return createReactive(obj, false, true /* 只读 */);
}

然而,上面实现的 readonly 函数更应该叫作 shallowReadonly,因为它没有做到深只读:

javascript 复制代码
const obj = readonly({ foo: { bar: 1 } });
obj.foo.bar = 2; // 仍然可以修改

所以为了实现深只读,我们还应该在 get 拦截函数内递归地调用 readonly 将数据包装成只读的代理对象,并将其作为返回值返回:

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
      if (!isReadonly) {
        track(target, key)
      }

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

      if (isShallow) {
        return res
      }

      if (typeof res === 'object' && res !== null) {
        // 如果数据为只读,则调用 readonly 对值进行包装
        return isReadonly ? readonly(res) : reactive(res)
      }

      return res
    }
    // 省略其他拦截函数
  })
}

上述代码,我们判断是否只读,如果只读则调用 readonly 函数对值进行包装,并把包装后的只读对象返回。

对于 shallowReadonly,实际上我们只需要修改 createReactive 的第二个参数即可:

javascript 复制代码
function readonly(obj) {
  return createReactive(obj, false, true);
}

function shallowReadonly(obj) {
  return createReactive(obj, true, true);
}

上述代码,在 shallowReadonly 函数内调用 createReactive 函数创建代理对象时,将第二个参数 isShallow 设置为 true,这样就可以创建一个浅只读的代理对象了。

5.7 代理数组

JS 中的数组是一种异质对象,其 [[DefineOwnProperty]] 内部方法与常规对象不同。

但除此之外,数组的其他内部方法与常规对象相同。因此,在实现数组代理时,大部分用于代理普通对象的代码依然适用,如下:

javascript 复制代码
const arr = reactive(['foo'])

effect(() => {
  console.log(arr[0]) // 'foo'
})

arr[0] = 'bar' // 触发响应

上述代码,我们通过索引读取或设置数组元素的值时,代理对象的 get/set 拦截函数也会执行,使得数组索引的读取和设置操作是响应式的。

然而,数组操作与普通对象操作仍有不同。数组的读取操作包括:

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

数组的设置操作包括:

  • 通过索引修改数组元素值:arr[1] = 3
  • 修改数组长度:arr.length = 0
  • 数组的栈方法:push、pop、shift、unshift
  • 修改原数组的原型方法:splice、fill、sort 等

虽然代理数组相对复杂,但因为数组本身也是对象,所以大部分用于代理常规对象的代码对数组依然有效。

接下来,我们将探讨如何通过索引读取或设置数组元素值。

5.7.1 数组的索引与 length

在前面的例子中,通过数组的索引访问元素值已经建立了响应关系。

但是,通过索引设置数组元素值与设置对象属性值仍存在根本差异,因为数组对象部署的 [[DefineOwnProperty]] 内部方法不同于常规对象。

规范明确说明,如果设置的索引值大于数组当前长度,需要更新数组的 length 属性。因此,在触发响应时,也应触发与 length 属性相关联的副作用函数重新执行。

javascript 复制代码
const arr = reactive(['foo']) // 数组的原长度为 1

effect(() => {
  console.log(arr.length) // 1
})

// 设置索引 1 的值,会导致数组的长度变为 2
arr[1] = 'bar'

为了实现这个目标,我们需要修改 set 拦截函数:

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截设置操作
    set(target, key, newVal, receiver) {
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      const oldVal = target[key]
      // 如果属性不存在,则说明是在添加新的属性,否则是设置已有属性
      const type = Array.isArray(target)
        ? // 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,
          // 如果是,则视作 SET 操作,否则是 ADD 操作
          Number(key) < target.length
          ? 'SET'
          : 'ADD'
        : Object.prototype.hasOwnProperty.call(target, key)
        ? 'SET'
        : 'ADD'

      const res = Reflect.set(target, key, newVal, receiver)
      if (target === receiver.raw) {
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          trigger(target, key, type)
        }
      }

      return res
    }
    // 省略其他拦截函数
  })
}

在判断操作类型时,我们新增了对数组类型的判断。如果代理的目标对象是数组,那么对于操作类型的判断会有所区别。

接下来,我们可以在 trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行:

javascript 复制代码
function trigger(target, key, type) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 省略部分内容

  // 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数
  if (type === 'ADD' && Array.isArray(target)) {
    // 取出与 length 相关联的副作用函数
    const lengthEffects = depsMap.get('length')
    // 将这些副作用函数添加到 effectsToRun 中,待执行
    lengthEffects &&
      lengthEffects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn)
        }
      })
  }

  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

这样,我们就实现了当数组长度发生变化时,正确地触发与 length 属性相关联的副作用函数重新执行。

在另一方面,实际上修改数组的 length 属性也会隐式地影响数组元素。例如:

javascript 复制代码
const arr = reactive(['foo'])

effect(() => {
  // 访问数组的第 0 个元素
  console.log(arr[0]) // foo
})

// 将数组的长度修改为 0,导致第 0 个元素被删除,因此应该触发响应
arr.length = 0

然而,并非所有对 length 属性的修改都会影响数组中的已有元素。

上面如果我们将 length 属性设置为 100,这并不会影响第 0 个元素,所以也就不需要触发副作用函数重新执行

当修改 length 属性值时,只有那些索引值大于或等于新的 length 属性值的元素才需要触发响应。

为了实现这一目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去:

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截设置操作
    set(target, key, newVal, receiver) {
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      const oldVal = target[key]

      const type = Array.isArray(target)
        ? Number(key) < target.length
          ? 'SET'
          : 'ADD'
        : Object.prototype.hasOwnProperty.call(target, key)
        ? 'SET'
        : 'ADD'

      const res = Reflect.set(target, key, newVal, receiver)
      if (target === receiver.raw) {
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          // 增加第四个参数,即触发响应的新值
          trigger(target, key, type, newVal)
        }
      }

      return res
    }
  })
}

接着,我们还需要修改 trigger 函数:

javascript 复制代码
// 为 trigger 函数增加第四个参数,newVal,即新值
function trigger(target, key, type, newVal) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 省略其他代码

  // 如果操作目标是数组,并且修改了数组的 length 属性
  if (Array.isArray(target) && key === 'length') {
    // 对于索引大于或等于新的 length 值的元素,
    // 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行
    depsMap.forEach((effects, index) => {
      if (index >= newVal) {
        effects.forEach(effectFn => {
          if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
          }
        })
      }
    })
  }

  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

5.7.2 遍历数组

既然数组也是对象,就意味着我们同样可以使用 for...in 循环遍历数组:

javascript 复制代码
const arr = reactive(['foo'])

effect(() => {
  for (const key in arr) {
    console.log(key) // 0
  }
})

但是我们应该尽量避免使用 for...in 循环遍历数组,

前面说数组对象和常规对象的不同仅体现在 [[DefineOwnProperty]] 这个内部方法上。

因此,使用 for...in 循环遍历数组与遍历常规对象并无差异,可以使用 ownKeys 拦截函数进行拦截。

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 省略其他拦截函数
    ownKeys(target) {
      track(target, ITERATE_KEY)
      return Reflect.ownKeys(target)
    }
  })
}

上述代码取自前文,我们为了追踪对普通对象的 for...in 操作,创建了 ITERATE_KEY 作为追踪的 key。

然而,这是为了代理普通对象而考虑的。对于普通对象来说,只有当添加或删除属性值时才会影响 for...in 循环的结果,这时候就需要取出与 ITERATE_KEY 相关联的副作用函数重新执行。

对于数组来说,情况有所不同。我们看看哪些操作会影响 for...in 循环对数组的遍历:

  • 添加新元素:arr[100] = 'bar'
  • 修改数组长度:arr.length = 0

实际上,无论是为数组添加新元素,还是直接修改数组的长度,本质上都是因为修改了数组的 length 属性。一旦数组的 length 属性被修改,那么 for...in 循环对数组的遍历结果就会改变。

所以,在这种情况下我们应该触发响应。我们可以在 ownKeys 拦截函数内,判断当前操作目标 target 是否是数组,如果是,则使用 length 作为 key 建立响应联系:

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 省略其他拦截函数
    ownKeys(target) {
      // 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系
      track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
      return Reflect.ownKeys(target)
    }
  })
}

这样,无论是为数组添加新元素,还是直接修改 length 属性,都能够正确地触发响应:

javascript 复制代码
const arr = reactive(['foo'])

effect(() => {
  for (const key in arr) {
    console.log(key
}
})

arr[1] = 'bar' // 能够触发副作用函数重新执行
arr.length = 0 // 能够触发副作用函数重新执行

现在,当我们为数组添加新元素或直接修改 length 属性时,都能正确地触发响应。这样,我们已经解决了数组在遍历时可能遇到的问题。

讲解了使用 for...in 遍历数组,接下来我们再看看使用 for...of 遍历数组的情况。

for...in 遍历数组与 for...of 遍历数组的区别在于,for...of 用于遍历可迭代对象(iterable object)。可迭代对象是实现了 @@iterator 方法的对象,例如 Symbol.iterator 方法。

下面创建一个实现了 Symbol.iterator 方法的对象:

javascript 复制代码
const obj = {
  val: 0,
  [Symbol.iterator]() {
    return {
      next() {
        return {
          value: obj.val++,
          done: obj.val > 10 ? true : false
        }
      }
    }
  }
}

for (const value of obj) {
  console.log(value) // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}

数组内建了 Symbol.iterator 方法的实现,我们可以手动执行迭代器的 next 函数,这样也可以得到期望的结果。这也是默认情况下数组可以使用 for...of 遍历的原因:

javascript 复制代码
const arr = [1, 2, 3, 4, 5]

// 获取并调用数组内建的迭代器方法
const itr = arr[Symbol.iterator]()

console.log(itr.next()) // {value: 1, done: false}
console.log(itr.next()) // {value: 2, done: false}
console.log(itr.next()) // {value: 3, done: false}
console.log(itr.next()) // {value: 4, done: false}
console.log(itr.next()) // {value: 5, done: false}
console.log(itr.next()) // {value: undefined, done: true}

for (const val of arr) {
  console.log(val) // 1, 2, 3, 4, 5
}

数组迭代器的执行会读取数组的 length 属性。如果迭代的是数组元素值,还会读取数组的索引。我们可以给出一个数组迭代器的模拟实现:

javascript 复制代码
const arr = [1, 2, 3, 4, 5]

arr[Symbol.iterator] = function () {
  const target = this
  const len = target.length
  let index = 0

  return {
    next() {
      return {
        value: index < len ? target[index] : undefined,
        done: index++ >= len
      }
    }
  }
}

上述代码,我们用自定义的实现覆盖了数组内建的迭代器方法,但它仍然能够正常工作。

这个例子表明,迭代数组时,只需要在副作用函数与数组的长度和索引之间建立响应联系,就能够实现响应式的 for...of 迭代:

javascript 复制代码
const arr = reactive([1, 2, 3, 4, 5])

effect(() => {
  for (const val of arr) {
    console.log(val)
  }
})

arr[1] = 'bar' // 能够触发响应
arr.length = 0 // 能够触发响应

注意,在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系时,需要避免发生意外的错误,以及性能上的考虑。因此需要修改 get 拦截函数:

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      console.log('get: ', key)
      if (key === 'raw') {
        return target
      }

      // 添加判断,如果 key 的类型是 symbol,则不进行追踪
      if (!isReadonly && typeof key !== 'symbol') {
        track(target, key)
      }

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

      if (isShallow) {
        return res
      }

      if (typeof res === 'object' && res !== null) {
        return isReadonly ? readonly(res) : reactive(res)
      }

      return res
    }
  })
}

在调用 track 函数进行追踪之前,需要添加一个判断条件,即只有当 key 的类型不是 symbol 时才进行追踪,这样就避免了上述问题。

5.7.3 数组查找

通过之前的学习,我们了解到数组的内部方法大都依赖于对象的基础语义。通常情况下,不需特殊处理就可以正常使用。例如:

javascript 复制代码
const arr = reactive([1, 2])

effect(() => {
  console.log(arr.includes(1)) // 初始打印 true
})

arr[0] = 3 // 副作用函数重新执行,并打印 false

这是因为 includes 方法在寻找特定值时,会访问数组的 length 属性以及数组索引。因此,当我们更改某个索引指向的元素值时,就能触发响应。

但 includes 方法并不总是按预期工作,例如:

javascript 复制代码
const obj = {}
const arr = reactive([obj])

console.log(arr.includes(arr[0])) // false

在这段代码中,我们创建一个对象 obj 并将其作为数组的第一个元素。

然后创建一个响应式数组,并尝试使用 includes 方法查找数组中是否包含第一个元素。这个操作应该返回 true,但实际上返回 false。

includes 方法通过索引读取数组元素的值,但是这里的 0 是代理对象 arr。所以,通过代理对象来访问元素值时,如果值还可以被代理,那么返回的是新的代理对象而非原始对象。以下代码可以证明这一点:

javascript 复制代码
if (typeof res === 'object' && res !== null) {
  // 如果值可以被代理,则返回代理对象
  return isReadonly ? readonly(res) : reactive(res)
}

在arr.includes(arr[0])中,arr[0] 得到的是一个代理对象,而在 includes 方法内部通过 arr 访问数组元素时也得到一个代理对象。

但这两个代理对象是不同的。这是因为每次调用 reactive 函数都会创建一个新的代理对象。解决方案如下:

javascript 复制代码
// 定义一个 Map 实例,存储原始对象到代理对象的映射
const reactiveMap = new Map()

function reactive(obj) {
  // 优先通过原始对象 obj 寻找之前创建的代理对象,如果找到了,直接返回已有的代理对象
  const existionProxy = reactiveMap.get(obj)
  if (existionProxy) return existionProxy

  // 否则,创建新的代理对象
  const proxy = createReactive(obj)
  // 存储到 Map 中,从而避免重复创建
  reactiveMap.set(obj, proxy)

  return proxy
}

当前的行为已经达到了预期。但是,我们不能过早地庆祝。让我们再来看一下以下的代码:

javascript 复制代码
const obj = {}
const arr = reactive([obj])

console.log(arr.includes(obj)) // false

在这段代码中,返回 false 令人费解,这是因为 includes 方法内部的 this 指向的是代理对象 arr,并且在获取数组元素时得到的也是代理对象,因此当我们使用原始对象 obj 进行查找时,肯定找不到,从而返回 false。

为了解决这个问题,我们需要重写数组的 includes 方法并实现自定义的行为:

javascript 复制代码
const arrayInstrumentations = {
  includes: function () {
    /* ... */
  }
}

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      console.log('get: ', key)
      if (key === 'raw') {
        return target
      }
      // 如果操作的目标对象是数组,并且 key 存在于 arrayInstrumentations 上,
      // 那么返回定义在 arrayInstrumentations 上的值
      if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
        return Reflect.get(arrayInstrumentations, key, receiver)
      }

      if (!isReadonly && typeof key !== 'symbol') {
        track(target, key)
      }

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

      if (isShallow) {
        return res
      }

      if (typeof res === 'object' && res !== null) {
        return isReadonly ? readonly(res) : reactive(res)
      }

      return res
    }
  })
}

在上述代码中,我们修改了 get 拦截函数,以重写数组的 includes 方法。

执行 arr.includes 时,实际执行的是定义在 arrayInstrumentations 上的 includes 函数,这样我们就重写了这个方法。

接下来,我们可以自定义 includes 函数:

javascript 复制代码
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
  includes: function (...args) {
    // this 是代理对象,先在代理对象中查找,将结果存储到 res 中
    let res = originMethod.apply(this, args)

    if (res === false) {
      // res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找并更新 res 值
      res = originMethod.apply(this.raw, args)
    }
    // 返回最终结果
    return res
  }
}

在上述代码中,includes 方法内的 this 指向的是代理对象,我们首先在代理对象中进行查找,这其实是 arr.include(obj) 的默认行为。

如果在代理对象中找不到,我们会通过 this.raw 获取原始数组,然后在其中进行查找,最后返回结果。这样就解决了先前提到的问题。运行以下测试代码:

javascript 复制代码
const obj = {}
const arr = reactive([obj])

console.log(arr.includes(obj)) // true

你会发现,现在代码的行为已经符合预期。

除了 includes 方法,还有一些其他的数组方法,如 indexOf 和 lastIndexOf,也需要进行类似的处理,因为这些方法都是根据给定的值返回查找结果。以下是完整的代码:

javascript 复制代码
const arrayInstrumentations = {}

;['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
  const originMethod = Array.prototype[method]
  arrayInstrumentations[method] = function (...args) {
    // this 是代理对象,先在代理对象中查找,将结果存储到 res 中
    let res = originMethod.apply(this, args)

    if (res === false || res === -1) {
      // res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找,并更新 res 值
      res = originMethod.apply(this.raw, args)
    }
    // 返回最终结果
    return res
  }
})

5.7.4 隐式修改数组长度的原型方法

push 方法会读取并设置数组的 length 属性,这可能导致两个独立的副作用函数相互影响。例如:

javascript 复制代码
const arr = reactive([])
// 第一个副作用函数
effect(() => {
  arr.push(1)
})

// 第二个副作用函数
effect(() => {
  arr.push(1)
})

上述代码在运行时会导致栈溢出错误(Maximum call stack size exceeded)。

这是因为,两个副作用函数都在执行 push 操作,既读取了 length 属性,又设置了 length 属性。

第一个副作用函数执行完毕后,会与 length 属性建立响应关系。当第二个副作用函数执行时,也会与 length 属性建立响应关系,同时设置 length 属性。这导致了第一个副作用函数的重新执行,从而形成了无限循环,最终导致栈溢出。

解决方法是"屏蔽"对 length 属性的读取,防止在 length 属性和副作用函数之间建立响应关系。

这是因为数组的 push 操作本质上是修改操作,而非读取操作。避免建立响应联系并不会产生其他副作用。

重写数组的 push 方法:

javascript 复制代码
// 一个标记变量,代表是否进行追踪。默认值为 true,即允许追踪
let shouldTrack = true
// 重写数组的 push 方法
;['push'].forEach(method => {
  // 取得原始 push 方法
  const originMethod = Array.prototype[method]
  // 重写
  arrayInstrumentations[method] = function (...args) {
    // 在调用原始方法之前,禁止追踪
    shouldTrack = false
    // push 方法的默认行为
    let res = originMethod.apply(this, args)
    // 在调用原始方法之后,恢复原来的行为,即允许追踪
    shouldTrack = true
    return res
  }
})

在上述代码中,我们在执行 push 方法的默认行为前后,分别禁止和允许追踪。

我们还需要相应地修改 track 函数,代码如下:

javascript 复制代码
function track(target, key) {
  if (!activeEffect || !shouldTrack) return
  // 省略部分代码
}

这样,当 push 方法间接读取 length 属性时,由于此时是禁止追踪的状态,所以 length 属性与副作用函数之间不会建立响应联系。

这样就解决了上文的问题。我们再次尝试运行测试代码:

javascript 复制代码
const arr = reactive([])
// 第一个副作用函数
effect(() => {
  arr.push(1)
})

// 第二个副作用函数
effect(() => {
  arr.push(1)
})

你会发现现在它能够正确地工作,并且不会导致调用栈溢出。

除了 push 方法,我们还需要对 pop、shift、unshift 和 splice 等方法进行类似的处理。完整的代码如下:

javascript 复制代码
let shouldTrack = true
// 重写数组的 push、pop、shift、unshift 和 splice 方法
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
  const originMethod = Array.prototype[method]
  arrayInstrumentations[method] = function(...args) {
    shouldTrack = false
    let res = originMethod.apply(this, args)
    shouldTrack = true
    return res
  }
})

5.8 Set 和 Map 的响应式代理

我们简要回顾一下 Set 和 Map 这两种数据类型的原型属性和方法:

Set 数据类型的原型属性和方法包括:

  • size:返回集合中元素的数量。
  • add(value):向集合添加新元素。
  • clear():清空集合内所有元素。
  • delete(value):从集合中移除特定元素。
  • has(value):检查集合中是否包含特定元素。
  • keys()和 values():在 Set 中,这两个方法是等价的,它们都返回一个可用于 for...of 循环的迭代器,用于遍历集合中的元素。
  • entries():返回一个迭代器,遍历集合中的每个元素,每次产生一个形如 [value, value] 的数组。
  • forEach(callback[, thisArg]):遍历集合中的所有元素,为每个元素执行 callback 函数。可选参数 thisArg 用于设定 callback 函数执行时的 this 值。

Map 数据类型的原型属性和方法包括:

  • size:返回 Map 中的键值对数量。
  • clear():清空 Map 中的所有元素。
  • delete(key):从 Map 中移除特定键的键值对。
  • has(key):检查 Map 中是否包含特定键的键值对。
  • get(key):获取特定键对应的值。
  • set(key, value):在 Map 中添加新的键值对。
  • keys():返回一个迭代器,用于遍历 Map 中的所有键。
  • values():返回一个迭代器,用于遍历 Map 中的所有值。
  • entries():返回一个迭代器,遍历 Map 中的每个键值对,每次产生一个形如 [key, value] 的数组。
  • forEach(callback[, thisArg]):遍历 Map 中的所有键值对,为每个键值对执行 callback 函数。可选参数 thisArg 用于设定 callback 函数执行时的 this 值。

Map 和 Set 的操作方法有很多相似之处。主要的区别在于,Set 使用 add(value) 方法添加元素,而 Map 则使用 set(key, value) 方法添加键值对,并且 Map 还可以通过 get(key) 方法获取特定键的值。

5.8.1 如何代理 Set 和 Map

Set 和 Map 类型的数据具有专属的属性和方法来进行操作数据,这一点与普通对象存在显著差异,所以我们不能简单地像代理普通对象那样来代理 Set 和 Map 类型的数据。

然而,代理的基本思路依然不变:当读取操作发生时,我们需要调用 track 函数建立依赖关系;当设置操作发生时,我们需要调用 trigger 函数以触发响应。例如:

javascript 复制代码
const proxy = reactive(new Map([['key', 1]]));

effect(() => {
    console.log(proxy.get('key')); // 读取键为 key 的值
});

proxy.set('key', 2); // 修改键为 key 的值,应该触发响应

以上代码展示的是我们最终希望实现的效果。

在实现之前,我们先注意一些细节:

javascript 复制代码
const s = new Set([1, 2, 3]);
const p = new Proxy(s, {});

console.log(p.size); // 报错 TypeError: Method get Set.prototype.size called on incompatible receiver

代理对象 p 并不包含 size,这就是我们在上面的例子中所遇到的错误。size 属性应该是一个访问器属性,所以它作为方法被调用了。

为了解决这个问题,我们可以调整访问器属性的 getter 函数中 this 的指向,如下:

javascript 复制代码
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
  get(target, key, receiver) {
    if (key === 'size') {
      // 如果读取的是 size 属性
      // 通过指定第三个参数 receiver 为原始对象 target 从而修复问题
      return Reflect.get(target, key, target)
    }
    // 读取其他属性的默认行为
    return Reflect.get(target, key, receiver)
  }
})

console.log(s.size) // 3

在这段代码中,我们在创建代理对象时添加了 get 拦截函数。然后检查读取的属性名是否为 size,通过 Reflect.get 函数时将第三个参数设为原始的 Set 对象保证了 this 原始 set 对象。

然后,我们试图从 Set 中删除数据:

javascript 复制代码
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
  get(target, key, receiver) {
    if (key === 'size') {
      return Reflect.get(target, key, target)
    }
    return Reflect.get(target, key, receiver)
  }
})

// 尝试删除值为 1 的元素
// 我们得到了错误:TypeError: Method Set.prototype.delete called on incompatible receiver [object Object]
p.delete(1)

在 delete 方法执行时,this 总是指向代理对象 p,而不是原始的 Set 对象。我们可以通过将 delete 方法与原始的数据对象绑定来修复这个问题:

javascript 复制代码
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
  get(target, key, receiver) {
    if (key === 'size') {
      return Reflect.get(target, key, target)
    }
    // 将方法与原始数据对象 target 绑定后返回
    return target[key].bind(target)
  }
})

// 调用 delete 方法删除值为 1 的元素,正确执行
p.delete(1)

我们用 bind 函数将用于操作数据的方法与原始数据对象 target 进行了绑定,使得代码能够正确执行。

最后,为了方便后续讲解和提高代码的可扩展性,我们将 new Proxy 也封装进之前介绍过的 createReactive 函数中:

javascript 复制代码
const reactiveMap = new Map()
// reactive 函数与之前相比没有变化
function reactive(obj) {
  const existionProxy = reactiveMap.get(obj)
  if (existionProxy) return existionProxy
  const proxy = createReactive(obj)

  reactiveMap.set(obj, proxy)

  return proxy
}
// 在 createReactive 里封装用于代理 Set/Map 类型数据的逻辑
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'size') {
        return Reflect.get(target, key, target)
      }

      return target[key].bind(target)
    }
  })
}

现在,我们可以很简单地创建代理数据了:

javascript 复制代码
const p = reactive(new Set([1, 2, 3]))
console.log(p.size) // 输出:3

通过这种方式,我们成功地代理了 Set 或 Map 类型的响应式数据,使其在使用上与普通对象无异,同时维持了其原有的特性和操作方式。

5.8.2 建立响应联系

开始实现 Set 类型数据的响应式解决方案,让我们以下面的代码为例:

javascript 复制代码
const p = reactive(new Set([1, 2, 3]))

effect(() => {
  // 在副作用函数内部,我们访问了 size 属性
  console.log(p.size)
})

// 向集合中添加一个元素,间接改变 size,这应该会触发响应
p.add(1)

这段代码我们需要在访问 size 属性时调用 track 函数来进行依赖跟踪,然后在执行 add 方法时调用 trigger 函数来触发响应,下面的代码演示了如何进行依赖跟踪:

javascript 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'size') {
        // 我们在这里调用了 track 函数来建立响应联系
        track(target, ITERATE_KEY)
        return Reflect.get(target, key, target)
      }

      return target[key].bind(target)
    }
  })
}

当我们读取 size 属性时,我们只需要调用 track 函数来建立响应联系即可。这是因为任何新增、删除操作都会影响 size 属性。

当我们调用 add 方法向集合中添加新元素时,我们应该如何触发响应?我们需要实现一个自定义的 add 方法:

javascript 复制代码
// 我们定义一个对象,并在这个对象上定义我们自定义的 add 方法
const mutableInstrumentations = {
  add(key) {/* ... */}
}

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 如果读取的是 raw 属性,那么我们返回原始数据对象 target
      if (key === 'raw') return target
      if (key === 'size') {
        track(target, ITERATE_KEY)
        return Reflect.get(target, key, target)
      }
      // 我们返回在 mutableInstrumentations 对象上定义的方法
      return mutableInstrumentations[key]
    }
  })
}

通过上面代码后, p.add 获取方法时,得到的就是我们自定义的 mutableInstrumentations.add 方法了,有了自定义实现的方法 后,就可以在其中调用 trigger 函数触发响应了:

javascript 复制代码
// 定义一个对象,将自定义的 add 方法定义到该对象下
const mutableInstrumentations = {
  add(key) {
    // this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象
    const target = this.raw
    // 通过原始数据对象执行 add 方法添加具体的值,
    // 注意,这里不再需要 .bind 了,因为是直接通过 target 调用并执行的
    const res = target.add(key)
    // 调用 trigger 函数触发响应,并指定操作类型为 ADD
    trigger(target, key, 'ADD')
    // 返回操作结果
    return res
  }
}

在我们自定义的 add 方法中,this 仍然指向代理对象,因此我们需要通过 this.raw 来获取原始数据对象。然后通过一系列操作最后触发 操作类型为 ADD 的响应,有了原始数据对象后,就可以通过它调用target.add 方法,这样就不再需要 .bind 绑定了。

还记得 trigger 函数的实现吗?让我们回顾一下:

javascript 复制代码
function trigger(target, key, type, newVal) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  // 省略无关内容

  // 当操作类型 type 为 ADD 时,会取出与 ITERATE_KEY 相关联的副作用函数并执行
  if (type === 'ADD' || type === 'DELETE') {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    iterateEffects &&
      iterateEffects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn)
        }
      })
  }

  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

当操作类型是 ADD 或 DELETE 时,我们会获取和 ITERATE_KEY 相关联的副作用函数并执行它们。这样我们就可以触发通过访问 size 属性收集的副作用函数了。

当然,如果调用 add 方法时所添加的元素已经存在于 Set 集合中,那么就不需要触发响应,这样可以提高性能。因此,我们可以优化代码如下:

javascript 复制代码
const mutableInstrumentations = {
  add(key) {
    const target = this.raw
    // 先判断值是否已经存在
    const hadKey = target.has(key)
    // 只有在值不存在的情况下,才需要触发响应
    const res = target.add(key)
    if (!hadKey) {
      trigger(target, key, 'ADD')
    }
    return res
  }
}

这段代码调用 target.has 方法判断值是否已存在,只有在值不存在的情况下才需要触发响应。

基于此,我们可以通过类似的逻辑轻松地实现 delete 方法:

javascript 复制代码
const mutableInstrumentations = {
  delete(key) {
    const target = this.raw
    const hadKey = target.has(key)
    const res = target.delete(key)
    // 当要删除的元素确实存在时,才触发响应
    if (hadKey) {
      trigger(target, key, 'DELETE')
    }
    return res
  }
}

delete 方法只在要删除的元素确实存在于集合中时才需要触发响应,这与 add 方法的逻辑相反。

5.8.3 避免污染原始数据

Map 数据类型拥有 get 和 set 这两个方法。当我们通过 get 方法读取数据时,需要调用 track 函数来追踪依赖并建立响应关系;而当我们通过 set 方法设置数据时,则需要调用 trigger 方法来触发响应。以下面的代码为例:

javascript 复制代码
const p = reactive(new Map([['key', 1]]))

effect(() => {
  console.log(p.get('key'))
})

p.set('key', 2) // 触发响应

让我们看看 get 方法的具体实现:

javascript 复制代码
const mutableInstrumentations = {
  get(key) {
    // 获取原始对象
    const target = this.raw
    // 判断读取的 key 是否存在
    const had = target.has(key)
    // 追踪依赖,建立响应联系
    track(target, key)
    // 如果存在,则返回结果。这里要注意的是,如果得到的结果 res 仍然是可代理的数据,
    // 则要返回使用 reactive 包装后的响应式数据
    if (had) {
      const res = target.get(key)
      return typeof res === 'object' ? reactive(res) : res
    }
  }
}

这段代码在非浅响应模式下,如果返回的数据还可以被代理,我们需要调用 reactive(res) 将数据转换为响应式数据后再返回。

我们来看看 set 方法的实现,注意触发响应我们需要区分操作的类型是 SET 还是 ADD:

javascript 复制代码
const mutableInstrumentations = {
  set(key, value) {
    const target = this.raw
    const had = target.has(key)
    // 获取旧值
    const oldValue = target.get(key)
    // 设置新值
    target.set(key, value)
    // 如果不存在,则说明是 ADD 类型的操作,意味着新增
    if (!had) {
      trigger(target, key, 'ADD')
    } else if (oldValue !== value || (oldValue === oldValue && value === value)) {
      // 如果不存在,并且值变了,则是 SET 类型的操作,意味着修改
      trigger(target, key, 'SET')
    }
  }
}

代码的关键在于我们需要判断待设置的 key 是否已存在,任何依赖 size 属性的副作用函数都需要在 ADD 类型的操作发生时重新执行。

上面存在一个问题,set 方法会污染原始数据:

javascript 复制代码
// 原始 Map 对象 m
const m = new Map()
// p1 是 m 的代理对象
const p1 = reactive(m)
// p2 是另外一个代理对象
const p2 = reactive(new Map())
// 为 p1 设置一个键值对,值是代理对象 p2
p1.set('p2', p2)

effect(() => {
  // 注意,这里我们通过原始数据 m 访问 p2
  console.log(m.get('p2').size)
})
// 注意,这里我们通过原始数据 m 为 p2 设置一个键值对 foo --> 1
m.get('p2').set('foo', 1)

上述代码我们通过原始数据 m 来读取和设置数据值,却发现副作用函数重新执行了,但是原始数据不应该具备响应式。

这个问题需要我们观察下之前实现的 set 方法:

javascript 复制代码
const mutableInstrumentations = {
  set(key, value) {
    const target = this.raw
    const had = target.has(key)
    const oldValue = target.get(key)
    // 我们把 value 原封不动地设置到原始数据上
    target.set(key, value)
    if (!had) {
      trigger(target, key, 'ADD')
    } else if (oldValue !== value || (oldValue === oldValue && value === value)) {
      trigger(target, key, 'SET')
    }
  }
}

在 set 方法中,我们将 value 原样设置到了原始数据 target 上,但是如果 value 是响应式数据,设置上去也是响应式数据,这就是数据污染。

解决数据污染,我们可以在调用 target.set 函数设置值之前对值进行检查,即发现设置的是响应式数据,则通过 raw 属性获取原始数据设置到 target 上即可:

javascript 复制代码
const mutableInstrumentations = {
	set(key, value) {
		const target = this.raw
		const had = target.has(key)
		const oldValue = target.get(key)
		// 获取原始数据,如果 value.raw 不存在,则直接使用 value
		const rawValue = value.raw || value
		target.set(key, rawValue)
		if (!had) {
			trigger(target, key, 'ADD')
		} else if (oldValue !== value || (oldValue === oldValue && value === value)) {
			trigger(target, key, 'SET')
		}
	},
}

现在已经不会造成数据污染了。但是我们一直使用 raw 属性来访问原始数据,这可能会与用户自定义的 raw 属性冲突。因此,在一个更为严谨的实现中,我们需要使用一个唯一的标识来作为访问原始数据的键,例如使用 Symbol 类型来代替。

5.8.4 处理 forEach

集合类型的 forEach 方法类似于数组的 forEach 方法,让我们一起看看它的工作原理:

javascript 复制代码
const m = new Map([
  [{ key: 1 }, { value: 1 }]
])

effect(() => {
  m.forEach(function (value, key, m) {
    console.log(value) // { value: 1 }
    console.log(key) // { key: 1 }
  })
})

Map 的 forEach 方法接收一个回调函数作为参数,回调函数接收三个参数,分别是 Map 的每个值、键以及原始 Map 对象。

任何修改 Map 对象键值对数量的操作,例如 delete 和 add 方法,都应该触发副作用函数重新执行。

因此,当 forEach 函数被调用时,我们应该让副作用函数与 ITERATE_KEY 建立响应联系:

javascript 复制代码
const mutableInstrumentations = {
  forEach(callback) {
    // 取得原始数据对象
    const target = this.raw
    // 与 ITERATE_KEY 建立响应联系
    track(target, ITERATE_KEY)
    // 通过原始数据对象调用 forEach 方法,并把 callback 传递过去
    target.forEach(callback)
  }
}

这样我们就实现了对 forEach 操作的追踪。但是回调函数接收的参数是非响应式数据,如果修改则无法触发副作用函数重新触发。如下所示:

javascript 复制代码
const key = { key: 1 }
const value = new Set([1, 2, 3])
const p = reactive(new Map([[key, value]]))

effect(() => {
  p.forEach(function (value, key) {
    console.log(value.size) // 3
  })
})

p.get(key).delete(1)

我们尝试删除 Set 类型数据中值为 1 的元素,却发现没能触发副作用函数重新执行。

原因是通过 value.size 访问 size 属性时,这里的 value 是原始数据对象,即 new Set([1, 2, 3]),而非响应式数据对象,因此无法建立响应联系。

不符合直觉,reactive 本身是深响应,forEach 方法的回调函数所接收到的参数也应该是响应式数据才对。

为了解决这个问题我们需要调用原始 forEach 方法之前,先将参数转换为响应式数据:

javascript 复制代码
const mutableInstrumentations = {
  forEach(callback, thisArg) {
    // wrap 函数用来把可代理的值转换为响应式数据
    const wrap = val => (typeof val === 'object' ? reactive(val) : val)
    const target = this.raw
    track(target, ITERATE_KEY)
    // 通过 target 调用原始 forEach 方法进行遍历
    target.forEach((v, k) => {
      // 手动调用 callback,用 wrap 函数包裹 value 和 key 后再传给 callback,这样就实现了深响应
      callback.call(thisArg, wrap(v), wrap(k), this)
    })
  }
}

上述代码我们使用 wrap 函数将参数包装成响应式的,这样就实现了深响应。

当我们使用 for...in 循环遍历一个对象时,一般只关心对象的键,而不关心对象的值,我们使用 forEach 遍历集合时,既关心键,又关心值。

但这个规则不适用于 Map 类型的 forEach 遍历,如以下代码所示:

javascript 复制代码
const p = reactive(new Map([['key', 1]]))

effect(() => {
  p.forEach(function (value, key) {
    // forEach 循环不仅关心集合的键,还关心集合的值
    console.log(value) // 1
  })
})

p.set('key', 2) // 即使操作类型是 SET,也应该触发响应

所以对于 Map 类型的数据,即使操作类型是 SET,只要值发生了变化,也应该触发副作用函数重新执行。因此,我们需要进一步修改 trigger 函数的代码:

javascript 复制代码
function trigger(target, key, type, newVal) {
  console.log('trigger', key)
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects &&
    effects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })

  if (
    type === 'ADD' ||
    type === 'DELETE' ||
    // 如果操作类型是 SET,并且目标对象是 Map 类型的数据,
    // 也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行
    (type === 'SET' && Object.prototype.toString.call(target) === '[object Map]')
  ) {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    iterateEffects &&
      iterateEffects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn)
        }
      })
  }

  // 省略部分内容

  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

上述代码中即使操作类型是 SET,只要值发生了变化,也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。

5.8.5 迭代器方法

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

  • entries
  • keys
  • values

当我们调用这些方法时,将会得到对应的迭代器,然后我们可以使用 for...of 循环进行迭代。例如:

javascript 复制代码
const m = new Map([
  ['key1', 'value1'],
  ['key2', 'value2']
])

for (const [key, value] of m.entries()) {
  console.log(key, value)
}
// 输出:
// key1 value1
// key2 value2

此外,由于 Map 或 Set 类型本身就实现了 Symbol.iterator 方法,因此我们也可以直接使用 for...of 进行迭代:

javascript 复制代码
for (const [key, value] of m) {
  console.log(key, value)
}
// 输出:
// key1 value1
// key2 value2

当然,我们也可以先获取迭代器对象,然后手动调用迭代器对象的 next 方法来获取对应的值:

javascript 复制代码
const itr = m[Symbol.iterator]()

console.log(itr.next()) // { value: ['key1', 'value1'], done: false }
console.log(itr.next()) // { value: ['key2', 'value2'], done: false }
console.log(itr.next()) // { value: undefined, done: true }

实际上,m[Symbol.iterator] 和 m.entries 是等价的:

javascript 复制代码
console.log(m[Symbol.iterator] === m.entries) // true

这也是上面为什么使用 for...of 循环迭代 m.entries 和 m 会得到同样的结果。

理解了这些后,我们就可以尝试去实现对迭代器方法的代理:

javascript 复制代码
const p = reactive(new Map([
  ['key1', 'value1'],
  ['key2', 'value2']
]))

effect(() => {
  // TypeError: p is not iterable
  for (const [key, value] of p) {
    console.log(key, value)
  }
})

p.set('key3', 'value3')

上述代理对象 p 没有实现 Symbol.iterator 方法,所以我们得到了上面的错误。

当我们试图使用 for...of 循环遍历代理对象时,系统会尝试从代理对象 p 上获取 p[Symbol.iterator] 属性,这会触发 get 拦截函数,我们可以把 Symbol.iterator 方法的实现放到 mutableInstrumentations 中:

javascript 复制代码
const mutableInstrumentations = {
    [Symbol.iterator]() {
        // 获取原始数据对象 target
        const target = this.raw
        // 获取原始迭代器方法
        const itr = target[Symbol.iterator]()
        // 将其返回
        return itr
    }
}

上述代码只是返回了原始的迭代器对象后,就可以使用 for...of 循环遍历代理对象 p 了。

但是如果迭代产生的值可以被代理,那么我们也应该将其包装成响应式数据:

javascript 复制代码
const mutableInstrumentations = {
  [Symbol.iterator]() {
    // 获取原始数据对象 target
    const target = this.raw
    // 获取原始迭代器方法
    const itr = target[Symbol.iterator]()

    const wrap = val => (typeof val === 'object' && val !== null ? reactive(val) : val)

    // 返回自定义的迭代器
    return {
      next() {
        // 调用原始迭代器的 next 方法获取 value 和 done
        const { value, done } = itr.next()
        return {
          // 如果 value 不是 undefined,则对其进行包裹
          value: value ? [wrap(value[0]), wrap(value[1])] : value,
          done
        }
      }
    }
  }
}

上述代码,我们自定义了迭代器,如果值 value 不为 undefined,则对其进行包装,最后返回包装后的代理对象。

为了让我们能够追踪 for...of 循环对数据的处理,我们需要调用track函数以建立 ITERATE_KEY 与副作用函数的联系:

javascript 复制代码
const mutableInstrumentations = {
  [Symbol.iterator]() {
    const target = this.raw
    const itr = target[Symbol.iterator]()

    const wrap = val => (typeof val === 'object' && val !== null ? reactive(val) : val)

    // 调用 track 函数建立响应联系
    track(target, ITERATE_KEY)

    return {
      next() {
        const { value, done } = itr.next()
        return {
          value: value ? [wrap(value[0]), wrap(value[1])] : value,
          done
        }
      }
    }
  }
}

由于迭代操作与集合元素的数量相关,集合 size 的变化应该触发迭代操作的重新执行,我们通过以下代码测试:

javascript 复制代码
const p = reactive(new Map([
  ['key1', 'value1'],
  ['key2', 'value2']
]));

effect(() => {
  for (const [key, value] of p) {
    console.log(key, value);
  }
});

p.set('key3', 'value3'); // 触发响应

我们之前提到,由于 p.entries 和 p[Symbol.iterator] 等效,所以我们可以使用相同的代码来拦截 p.entries 函数:

javascript 复制代码
const mutableInstrumentations = {
  // 共用 iterationMethod 方法
  [Symbol.iterator]: iterationMethod,
  entries: iterationMethod
}

// 抽离为独立的函数,便于复用
function iterationMethod() {
  const target = this.raw
  const itr = target[Symbol.iterator]()

  const wrap = val => (typeof val === 'object' ? reactive(val) : val)

  track(target, ITERATE_KEY)

  return {
    next() {
      const { value, done } = itr.next()
      return {
        value: value ? [wrap(value[0]), wrap(value[1])] : value,
        done
      }
    }
  }
}

但当你尝试运行代码使用 for...of 进行迭代时,会得到一个错误:

javascript 复制代码
// TypeError: p.entries is not a function or its return value isnot iterable
for (const [key, value] of p.entries()) {
	console.log(key, value)
}

因为 p.entries 的返回的具有 next 方法的对象不具有 Symbol.iterator 方法,不是一个可迭代对象。

可迭代协议指的是一个对象实现了 Symbol.iterator 方法,而迭代器协议指的是一个对象实现了 next 方法。

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

javascript 复制代码
const obj = {
  // 迭代器协议
  next() {
    // ...
  },
  // 可迭代协议
  [Symbol.iterator]() {
    return this;
  }
}

所以,我们可以在iterationMethod函数中实现可迭代协议来解决上述问题:

javascript 复制代码
// 独立函数,方便重复使用
function iterationMethod() {
  const target = this.raw;
  const itr = target[Symbol.iterator]();
  const wrap = val => (typeof val === 'object') ? reactive(val) : val;

  track(target, ITERATE_KEY);

  return {
    next() {
      const { value, done } = itr.next();
      return {
        value: value ? [wrap(value[0]), wrap(value[1])] : value,
        done
      }
    },
    // 实现可迭代协议
    [Symbol.iterator]() {
      return this;
    }
  }
}

现在,无论是使用 for...of 循环还是 p.entries() 方法,都能正常运行且能触发响应。

5.8.6 实现 values 与 keys 方法

values 方法的实现和 entries 方法相似,只不过我们使用 for...of迭代 values 时获取的是值:

javascript 复制代码
for (const value of p.values()) {
  console.log(value)
}

values 方法的实现如下:

javascript 复制代码
const mutableInstrumentations = {
  // 共用 iterationMethod 方法
  [Symbol.iterator]: iterationMethod,
  entries: iterationMethod,
  values: valuesIterationMethod
}

function valuesIterationMethod() {
  // 获取原始数据对象 target
  const target = this.raw
  // 通过 target.values 获取原始迭代器方法
  const itr = target.values()

  const wrap = val => (typeof val === 'object' ? reactive(val) : val)

  track(target, ITERATE_KEY)

  // 将其返回
  return {
    next() {
      const { value, done } = itr.next()
      return {
        // value 是值,而非键值对,所以只需要包裹 value 即可
        value: wrap(value),
        done
      }
    },
    [Symbol.iterator]() {
      return this
    }
  }
}

iterationMethod 和 valuesIterationMethod 存在以下差异:

  • iterationMethod 通过 target[Symbol.iterator] 获取迭代器对象,而 valuesIterationMethod 通过 target.values 获取迭代器对象。
  • iterationMethod 处理键值对 [wrap(value[0]), wrap(value[1])],而 valuesIterationMethod 只处理值 wrap(value)。

keys 方法和 values 方法相似,只是它处理的是键,而不是值,我们只需在 valuesIterationMethod 方法中修改一行代码,即可以实现对 keys 方法的代理:

javascript 复制代码
const itr = target.values();

替换成:

javascript 复制代码
const itr = target.keys()

但是,如果我们尝试运行以下测试用例,我们会发现一个问题:

javascript 复制代码
const p = reactive(new Map([
  ['key1', 'value1'],
  ['key2', 'value2']
]))

effect(() => {
  for (const value of p.keys()) {
    console.log(value) // key1 key2
  }
})

p.set('key2', 'value3') // 这是一个 SET 类型的操作,它修改了 key2 的值,不应该触发响应
p.set('key3', 'value3') // 能够触发响应

上述代码,我们使用 for...of 循环遍历 p.keys,并调用 p.set 修改 key2 的值,理论上Map 类型数据的所有键没有变化,副作用函数不应该执行,然后却执行了。因为之前我们做了特殊处理,即使操作类型为 SET,也会触发与 ITERATE_KEY 相关的副作用函数。

虽然对于 values 或 entries 方法这是必要的,但对于 keys 方法来说这并不必要,因为 keys 方法只关心 Map 类型数据的键的变化,而不关心值的变化:

解决方案如下所示:

javascript 复制代码
const MAP_KEY_ITERATE_KEY = Symbol()

function keysIterationMethod() {
  // 获取原始数据对象 target
  const target = this.raw
  // 获取原始迭代器方法
  const itr = target.keys()

  const wrap = val => (typeof val === 'object' ? reactive(val) : val)

  // 调用 track 函数追踪依赖,在副作用函数与 MAP_KEY_ITERATE_KEY 之间建立响应联系
  track(target, MAP_KEY_ITERATE_KEY)

  // 将其返回
  return {
    next() {
      const { value, done } = itr.next()
      return {
        value: wrap(value),
        done
      }
    },
    [Symbol.iterator]() {
      return this
    }
  }
}

上述代码我们使用 MAP_KEY_ITERATE_KEY 取代了 ITERATE_KEY 来追踪依赖。

此时当 SET 类型的操作只触发与 ITERATE_KEY 相关的副作用函数时,与 MAP_KEY_ITERATE_KEY 相关的副作用函数则会被忽略。

而在 ADD 或 DELETE 类型的操作中,除了触发与 ITERATE_KEY 相关的副作用函数,还需要触发与 MAP_KEY_ITERATE_KEY 相关的副作用函数,需要修改 trigger 函数:

javascript 复制代码
function trigger(target, key, type, newVal) {
  // 省略其他代码

  if (
    // 操作类型为 ADD 或 DELETE
    (type === 'ADD' || type === 'DELETE') &&
    // 并且是 Map 类型的数据
    Object.prototype.toString.call(target) === '[object Map]'
  ) {
    // 则取出那些与 MAP_KEY_ITERATE_KEY 相关联的副作用函数并执行
    const iterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY)
    iterateEffects &&
      iterateEffects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn)
        }
      })
  }

  // 省略其他代码
}

这样,我们就可以避免不必要的更新:

javascript 复制代码
const p = reactive(
  new Map([
    ['key1', 'value1'],
    ['key2', 'value2']
  ])
)

effect(() => {
  for (const value of p.keys()) {
    console.log(value)
  }
})

p.set('key2', 'value3') // 不会触发响应
p.set('key3', 'value3') // 能够触发响应
相关推荐
m0_748239833 分钟前
基于web的音乐网站(Java+SpringBoot+Mysql)
java·前端·spring boot
时雨h8 分钟前
RuoYi-ue前端分离版部署流程
java·开发语言·前端
Cachel wood21 分钟前
Django REST framework (DRF)中的api_view和APIView权限控制
javascript·vue.js·后端·python·ui·django·前端框架
m0_7482340827 分钟前
Spring Boot教程之三十一:入门 Web
前端·spring boot·后端
Domain-zhuo36 分钟前
如何提高webpack的构建速度?
前端·webpack·前端框架·node.js·ecmascript
放逐者-保持本心,方可放逐1 小时前
SSE 流式场景应用 及 方案总结
javascript·axios·fetch·eventsource
还是大剑师兰特1 小时前
面试题:ES6模块与CommonJS模块有什么异同?
前端·es6·大剑师
胡西风_foxww1 小时前
【ES6复习笔记】数值扩展(16)
前端·笔记·es6·扩展·数值
mosen8681 小时前
uniapp中uni.scss如何引入页面内或生效
前端·uni-app·scss
白云~️1 小时前
uniappX 移动端单行/多行文字隐藏显示省略号
开发语言·前端·javascript