理解 Proxy 和 Reflect
Proxy
Proxy可以创建一个代理对象,实现对其他对象的代理,注意, Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。
那么,代理是指什么的?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。
什么是基本语义,类似这种读取、设置属性值的操作,就属于基本语义的操作,即基本操作。
01 obj.foo // 读取属性 foo 的值
02 obj.foo++ // 读取和设置属性 foo 的值在 JavaScript 的世界里,万物皆对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:
const fn = (name) => {
console.log('我是:', name)
}
// 调用函数是对对象的基本操作
fn()那么什么是非基本语义(操作) 呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作:
obj.fn()实际上,调用一个对象下的方法,是由两个基本语义组成的。第一个基本语义是 get,即先通过 get 操作得到 obj.fn 属性。第二个基本语义是函数调用,即通过 get 得到obj.fn 的值后再调用它。
理解 Proxy 只能够代理对象的基本语义很重要,这涉及到如何对数组或 Map、Set 等数据类型的代理。
Reflect
Reflect 是一个全局对象,它提供了许多拦截 JavaScript 操作静态方法。
Reflect.get()
Reflect.set()
Reflect.apply()为什么引入 Reflect 呢?
Reflect 方法提供了一种一致的方式来拦截语言操作。例如,Reflect.get、Reflect.set、Reflect.has 等方法与 Object 上的 get、set、has 操作相对应,但它们的行为更加一致。
- 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 使用者来说是不可见的。
举个例子,当我们访问对象属性时:
p.bar引擎内部会调用 [[Get]] 这个内部方法来读取属性值。
规范要求的所有必要的内部方法如下图所示:

proxy也是一个对象,所以它本身也部署了上述必要的内部方法,我们在创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。
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 中进行拦截。
这里直接给出结论:
- 对于
in操作符,通过拦截has操作:
const obj = { foo: 1 }
const p = new Proxy(obj, {
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
})- 对于
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)
}
})注意代码中我们用ITERATE_KEY作为追踪的 key,建立响应式对象与副作用函数的联系,这是因为 ownKeys 函数用于获取一个对象下所有属于自己的键值,不与具体的键绑定,所以我们自己构造了一个唯一的 key 。
这样在触发响应的时候也传入这个唯一的 key:
trigger(target, ITERATE_KEY)合理触发响应
本部分主要解决 object 代理中的两个问题:
1.当值没有发生变化时,不需要触发响应
- 原型上的值变化时,合理触发响应
对于问题 1,解决的方法就是在 set 拦截函数里,对新旧值进行判断:
// 拦截设置操作
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 的属性时,会触发两次更新:
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 都建立了联系,也就会触发两次响应。
解决的办法是 利用:
// parent 的 set 拦截函数
set(target, key, value, receiver) {
// target 是原始对象 proto
// receiver 仍然是代理对象 child
}首先给代理对象的 get 拦截函数添加一个能力,让其可以访问原始对象:
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 拦截函数中:
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 }浅响应和深响应
我们目前实现的浅响应,即只能收集对象的第一层属性值,实现深响应也很简单:
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 中对数组代理并不完善,不能检测以下数组的变动:
- 当你利用索引直接设置一个数组项时,例如:
vm.items[indexOfItem] = newValue - 当你修改数组的长度时,例如:
vm.items.length = newLength
那么这是为什么,vue3 总又为何可以?
我们知道 vue2 中是通过object.defineProperty来实现响应式的,而 vue2 对数组的代理并没有使用object.defineProperty,原因并不是因为object.defineProperty不能监听数组,因为在 js 中数组也是一个特殊的对象,数组下表就是它的键值:
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 原型上的方法去操作数组。
// 拦截器其实就是一个和 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]] 这个内部方法之外,其他内部方法的逻辑都与常规对象相同。
数组的下表也相当于数组对象的键值
因此,前边实现的代理普通对象的大部分代码依然可以使用:
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 属性:
const a = [0]
a[1] =1 //数组length发生变化这种情况下,我们需要触发与 length 属性相关联的副作用函数执行
❓mark, 这里还不太理解,为什么还需要监听 length 变化,可能看到后边章节会理解吧