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

1 阅读15分钟

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

目录

[TOC]

5.1 理解 Proxy 和 Reflect

Proxy 可以创建一个代理对象,实现对其他对象的代理,拦截并重新定义对对象的基本操作。
注意,Proxy 只能代理对象,不能代理非对象值(如字符串、布尔值等)。
基本操作包括读取属性值、设置属性值等。例如:

obj.foo // 读取属性 foo 的值
obj.foo++ // 读取并设置属性 foo 的值

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

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

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

const fn = (name) => {
  console.log('我是:', name)
}
 
// 调用函数
fn()

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

const p2 = new Proxy(fn, {
  // 使用 apply 拦截函数调用
  apply(target, thisArg, argArray) {
    target.call(thisArg, ...argArray)
  }
})
 
p2('hcy') // 输出:'我是:hcy'
Proxy 只能拦截对象的基本操作。
非基本操作,如调用对象下的方法(称为复合操作):
obj.fn()

复合操作实际上由两个基本操作组成的:首先是 get 操作得到 obj.fn 属性,其次是函数调用。即获得 obj.fn 值后再调用它,这就是我们刚才提到的 apply。
理解 Proxy 只能代理对象的基本操作对于后续实现数组或 Map、Set 等数据类型的代理至关重要。

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

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

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

const obj = { foo: 1 }
 
// 直接读取
console.log(obj.foo) // 1
 
// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo')) // 1

如果两种操作等价,Reflect 存在的意义是什么呢?
Reflect.get() 还接受第三个参数,也就是 receiver,你可以将它看作函数调用中的 this,例如:

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

在这段代码中,我们指定第三个参数 receiver 为一个对象 { foo: 2 },这时读取到的值是 receiver 对象的 foo 属性值。
事实上,Reflect 的各个方法都有很多其他用途,但在此我们只关注与响应式数据实现相关的部分,我们回顾一下上一节的响应式代码:

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 属性:

const obj = {
  foo: 1,
  get bar() {
    return this.foo
  }
}

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

effect(() => {
  console.log(p.bar) // 1
})

这个过程中发生了什么?当执行 effect 注册的副作用函数时,会读取 p.bar 属性。
因为 p.bar 是一个访问器属性,所以会执行 getter 函数。
getter 函数通过 this.foo 读取了 foo 属性值,所以我们认为副作用函数和 foo 属性之间会建立联系。当我们尝试改变 p.foo 的值时:

p.foo++

副作用函数并没有重新执行。问题在哪里呢?
实际上,问题出在 bar 属性的 getter 函数里:

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

当我们使用 this.foo 读取 foo 属性值时,这里的 this 指向的是谁呢?
我们回顾一下整个流程。首先,我们通过代理对象 p 访问 p.bar,这会触发代理对象的 get 拦截函数:

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。很明显,通过原始对象访问属性无法建立响应联系,相当于下面:

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

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

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:

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 使用者来说是不可见的。例如,当我们访问对象属性时:

obj.foo

引擎内部会调用 [[Get]] 这个内部方法来读取属性值。
在ECMAScript规范中,使用 [[xxx]] 表示内部方法或内部槽。一个对象不仅部署了 [[Get]] 这个内部方法,规范还要求部署一系列其他必要的内部方法。

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

image.png image.png image.png

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

image.png

如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法 [[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 也是对象,那么它本身也部署了上述必要的内部方法,当我们通过代理对象访问属性值时:

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 拦截函数实现:

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 也属于“读取”操作,如下面的代码所示:

effect(() => {
  'foo' in obj
});

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

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

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

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

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

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

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

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

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

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:

trigger(target, ITERATE_KEY)
在什么情况下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行?我们用一段代码来说明:
const obj = { foo: 1 }
const p = new Proxy(obj, {/* ... */})
 
effect(() => {
  for (const key in p) {
    console.log(key) // foo
  }
})

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

p.bar = 2

由于对象 p 原本只有 foo 属性,因此 for...in 循环只会执行一次。现在为它添加了新的属性 bar,所以 for...in 循环就会由执行一次变成执行两次。
也就是说,当为对象添加新属性时,会对 for...in 循环产生影响,所以需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。但目前的实现还做不到这一点。
当我们为对象 p 添加新的属性 bar 时,并没有触发副作用函数重新执行,这是为什么呢?我们来看一下现在的 set 拦截函数的实现:

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 相关联的副作用函数也取出来执行就可以了:

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 相关联的副作用函数取出来执行。
添加新的属性来说,这么做没有什么问题,但修改已有属性,就有问题了,看如下代码:
const obj = { foo: 1 }
const p = new Proxy(obj, {
  /* ... */
})
 
effect(() => {
  // for...in 循环
  for (const key in p) {
    console.log(key) // foo
  }
})

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

p.foo = 2

修改属性其实不会对 for...in 循环产生影响。因为无论怎么修改一个属性的值,对于 for...in 循环来说都只会循环一次。
所以在这种情况下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。
然而无论是添加新属性,还是修改已有的属性值,其基本语义都是 [[Set]],我们都是通过 set 拦截函数来实现拦截的,如以下代码所示:

const p = new Proxy(obj, {
  // 拦截设置操作
  set(target, key, newVal, receiver) {
    // 设置属性值
    const res = Reflect.set(target, key, newVal, receiver)
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
 
    return res
  }
  // 省略其他拦截函数
})

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

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 相关联的副作用函数重新执行就行了,避免不必要性能损耗:

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()
    }
  })
}
通常我们会将操作类型封装为一个枚举值,例如:
const TriggerType = {
  SET: 'SET',
  ADD: 'ADD'
}

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

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

delete p.foo

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

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 相关联的副作用函数重新执行:

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 相关联的副作用函数重新执行。