Skip to content

理解 Proxy 和 Reflect ​

Proxy ​

Proxy可以创建一个代理对象,实现对其他对象的代理,注意, Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。

那么,代理是指什么的?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。

什么是基本语义,类似这种读取、设置属性值的操作,就属于基本语义的操作,即基本操作。

javascript
01 obj.foo // 读取属性 foo 的值
02 obj.foo++ // 读取和设置属性 foo 的值

在 JavaScript 的世界里,万物皆对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:

javascript
const fn = (name) => {
  console.log('我是:', name)
}
// 调用函数是对对象的基本操作
fn()

那么什么是非基本语义(操作) 呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作:

javascript
obj.fn()

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

理解 Proxy 只能够代理对象的基本语义很重要,这涉及到如何对数组或 Map、Set 等数据类型的代理。

Reflect ​

Reflect 是一个全局对象,它提供了许多拦截 JavaScript 操作静态方法。

javascript
Reflect.get()
Reflect.set()
Reflect.apply()

为什么引入 Reflect 呢?

Reflect 方法提供了一种一致的方式来拦截语言操作。例如,Reflect.get、Reflect.set、Reflect.has 等方法与 Object 上的 get、set、has 操作相对应,但它们的行为更加一致。

``````Reflect
  1. Pro****

<font style="color:rgb(6, 6, 7);">Reflect</font><font style="color:rgb(6, 6, 7);">Proxy</font>

``

​

根据ECMAScript 规范,在 JavaScript 中有两种对象,其中一种叫作常规对象(ordinary object),另一种叫作异质对象(exotic object)。

在 JavaScript 中,对象的实际语义是由对象的内部方法(internal method)指定的。所谓内部方法,指的是当我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于 JavaScript 使用者来说是不可见的。

举个例子,当我们访问对象属性时:

javascript
p.bar

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

规范要求的所有必要的内部方法如下图所示:

proxy也是一个对象,所以它本身也部署了上述必要的内部方法,我们在创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。

javascript
const p = new Proxy(obj, {
  // 指定代理对象p的get拦截函数
  get(target, key, receiver) {
    /
    return Reflect.get(target, key, receiver)
  },
  // 没有指定set拦截函数,此时当我们通过代理对象设置属性值时候,会调用原始对象的set内部方法
})

如何代理 Object ​

之前的实现中,我们用 get 拦截读取操作,这只能拦截通过“obj.key”这种属性读取的方式的操作方式,但在 js 中,对象的读取操作可能还有:

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

这些行为也属于属性的读取,我们也需要进行拦截。但对于这些操作,我们应该怎么拦截呢?这些操作属于非基本语义操作,需要查阅 ECMA 规范中对于这些操作在运行时的逻辑,找出存在于这些非基本语义操作后的基本语义操作,从而在 Proxy 中进行拦截。

这里直接给出结论:

  1. 对于 in操作符,通过拦截has操作:
javascript
const obj = { foo: 1 }
const p = new Proxy(obj, {
  has(target, key) {
    track(target, key)
    return Reflect.has(target, key)
  }
})
  1. 对于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)
  }
})

注意代码中我们用ITERATE_KEY作为追踪的 key,建立响应式对象与副作用函数的联系,这是因为 ownKeys 函数用于获取一个对象下所有属于自己的键值,不与具体的键绑定,所以我们自己构造了一个唯一的 key 。

这样在触发响应的时候也传入这个唯一的 key:

javascript
trigger(target, ITERATE_KEY)

合理触发响应 ​

本部分主要解决 object 代理中的两个问题:

1.当值没有发生变化时,不需要触发响应

  1. 原型上的值变化时,合理触发响应

对于问题 1,解决的方法就是在 set 拦截函数里,对新旧值进行判断:

javascript
// 拦截设置操作
set(target, key, newVal, receiver) {
  // 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
  const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD
  // 获取旧值
  const oldValue = target[key]
  const res = Reflect.set(target, key, newVal, receiver)
  // 比较新值和旧值,只有在他们不全等且不都为NaN的时候触发响应
  if (oldValue !== newVal && (oldValue === oldValue || newVal === newVal)) {
    // 把副作用函数从桶里取出并执行
    trigger(target, key as string, type)
  }

  return res
},

对于问题 2,主要是针对 类似下边这种场景:我们定义了两个响应式对象 child 和 parent,并把 parent 设置为 child 的原型,此时我们会发现当我们修改 child 上的继承自 parent 的属性时,会触发两次更新:

