理解 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 变化,可能看到后边章节会理解吧