《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 是一个全局对象,提供了一些方法,例如:
- Reflect.get()
- Reflect.set()
- 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 个必要的内部方法:



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

如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法 [[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
});
这本质上也是在进行“读取”操作。响应系统应该拦截所有读取操作,以便在数据变化时正确地触发响应。以下是普通对象所有可能的读取操作:
- 访问属性:obj.foo
- 判断对象或原型上是否存在给定的 key:key in obj
- 使用 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 相关联的副作用函数重新执行。