javascript
const child = reactive({})
const parent = reactive( { bar: 1 })
// 使用 parent 作为 child 的原型
Object.setPrototypeOf(child, parent)
effect(() => {
  console.log(child.bar) // 1
})
// 修改 child.bar 的值
child.bar = 2 // 会导致副作用函数重新执行两次

这个问题是因为当我们访问 child 上不存在的属性时,会接着寻找其原型,并调用其原型上的 [[SET]]方法,而上面的场景就是其原型 parent 也是一个响应式对象,所以 effect 副作用函数与 child.bar 和 parent.bar 都建立了联系,也就会触发两次响应。

解决的办法是 利用:

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

首先给代理对象的 get 拦截函数添加一个能力,让其可以访问原始对象:

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

javascript
01 function reactive(obj) {
02   return new Proxy(obj {
03     set(target, key, newVal, receiver) {
04       const oldVal = target[key]
05       const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
06       const res = Reflect.set(target, key, newVal, receiver)
07
08       // target === receiver.raw 说明 receiver 就是 target 的代理对象
09       if (target === receiver.raw) {
10         if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
11           trigger(target, key, type)
12         }
13       }
14
15       return res
16     }
17     // 省略其他拦截函数
18   })
19 }

浅响应和深响应 ​

我们目前实现的浅响应,即只能收集对象的第一层属性值,实现深响应也很简单:

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
    }
    // 省略其他拦截函数
  })
}

数组代理 ​

题外话:vue2 中对数组代理 ​

我们知道 vue2 中对数组代理并不完善,不能检测以下数组的变动:

  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength

那么这是为什么,vue3 总又为何可以?

我们知道 vue2 中是通过object.defineProperty来实现响应式的,而 vue2 对数组的代理并没有使用object.defineProperty,原因并不是因为object.defineProperty不能监听数组,因为在 js 中数组也是一个特殊的对象,数组下表就是它的键值:

javascript
const arr = [1, 2, 3]
arr.forEach((val, index) => {
  Object.defineProperty(arr, index, {
    get() {
      console.log('监听到了')
      return val
    },
    set(newVal) {
      console.log('变化了:', val, newVal)
      val = newVal
    }
  })
})

vue2 中没有使用 **Object.defineProperty** 主要是因为性能考虑。

vue2 中对数组代理的实现:通过对数组原型上的 7 个方法进行重写进行监听的,原理就是使用拦截器覆盖 Array.prototype,之后再去使用 Array 原型上的方法的时候,其实使用的是拦截器提供的方法,在拦截器里面才真正使用原生 Array 原型上的方法去操作数组。

javascript
// 拦截器其实就是一个和 Array.prototype 一样的对象。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(function (method) {
    // 缓存原始方法
    const original = arrayProto[method]
    Object.defineProperty(arrayMethods, method, {
        value: function mutator(...args) {
            // 最终还是使用原生的 Array 原型方法去操作数组
            const result = original.apply(this, args)
            // 获取 Observer 对象实例
            const ob = this.__ob__
            // 通过 Observer 对象实例上 Dep 实例对象去通知依赖进行更新
            ob.dep.notify()
        },
        eumerable: false,
        writable: false,
        configurable: true
    })
})

数组与普通对象的区别 ​

再前边,我们介绍了 JavaScript 中有两种对象:常规对象和异质对象。数组就是一个异质对象,这是因为数组对象的 [[DefineOwnProperty]] 内部方法与常规对象不同。换句话说,数组对象除了 [[DefineOwnProperty]] 这个内部方法之外,其他内部方法的逻辑都与常规对象相同。

数组的下表也相当于数组对象的键值

因此,前边实现的代理普通对象的大部分代码依然可以使用:

javascript
const arr = reactive(['foo'])
effect(() => {
  console.log(arr[0]) // 'foo'
})
arr[0] = 'bar' // 能够触发响应

但数组对象除了通过下标访问,还有很多其他的操作方式,这些都是我们需要去处理的地方:

读取操作:

● 通过索引访问数组元素值: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 等。

数组的索引与 length ​

如果设置的索引值大于数组当前的长度,那么要更新数组的 length 属性:

javascript
const a = [0]
a[1] =1 //数组length发生变化

这种情况下,我们需要触发与 length 属性相关联的副作用函数执行

❓mark, 这里还不太理解,为什么还需要监听 length 变化,可能看到后边章节会理解吧