5.1 理解 Proxy 和 Reflect
Proxy 可以创建一个代理对象,实现对其他对象的代理,拦截并重新定义对对象的基本操作。
注意,Proxy 只能代理对象,不能代理非对象值(如字符串、布尔值等)。
基本操作包括读取属性值、设置属性值等。例如:
javascript
obj.foo // 读取属性 foo 的值
obj.foo++ // 读取并设置属性 foo 的值
可以使用 Proxy 拦截基本操作:
javascript
const p = new Proxy(obj, {
// 拦截读取属性操作
get() { /*...*/ },
// 拦截设置属性操作
set() { /*...*/ }
})
Proxy 构造函数接收两个参数:被代理对象和一个包含一组拦截函数的对象(trap夹子)。get 函数用于拦截读取操作,set 函数用于拦截设置操作。
在 JS 中,函数也是对象,所以调用函数也是对一个对象的基本操作:
javascript
const fn = (name) => {
console.log('我是:', name)
}
// 调用函数
fn()
我们可以用 Proxy 里的 apply 函数进行拦截:
javascript
const p2 = new Proxy(fn, {
// 使用 apply 拦截函数调用
apply(target, thisArg, argArray) {
target.call(thisArg, ...argArray)
}
})
p2('hcy') // 输出:'我是:hcy'
Proxy 只能拦截对象的基本操作。
非基本操作,如调用对象下的方法(称为复合操作):
javascript
obj.fn()
复合操作实际上由两个基本操作组成的:首先是 get 操作得到 obj.fn 属性,其次是函数调用。即获得 obj.fn 值后再调用它,这就是我们刚才提到的 apply。
理解 Proxy 只能代理对象的基本操作对于后续实现数组或 Map、Set 等数据类型的代理至关重要。
我们来看 Reflect。Reflect 是一个全局对象,提供了一些方法,例如:
- Reflect.get()
- Reflect.set()
- Reflect.apply()
Reflect 中的方法与 Proxy 的拦截器方法同名。它们提供了对象操作的默认行为。例如,以下两个操作是等价的:
javascript
const obj = { foo: 1 }
// 直接读取
console.log(obj.foo) // 1
// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo')) // 1
如果两种操作等价,Reflect 存在的意义是什么呢?
Reflect.get() 还接受第三个参数,也就是 receiver,你可以将它看作函数调用中的 this,例如:
javascript
const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 输出的是 2 而不是 1
在这段代码中,我们指定第三个参数 receiver 为一个对象 { foo: 2 },这时读取到的值是 receiver 对象的 foo 属性值。
事实上,Reflect 的各个方法都有很多其他用途,但在此我们只关注与响应式数据实现相关的部分,我们回顾一下上一节的响应式代码:
javascript
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 属性:
javascript
const obj = {
foo: 1,
get bar() {
return this.foo
}
}
上述代码 bar 是一个访问器属性,它返回了 this.foo 的值。接下来,我们在 effect 的副作用函数中通过代理对象 p 访问 bar 属性:
javascript
effect(() => {
console.log(p.bar) // 1
})
这个过程中发生了什么?当执行 effect 注册的副作用函数时,会读取 p.bar 属性。
因为 p.bar 是一个访问器属性,所以会执行 getter 函数。
getter 函数通过 this.foo 读取了 foo 属性值,所以我们认为副作用函数和 foo 属性之间会建立联系。当我们尝试改变 p.foo 的值时:
javascript
p.foo++
副作用函数并没有重新执行。问题在哪里呢?
实际上,问题出在 bar 属性的 getter 函数里:
javascript
const obj = {
foo: 1,
get bar() {
// 这里的 this 指向的是谁?
return this.foo
}
}
当我们使用 this.foo 读取 foo 属性值时,这里的 this 指向的是谁呢?
我们回顾一下整个流程。首先,我们通过代理对象 p 访问 p.bar,这会触发代理对象的 get 拦截函数:
javascript
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。很明显,通过原始对象访问属性无法建立响应联系,相当于下面:
javascript
effect(() => {
// obj 是原始数据,不是代理对象,这样的访问不能够建立响应联系
obj.foo
})
这就是问题所在,无法触发响应。那么该如何解决这个问题呢?这时 Reflect.get 函数就派上用场了。我们可以修改代码如下:
javascript
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:
javascript
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 使用者来说是不可见的。例如,当我们访问对象属性时:
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 也是对象,那么它本身也部署了上述必要的内部方法,当我们通过代理对象访问属性值时:
javascript
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 拦截函数实现:
javascript
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 也属于"读取"操作,如下面的代码所示:
javascript
effect(() => {
'foo' in obj
});
这本质上也是在进行"读取"操作。响应系统应该拦截所有读取操作,以便在数据变化时正确地触发响应。以下是普通对象所有可能的读取操作:
- 访问属性:obj.foo
- 判断对象或原型上是否存在给定的 key:key in obj
- 使用 for...in 循环遍历对象:for (const key in obj) {}
首先,可以通过 get 拦截器实现属性访问:
javascript
const obj = { foo: 1 }
const p = new Proxy(obj, {
get(target, key, receiver) {
// 建立联系
track(target, key)
// 返回属性值
return Reflect.get(target, key, receiver)
}
})
为拦截 in 操作符,我们需要使用 has 拦截器:
javascript
const obj = { foo: 1 }
const p = new Proxy(obj, {
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
})
这样,当我们在副作用函数中通过 in 操作符操作响应式数据时,就能够建立依赖关系:
javascript
effect(() => {
'foo' in p; // 将会建立依赖关系
});
要拦截 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)
}
})
因为 ownKeys 拦截器是获取所有 key,无法获取具体操作的 key。在这里,我们使用 ITERATE_KEY 作为追踪的 key。
在触发响应时,也要触发 ITERATE_KEY:
javascript
trigger(target, ITERATE_KEY)
在什么情况下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行?我们用一段代码来说明:
javascript
const obj = { foo: 1 }
const p = new Proxy(obj, {/* ... */})
effect(() => {
for (const key in p) {
console.log(key) // foo
}
})
执行副作用函数后,会与 ITERATE_KEY 建立响应联系。然后,我们尝试为对象 p 添加新属性 bar:
javascript
p.bar = 2
由于对象 p 原本只有 foo 属性,因此 for...in 循环只会执行一次。现在为它添加了新的属性 bar,所以 for...in 循环就会由执行一次变成执行两次。
也就是说,当为对象添加新属性时,会对 for...in 循环产生影响,所以需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。但目前的实现还做不到这一点。
当我们为对象 p 添加新的属性 bar 时,并没有触发副作用函数重新执行,这是为什么呢?我们来看一下现在的 set 拦截函数的实现:
javascript
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 相关联的副作用函数也取出来执行就可以了:
javascript
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 相关联的副作用函数取出来执行。
添加新的属性来说,这么做没有什么问题,但修改已有属性,就有问题了,看如下代码:
javascript
const obj = { foo: 1 }
const p = new Proxy(obj, {
/* ... */
})
effect(() => {
// for...in 循环
for (const key in p) {
console.log(key) // foo
}
})
当我们修改 p.foo 的值时:
javascript
p.foo = 2
修改属性其实不会对 for...in 循环产生影响。因为无论怎么修改一个属性的值,对于 for...in 循环来说都只会循环一次。
所以在这种情况下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。
然而无论是添加新属性,还是修改已有的属性值,其基本语义都是 [[Set]],我们都是通过 set 拦截函数来实现拦截的,如以下代码所示:
javascript
const p = new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal, receiver) {
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)
// 把副作用函数从桶里取出并执行
trigger(target, key)
return res
}
// 省略其他拦截函数
})
解决上述问题,我们可以在 set 拦截函数内去区分操作的类型,到底是添加新属性还是设置已有属性:
javascript
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 相关联的副作用函数重新执行就行了,避免不必要性能损耗:
javascript
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()
}
})
}
通常我们会将操作类型封装为一个枚举值,例如:
javascript
const TriggerType = {
SET: 'SET',
ADD: 'ADD'
}
这样代码比较清晰,对后期代码的维护,是非常有帮助的。
关于对象的代理,还有最后删除属性操作的代理:
javascript
delete p.foo
delete 操作符的行为依赖 [[Delete]] 内部方法,该内部方法可以使用 deleteProperty 拦截:
javascript
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 相关联的副作用函数重新执行:
javascript
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 相关联的副作用函数重新执行。
5.4 合理触发响应
为了合理触发响应,我们需要处理一些问题。
首先,当值没有变化时,我们不应该触发响应:
javascript
const obj = { foo: 1 }
const p = new Proxy(obj, { /* ... */ })
effect(() => {
console.log(p.foo)
})
// 设置 p.foo 的值,但值没有变化
p.foo = 1
上述代码,p.foo 的初始值为 1,当为 p.foo 设置新的值时,如果值没有发生变化,则不需要触发响应。
为了满足需求,在调用 trigger 函数触发响应之前,我们需要检查值是否发生了变化:
javascript
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
// 先获取旧值
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
// 比较新值与旧值,只要当不全等的时候才触发响应
if (oldVal !== newVal) {
trigger(target, key, type)
}
return res
}
})
在 set 函数内,先获取旧值 oldVal,比较新旧值,只有不全等时才触发响应。
但是,全等比较对 NaN 的处理有缺陷,因为 NaN === NaN 返回 false,为了解决这个问题,需要加一个条件:
javascript
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
// 先获取旧值
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
// 比较新值与旧值,只有当它们不全等,并且不都是 NaN 的时候才触发响应
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
return res
}
})
现在,我们已经解决了对 NaN 的处理问题。当新旧值不全等且不都是 NaN 时,才触发响应。
我们还需要处理从原型上继承属性的情况。首先,我们封装一个 reactive 函数,接受一个对象作为参数,返回创建的响应式数据:
javascript
function reactive(obj) {
return new Proxy(obj, {
// 省略拦截函数
})
}
接下来,创建一个例子:
javascript
const obj = {}
const child = reactive(obj)
const proto = { bar: 1 }
const parent = reactive(proto)
// 使用 parent 作为 child 的原型
Object.setPrototypeOf(child, parent)
effect(() => {
console.log(child.bar) // 1
})
// 修改 child.bar 的值
child.bar = 2 // 会导致副作用函数重新执行两次
在这个例子中,我们创建了两个响应式对象 child 和 parent,并将 parent 设置为 child 的原型。
在副作用函数中访问 child.bar 时,值是从原型上继承的。当我们执行 child.bar = 2 时,副作用函数会执行两次,导致不必要的更新。
我们分析下整个过程,访问 child.bar 时,触发 child 代理对象的 get 拦截函数。在拦截函数中,引擎使用 Reflect.get(target, key, receiver) 得到结果。如果对象自身不存在该属性,会获取对象的原型,并调用原型的 [[Get]] 方法得到最终结果。
在这个例子中,由于 child 自身没有 bar 属性,所以最终得到的实际上是 parent.bar 的值。但 parent 本身也是响应式数据,因此在副作用函数中访问 parent.bar 的值时,会建立响应联系。所以,child.bar 和 parent.bar 都与副作用函数建立了响应联系。
当设置 child.bar 的值时,我们需要弄清楚为什么副作用函数会连续执行两次。在设置过程中,会先触发 child 代理对象的 set 拦截函数。由于 obj 上不存在 bar 属性,会取得 obj 的原型 parent,并执行 parent 代理对象的 set 拦截函数。这导致副作用函数被触发两次。
为了解决这个问题,我们可以在 set 拦截函数内区分这两次更新。当我们设置 child.bar 的值时,receiver 始终是 child,而 target 则会变化:
javascript
// child 的 set 拦截函数
set(target, key, value, receiver) {
// target 是原始对象 obj
// receiver 是代理对象 child
}
// parent 的 set 拦截函数
set(target, key, value, receiver) {
// target 是原始对象 proto
// receiver 仍然是代理对象 child
}
我们只需要判断 receiver 是否是 target 的代理对象即可。只有当 receiver 是 target 的代理对象时才触发更新,从而屏蔽原型引起的更新。
这就需要我们为 get 拦截函数添加一个能力,使代理对象可以通过 raw 属性访问原始数据:
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 拦截函数中判断 receiver 是不是 target 的代理对象:
javascript
function reactive(obj) {
return new Proxy(obj, {
set(target, key, newVal, receiver) {
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
// target === receiver.raw 说明 receiver 就是 target 的代理对象
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
}
// 省略其他拦截函数
})
}
通过这种方式,我们只在 receiver 是 target 的代理对象时触发更新,从而避免了由原型引起的不必要的更新操作。
5.5 浅响应与深响应
事实上,我们目前实现的 reactive 是浅响应的。看以下代码:
javascript
const obj = reactive({ foo: { bar: 1 } })
effect(() => {
console.log(obj.foo.bar)
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2
首先,创建了 obj 代理对象,该对象的 foo 属性值是另一个对象,即 { bar: 1 }。
然后,在副作用函数内访问 obj.foo.bar 的值。但我们发现,后续对 obj.foo.bar 的修改无法触发副作用函数的重新执行。
为什么呢?让我们看一下现有的实现:
javascript
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target
}
track(target, key)
// 当读取属性值时,直接返回结果
return Reflect.get(target, key, receiver)
}
// 省略其他拦截函数
})
}
上述代码显示,当我们读取 obj.foo.bar 时,首先要读取 obj.foo 的值。
这里我们直接使用 Reflect.get 函数返回 obj.foo 的结果。
由于通过 Reflect.get 得到的 obj.foo 结果是一个普通对象,即 { bar: 1 },它不是响应式对象,因此在副作用函数中访问 obj.foo.bar 时,无法建立响应联系。
为解决此问题,我们需要对 Reflect.get 返回结果进行一层包装:
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
}
// 省略其他拦截函数
})
}
如上述代码所示,当读取属性值时,我们首先检测该值是否是对象。如果是对象,就递归地调用 reactive 函数将其包装成响应式数据并返回。
这样,当使用 obj.foo 读取 foo 属性值时,得到的结果就是一个响应式数据。因此,再通过 obj.foo.bar 读取 bar 属性值时,就会自然地建立响应联系。这样,当修改 obj.foo.bar 的值时,就能触发副作用函数重新执行。
然而,并非所有情况下我们都希望深响应。这就产生了 shallowReactive,即浅响应。浅响应的是只有对象的第一层属性是响应的,例如:
javascript
const obj = shallowReactive({ foo: { bar: 1 } })
effect(() => {
console.log(obj.foo.bar)
})
// obj.foo 是响应的,可以触发副作用函数重新执行
obj.foo = { bar: 2 }
// obj.foo.bar 不是响应的,不能触发副作用函数重新执行
obj.foo.bar = 3
在这个例子中,我们使用 shallowReactive 函数创建了一个浅响应的代理对象 obj。
可以发现,只有对象的第一层属性是响应的,第二层及更深层次的属性则不是响应的。
实现此功能并不难,如下面的代码所示:
javascript
// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
function createReactive(obj, isShallow = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === 'raw') {
return target
}
const res = Reflect.get(target, key, receiver)
track(target, key)
// 如果是浅响应,则直接返回原始值
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return reactive(res)
}
return res
}
// 省略其他拦截函数
})
}
// 使用 createReactive 函数轻松实现 reactive 和 shallowReactive 函数
function reactive(obj) {
return createReactive(obj)
}
function shallowReactive(obj) {
return createReactive(obj, true)
}
在上述代码中,我们将对象创建的工作封装到一个新的函数 createReactive 中。
该函数除了接收原始对象 obj 之外,还接收参数 isShallow,它是一个布尔值,代表是否创建浅响应对象。
有了 createReactive 函数后,我们就可以使用它轻松地实现 reactive 和 shallowReactive 函数。
5.6 只读和浅只读
有时我们希望某些数据是只读的,即用户尝试修改时会收到警告。
例如,组件接收到的 props 应该是只读的。这时我们可以使用 readonly 函数将数据设为只读:
javascript
const obj = readonly({ foo: 1 })
// 尝试修改数据,会得到警告
obj.foo = 2
只读本质上也是对数据对象的代理,我们可以为 createReactive 函数增加第三个参数 isReadonly 来实现:
javascript
// 增加第三个参数 isReadonly,代表是否只读,默认为 false,即非只读
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal, receiver) {
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
},
deleteProperty(target, key) {
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const res = Reflect.deleteProperty(target, key)
if (res && hadKey) {
trigger(target, key, 'DELETE')
}
return res
}
// 省略其他拦截函数
})
}
当使用 createReactive 创建代理对象时,可以通过第三个参数指定是否创建一个只读的代理对象
同时,我们还修改了 set 拦截函数和 deleteProperty 拦截函数的实现,因为对于一个对象来说,只读意味着既不可以设置对象的属性值,也不可以删除对象的属性。
当然,如果一个数据是只读的,那就意味着任何方式都无法修改它,所以也就不需要调用 track 函数追踪响应:
javascript
const obj = readonly({ foo: 1 });
effect(() => {
obj.foo; // 可以读取值,但是不需要在副作用函数与数据之间建立响应联系
});
为了实现该功能,我们需要修改 get 拦截函数的实现:
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === 'raw') {
return target
}
// 非只读的时候才需要建立响应联系
if (!isReadonly) {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return reactive(res)
}
return res
}
// 省略其他拦截函数
})
}
如上面的代码所示,只有非只读的时候才需要建立响应联系。基于此,我们就可以实现 readonly 函数了:
javascript
function readonly(obj) {
return createReactive(obj, false, true /* 只读 */);
}
然而,上面实现的 readonly 函数更应该叫作 shallowReadonly,因为它没有做到深只读:
javascript
const obj = readonly({ foo: { bar: 1 } });
obj.foo.bar = 2; // 仍然可以修改
所以为了实现深只读,我们还应该在 get 拦截函数内递归地调用 readonly 将数据包装成只读的代理对象,并将其作为返回值返回:
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === 'raw') {
return target
}
if (!isReadonly) {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
// 如果数据为只读,则调用 readonly 对值进行包装
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
// 省略其他拦截函数
})
}
上述代码,我们判断是否只读,如果只读则调用 readonly 函数对值进行包装,并把包装后的只读对象返回。
对于 shallowReadonly,实际上我们只需要修改 createReactive 的第二个参数即可:
javascript
function readonly(obj) {
return createReactive(obj, false, true);
}
function shallowReadonly(obj) {
return createReactive(obj, true, true);
}
上述代码,在 shallowReadonly 函数内调用 createReactive 函数创建代理对象时,将第二个参数 isShallow 设置为 true,这样就可以创建一个浅只读的代理对象了。
5.7 代理数组
JS 中的数组是一种异质对象,其 [[DefineOwnProperty]] 内部方法与常规对象不同。
但除此之外,数组的其他内部方法与常规对象相同。因此,在实现数组代理时,大部分用于代理普通对象的代码依然适用,如下:
javascript
const arr = reactive(['foo'])
effect(() => {
console.log(arr[0]) // 'foo'
})
arr[0] = 'bar' // 触发响应
上述代码,我们通过索引读取或设置数组元素的值时,代理对象的 get/set 拦截函数也会执行,使得数组索引的读取和设置操作是响应式的。
然而,数组操作与普通对象操作仍有不同。数组的读取操作包括:
- 通过索引访问数组元素值: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 等
虽然代理数组相对复杂,但因为数组本身也是对象,所以大部分用于代理常规对象的代码对数组依然有效。
接下来,我们将探讨如何通过索引读取或设置数组元素值。
5.7.1 数组的索引与 length
在前面的例子中,通过数组的索引访问元素值已经建立了响应关系。
但是,通过索引设置数组元素值与设置对象属性值仍存在根本差异,因为数组对象部署的 [[DefineOwnProperty]] 内部方法不同于常规对象。
规范明确说明,如果设置的索引值大于数组当前长度,需要更新数组的 length 属性。因此,在触发响应时,也应触发与 length 属性相关联的副作用函数重新执行。
javascript
const arr = reactive(['foo']) // 数组的原长度为 1
effect(() => {
console.log(arr.length) // 1
})
// 设置索引 1 的值,会导致数组的长度变为 2
arr[1] = 'bar'
为了实现这个目标,我们需要修改 set 拦截函数:
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal, receiver) {
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}
const oldVal = target[key]
// 如果属性不存在,则说明是在添加新的属性,否则是设置已有属性
const type = Array.isArray(target)
? // 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,
// 如果是,则视作 SET 操作,否则是 ADD 操作
Number(key) < target.length
? 'SET'
: 'ADD'
: Object.prototype.hasOwnProperty.call(target, key)
? 'SET'
: 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
}
// 省略其他拦截函数
})
}
在判断操作类型时,我们新增了对数组类型的判断。如果代理的目标对象是数组,那么对于操作类型的判断会有所区别。
接下来,我们可以在 trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行:
javascript
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
// 省略部分内容
// 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数
if (type === 'ADD' && Array.isArray(target)) {
// 取出与 length 相关联的副作用函数
const lengthEffects = depsMap.get('length')
// 将这些副作用函数添加到 effectsToRun 中,待执行
lengthEffects &&
lengthEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
这样,我们就实现了当数组长度发生变化时,正确地触发与 length 属性相关联的副作用函数重新执行。
在另一方面,实际上修改数组的 length 属性也会隐式地影响数组元素。例如:
javascript
const arr = reactive(['foo'])
effect(() => {
// 访问数组的第 0 个元素
console.log(arr[0]) // foo
})
// 将数组的长度修改为 0,导致第 0 个元素被删除,因此应该触发响应
arr.length = 0
然而,并非所有对 length 属性的修改都会影响数组中的已有元素。
上面如果我们将 length 属性设置为 100,这并不会影响第 0 个元素,所以也就不需要触发副作用函数重新执行
当修改 length 属性值时,只有那些索引值大于或等于新的 length 属性值的元素才需要触发响应。
为了实现这一目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去:
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal, receiver) {
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}
const oldVal = target[key]
const type = Array.isArray(target)
? Number(key) < target.length
? 'SET'
: 'ADD'
: Object.prototype.hasOwnProperty.call(target, key)
? 'SET'
: 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
// 增加第四个参数,即触发响应的新值
trigger(target, key, type, newVal)
}
}
return res
}
})
}
接着,我们还需要修改 trigger 函数:
javascript
// 为 trigger 函数增加第四个参数,newVal,即新值
function trigger(target, key, type, newVal) {
const depsMap = bucket.get(target)
if (!depsMap) return
// 省略其他代码
// 如果操作目标是数组,并且修改了数组的 length 属性
if (Array.isArray(target) && key === 'length') {
// 对于索引大于或等于新的 length 值的元素,
// 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行
depsMap.forEach((effects, index) => {
if (index >= newVal) {
effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
5.7.2 遍历数组
既然数组也是对象,就意味着我们同样可以使用 for...in 循环遍历数组:
javascript
const arr = reactive(['foo'])
effect(() => {
for (const key in arr) {
console.log(key) // 0
}
})
但是我们应该尽量避免使用 for...in 循环遍历数组,
前面说数组对象和常规对象的不同仅体现在 [[DefineOwnProperty]] 这个内部方法上。
因此,使用 for...in 循环遍历数组与遍历常规对象并无差异,可以使用 ownKeys 拦截函数进行拦截。
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 省略其他拦截函数
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
}
上述代码取自前文,我们为了追踪对普通对象的 for...in 操作,创建了 ITERATE_KEY 作为追踪的 key。
然而,这是为了代理普通对象而考虑的。对于普通对象来说,只有当添加或删除属性值时才会影响 for...in 循环的结果,这时候就需要取出与 ITERATE_KEY 相关联的副作用函数重新执行。
对于数组来说,情况有所不同。我们看看哪些操作会影响 for...in 循环对数组的遍历:
- 添加新元素:arr[100] = 'bar'
- 修改数组长度:arr.length = 0
实际上,无论是为数组添加新元素,还是直接修改数组的长度,本质上都是因为修改了数组的 length 属性。一旦数组的 length 属性被修改,那么 for...in 循环对数组的遍历结果就会改变。
所以,在这种情况下我们应该触发响应。我们可以在 ownKeys 拦截函数内,判断当前操作目标 target 是否是数组,如果是,则使用 length 作为 key 建立响应联系:
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 省略其他拦截函数
ownKeys(target) {
// 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
}
这样,无论是为数组添加新元素,还是直接修改 length 属性,都能够正确地触发响应:
javascript
const arr = reactive(['foo'])
effect(() => {
for (const key in arr) {
console.log(key
}
})
arr[1] = 'bar' // 能够触发副作用函数重新执行
arr.length = 0 // 能够触发副作用函数重新执行
现在,当我们为数组添加新元素或直接修改 length 属性时,都能正确地触发响应。这样,我们已经解决了数组在遍历时可能遇到的问题。
讲解了使用 for...in 遍历数组,接下来我们再看看使用 for...of 遍历数组的情况。
for...in 遍历数组与 for...of 遍历数组的区别在于,for...of 用于遍历可迭代对象(iterable object)。可迭代对象是实现了 @@iterator 方法的对象,例如 Symbol.iterator 方法。
下面创建一个实现了 Symbol.iterator 方法的对象:
javascript
const obj = {
val: 0,
[Symbol.iterator]() {
return {
next() {
return {
value: obj.val++,
done: obj.val > 10 ? true : false
}
}
}
}
}
for (const value of obj) {
console.log(value) // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}
数组内建了 Symbol.iterator 方法的实现,我们可以手动执行迭代器的 next 函数,这样也可以得到期望的结果。这也是默认情况下数组可以使用 for...of 遍历的原因:
javascript
const arr = [1, 2, 3, 4, 5]
// 获取并调用数组内建的迭代器方法
const itr = arr[Symbol.iterator]()
console.log(itr.next()) // {value: 1, done: false}
console.log(itr.next()) // {value: 2, done: false}
console.log(itr.next()) // {value: 3, done: false}
console.log(itr.next()) // {value: 4, done: false}
console.log(itr.next()) // {value: 5, done: false}
console.log(itr.next()) // {value: undefined, done: true}
for (const val of arr) {
console.log(val) // 1, 2, 3, 4, 5
}
数组迭代器的执行会读取数组的 length 属性。如果迭代的是数组元素值,还会读取数组的索引。我们可以给出一个数组迭代器的模拟实现:
javascript
const arr = [1, 2, 3, 4, 5]
arr[Symbol.iterator] = function () {
const target = this
const len = target.length
let index = 0
return {
next() {
return {
value: index < len ? target[index] : undefined,
done: index++ >= len
}
}
}
}
上述代码,我们用自定义的实现覆盖了数组内建的迭代器方法,但它仍然能够正常工作。
这个例子表明,迭代数组时,只需要在副作用函数与数组的长度和索引之间建立响应联系,就能够实现响应式的 for...of 迭代:
javascript
const arr = reactive([1, 2, 3, 4, 5])
effect(() => {
for (const val of arr) {
console.log(val)
}
})
arr[1] = 'bar' // 能够触发响应
arr.length = 0 // 能够触发响应
注意,在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系时,需要避免发生意外的错误,以及性能上的考虑。因此需要修改 get 拦截函数:
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
console.log('get: ', key)
if (key === 'raw') {
return target
}
// 添加判断,如果 key 的类型是 symbol,则不进行追踪
if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
})
}
在调用 track 函数进行追踪之前,需要添加一个判断条件,即只有当 key 的类型不是 symbol 时才进行追踪,这样就避免了上述问题。
5.7.3 数组查找
通过之前的学习,我们了解到数组的内部方法大都依赖于对象的基础语义。通常情况下,不需特殊处理就可以正常使用。例如:
javascript
const arr = reactive([1, 2])
effect(() => {
console.log(arr.includes(1)) // 初始打印 true
})
arr[0] = 3 // 副作用函数重新执行,并打印 false
这是因为 includes 方法在寻找特定值时,会访问数组的 length 属性以及数组索引。因此,当我们更改某个索引指向的元素值时,就能触发响应。
但 includes 方法并不总是按预期工作,例如:
javascript
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(arr[0])) // false
在这段代码中,我们创建一个对象 obj 并将其作为数组的第一个元素。
然后创建一个响应式数组,并尝试使用 includes 方法查找数组中是否包含第一个元素。这个操作应该返回 true,但实际上返回 false。
includes 方法通过索引读取数组元素的值,但是这里的 0 是代理对象 arr。所以,通过代理对象来访问元素值时,如果值还可以被代理,那么返回的是新的代理对象而非原始对象。以下代码可以证明这一点:
javascript
if (typeof res === 'object' && res !== null) {
// 如果值可以被代理,则返回代理对象
return isReadonly ? readonly(res) : reactive(res)
}
在arr.includes(arr[0])中,arr[0] 得到的是一个代理对象,而在 includes 方法内部通过 arr 访问数组元素时也得到一个代理对象。
但这两个代理对象是不同的。这是因为每次调用 reactive 函数都会创建一个新的代理对象。解决方案如下:
javascript
// 定义一个 Map 实例,存储原始对象到代理对象的映射
const reactiveMap = new Map()
function reactive(obj) {
// 优先通过原始对象 obj 寻找之前创建的代理对象,如果找到了,直接返回已有的代理对象
const existionProxy = reactiveMap.get(obj)
if (existionProxy) return existionProxy
// 否则,创建新的代理对象
const proxy = createReactive(obj)
// 存储到 Map 中,从而避免重复创建
reactiveMap.set(obj, proxy)
return proxy
}
当前的行为已经达到了预期。但是,我们不能过早地庆祝。让我们再来看一下以下的代码:
javascript
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(obj)) // false
在这段代码中,返回 false 令人费解,这是因为 includes 方法内部的 this 指向的是代理对象 arr,并且在获取数组元素时得到的也是代理对象,因此当我们使用原始对象 obj 进行查找时,肯定找不到,从而返回 false。
为了解决这个问题,我们需要重写数组的 includes 方法并实现自定义的行为:
javascript
const arrayInstrumentations = {
includes: function () {
/* ... */
}
}
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
console.log('get: ', key)
if (key === 'raw') {
return target
}
// 如果操作的目标对象是数组,并且 key 存在于 arrayInstrumentations 上,
// 那么返回定义在 arrayInstrumentations 上的值
if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
})
}
在上述代码中,我们修改了 get 拦截函数,以重写数组的 includes 方法。
执行 arr.includes 时,实际执行的是定义在 arrayInstrumentations 上的 includes 函数,这样我们就重写了这个方法。
接下来,我们可以自定义 includes 函数:
javascript
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
includes: function (...args) {
// this 是代理对象,先在代理对象中查找,将结果存储到 res 中
let res = originMethod.apply(this, args)
if (res === false) {
// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找并更新 res 值
res = originMethod.apply(this.raw, args)
}
// 返回最终结果
return res
}
}
在上述代码中,includes 方法内的 this 指向的是代理对象,我们首先在代理对象中进行查找,这其实是 arr.include(obj) 的默认行为。
如果在代理对象中找不到,我们会通过 this.raw 获取原始数组,然后在其中进行查找,最后返回结果。这样就解决了先前提到的问题。运行以下测试代码:
javascript
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(obj)) // true
你会发现,现在代码的行为已经符合预期。
除了 includes 方法,还有一些其他的数组方法,如 indexOf 和 lastIndexOf,也需要进行类似的处理,因为这些方法都是根据给定的值返回查找结果。以下是完整的代码:
javascript
const arrayInstrumentations = {}
;['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function (...args) {
// this 是代理对象,先在代理对象中查找,将结果存储到 res 中
let res = originMethod.apply(this, args)
if (res === false || res === -1) {
// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找,并更新 res 值
res = originMethod.apply(this.raw, args)
}
// 返回最终结果
return res
}
})
5.7.4 隐式修改数组长度的原型方法
push 方法会读取并设置数组的 length 属性,这可能导致两个独立的副作用函数相互影响。例如:
javascript
const arr = reactive([])
// 第一个副作用函数
effect(() => {
arr.push(1)
})
// 第二个副作用函数
effect(() => {
arr.push(1)
})
上述代码在运行时会导致栈溢出错误(Maximum call stack size exceeded)。
这是因为,两个副作用函数都在执行 push 操作,既读取了 length 属性,又设置了 length 属性。
第一个副作用函数执行完毕后,会与 length 属性建立响应关系。当第二个副作用函数执行时,也会与 length 属性建立响应关系,同时设置 length 属性。这导致了第一个副作用函数的重新执行,从而形成了无限循环,最终导致栈溢出。
解决方法是"屏蔽"对 length 属性的读取,防止在 length 属性和副作用函数之间建立响应关系。
这是因为数组的 push 操作本质上是修改操作,而非读取操作。避免建立响应联系并不会产生其他副作用。
重写数组的 push 方法:
javascript
// 一个标记变量,代表是否进行追踪。默认值为 true,即允许追踪
let shouldTrack = true
// 重写数组的 push 方法
;['push'].forEach(method => {
// 取得原始 push 方法
const originMethod = Array.prototype[method]
// 重写
arrayInstrumentations[method] = function (...args) {
// 在调用原始方法之前,禁止追踪
shouldTrack = false
// push 方法的默认行为
let res = originMethod.apply(this, args)
// 在调用原始方法之后,恢复原来的行为,即允许追踪
shouldTrack = true
return res
}
})
在上述代码中,我们在执行 push 方法的默认行为前后,分别禁止和允许追踪。
我们还需要相应地修改 track 函数,代码如下:
javascript
function track(target, key) {
if (!activeEffect || !shouldTrack) return
// 省略部分代码
}
这样,当 push 方法间接读取 length 属性时,由于此时是禁止追踪的状态,所以 length 属性与副作用函数之间不会建立响应联系。
这样就解决了上文的问题。我们再次尝试运行测试代码:
javascript
const arr = reactive([])
// 第一个副作用函数
effect(() => {
arr.push(1)
})
// 第二个副作用函数
effect(() => {
arr.push(1)
})
你会发现现在它能够正确地工作,并且不会导致调用栈溢出。
除了 push 方法,我们还需要对 pop、shift、unshift 和 splice 等方法进行类似的处理。完整的代码如下:
javascript
let shouldTrack = true
// 重写数组的 push、pop、shift、unshift 和 splice 方法
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function(...args) {
shouldTrack = false
let res = originMethod.apply(this, args)
shouldTrack = true
return res
}
})
5.8 Set 和 Map 的响应式代理
我们简要回顾一下 Set 和 Map 这两种数据类型的原型属性和方法:
Set 数据类型的原型属性和方法包括:
- size:返回集合中元素的数量。
- add(value):向集合添加新元素。
- clear():清空集合内所有元素。
- delete(value):从集合中移除特定元素。
- has(value):检查集合中是否包含特定元素。
- keys()和 values():在 Set 中,这两个方法是等价的,它们都返回一个可用于 for...of 循环的迭代器,用于遍历集合中的元素。
- entries():返回一个迭代器,遍历集合中的每个元素,每次产生一个形如 [value, value] 的数组。
- forEach(callback[, thisArg]):遍历集合中的所有元素,为每个元素执行 callback 函数。可选参数 thisArg 用于设定 callback 函数执行时的 this 值。
Map 数据类型的原型属性和方法包括:
- size:返回 Map 中的键值对数量。
- clear():清空 Map 中的所有元素。
- delete(key):从 Map 中移除特定键的键值对。
- has(key):检查 Map 中是否包含特定键的键值对。
- get(key):获取特定键对应的值。
- set(key, value):在 Map 中添加新的键值对。
- keys():返回一个迭代器,用于遍历 Map 中的所有键。
- values():返回一个迭代器,用于遍历 Map 中的所有值。
- entries():返回一个迭代器,遍历 Map 中的每个键值对,每次产生一个形如 [key, value] 的数组。
- forEach(callback[, thisArg]):遍历 Map 中的所有键值对,为每个键值对执行 callback 函数。可选参数 thisArg 用于设定 callback 函数执行时的 this 值。
Map 和 Set 的操作方法有很多相似之处。主要的区别在于,Set 使用 add(value) 方法添加元素,而 Map 则使用 set(key, value) 方法添加键值对,并且 Map 还可以通过 get(key) 方法获取特定键的值。
5.8.1 如何代理 Set 和 Map
Set 和 Map 类型的数据具有专属的属性和方法来进行操作数据,这一点与普通对象存在显著差异,所以我们不能简单地像代理普通对象那样来代理 Set 和 Map 类型的数据。
然而,代理的基本思路依然不变:当读取操作发生时,我们需要调用 track 函数建立依赖关系;当设置操作发生时,我们需要调用 trigger 函数以触发响应。例如:
javascript
const proxy = reactive(new Map([['key', 1]]));
effect(() => {
console.log(proxy.get('key')); // 读取键为 key 的值
});
proxy.set('key', 2); // 修改键为 key 的值,应该触发响应
以上代码展示的是我们最终希望实现的效果。
在实现之前,我们先注意一些细节:
javascript
const s = new Set([1, 2, 3]);
const p = new Proxy(s, {});
console.log(p.size); // 报错 TypeError: Method get Set.prototype.size called on incompatible receiver
代理对象 p 并不包含 size,这就是我们在上面的例子中所遇到的错误。size 属性应该是一个访问器属性,所以它作为方法被调用了。
为了解决这个问题,我们可以调整访问器属性的 getter 函数中 this 的指向,如下:
javascript
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
get(target, key, receiver) {
if (key === 'size') {
// 如果读取的是 size 属性
// 通过指定第三个参数 receiver 为原始对象 target 从而修复问题
return Reflect.get(target, key, target)
}
// 读取其他属性的默认行为
return Reflect.get(target, key, receiver)
}
})
console.log(s.size) // 3
在这段代码中,我们在创建代理对象时添加了 get 拦截函数。然后检查读取的属性名是否为 size,通过 Reflect.get 函数时将第三个参数设为原始的 Set 对象保证了 this 原始 set 对象。
然后,我们试图从 Set 中删除数据:
javascript
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
get(target, key, receiver) {
if (key === 'size') {
return Reflect.get(target, key, target)
}
return Reflect.get(target, key, receiver)
}
})
// 尝试删除值为 1 的元素
// 我们得到了错误:TypeError: Method Set.prototype.delete called on incompatible receiver [object Object]
p.delete(1)
在 delete 方法执行时,this 总是指向代理对象 p,而不是原始的 Set 对象。我们可以通过将 delete 方法与原始的数据对象绑定来修复这个问题:
javascript
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
get(target, key, receiver) {
if (key === 'size') {
return Reflect.get(target, key, target)
}
// 将方法与原始数据对象 target 绑定后返回
return target[key].bind(target)
}
})
// 调用 delete 方法删除值为 1 的元素,正确执行
p.delete(1)
我们用 bind 函数将用于操作数据的方法与原始数据对象 target 进行了绑定,使得代码能够正确执行。
最后,为了方便后续讲解和提高代码的可扩展性,我们将 new Proxy 也封装进之前介绍过的 createReactive 函数中:
javascript
const reactiveMap = new Map()
// reactive 函数与之前相比没有变化
function reactive(obj) {
const existionProxy = reactiveMap.get(obj)
if (existionProxy) return existionProxy
const proxy = createReactive(obj)
reactiveMap.set(obj, proxy)
return proxy
}
// 在 createReactive 里封装用于代理 Set/Map 类型数据的逻辑
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'size') {
return Reflect.get(target, key, target)
}
return target[key].bind(target)
}
})
}
现在,我们可以很简单地创建代理数据了:
javascript
const p = reactive(new Set([1, 2, 3]))
console.log(p.size) // 输出:3
通过这种方式,我们成功地代理了 Set 或 Map 类型的响应式数据,使其在使用上与普通对象无异,同时维持了其原有的特性和操作方式。
5.8.2 建立响应联系
开始实现 Set 类型数据的响应式解决方案,让我们以下面的代码为例:
javascript
const p = reactive(new Set([1, 2, 3]))
effect(() => {
// 在副作用函数内部,我们访问了 size 属性
console.log(p.size)
})
// 向集合中添加一个元素,间接改变 size,这应该会触发响应
p.add(1)
这段代码我们需要在访问 size 属性时调用 track 函数来进行依赖跟踪,然后在执行 add 方法时调用 trigger 函数来触发响应,下面的代码演示了如何进行依赖跟踪:
javascript
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'size') {
// 我们在这里调用了 track 函数来建立响应联系
track(target, ITERATE_KEY)
return Reflect.get(target, key, target)
}
return target[key].bind(target)
}
})
}
当我们读取 size 属性时,我们只需要调用 track 函数来建立响应联系即可。这是因为任何新增、删除操作都会影响 size 属性。
当我们调用 add 方法向集合中添加新元素时,我们应该如何触发响应?我们需要实现一个自定义的 add 方法:
javascript
// 我们定义一个对象,并在这个对象上定义我们自定义的 add 方法
const mutableInstrumentations = {
add(key) {/* ... */}
}
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
// 如果读取的是 raw 属性,那么我们返回原始数据对象 target
if (key === 'raw') return target
if (key === 'size') {
track(target, ITERATE_KEY)
return Reflect.get(target, key, target)
}
// 我们返回在 mutableInstrumentations 对象上定义的方法
return mutableInstrumentations[key]
}
})
}
通过上面代码后, p.add 获取方法时,得到的就是我们自定义的 mutableInstrumentations.add 方法了,有了自定义实现的方法 后,就可以在其中调用 trigger 函数触发响应了:
javascript
// 定义一个对象,将自定义的 add 方法定义到该对象下
const mutableInstrumentations = {
add(key) {
// this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象
const target = this.raw
// 通过原始数据对象执行 add 方法添加具体的值,
// 注意,这里不再需要 .bind 了,因为是直接通过 target 调用并执行的
const res = target.add(key)
// 调用 trigger 函数触发响应,并指定操作类型为 ADD
trigger(target, key, 'ADD')
// 返回操作结果
return res
}
}
在我们自定义的 add 方法中,this 仍然指向代理对象,因此我们需要通过 this.raw 来获取原始数据对象。然后通过一系列操作最后触发 操作类型为 ADD 的响应,有了原始数据对象后,就可以通过它调用target.add 方法,这样就不再需要 .bind 绑定了。
还记得 trigger 函数的实现吗?让我们回顾一下:
javascript
function trigger(target, key, type, newVal) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// 省略无关内容
// 当操作类型 type 为 ADD 时,会取出与 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()
}
})
}
当操作类型是 ADD 或 DELETE 时,我们会获取和 ITERATE_KEY 相关联的副作用函数并执行它们。这样我们就可以触发通过访问 size 属性收集的副作用函数了。
当然,如果调用 add 方法时所添加的元素已经存在于 Set 集合中,那么就不需要触发响应,这样可以提高性能。因此,我们可以优化代码如下:
javascript
const mutableInstrumentations = {
add(key) {
const target = this.raw
// 先判断值是否已经存在
const hadKey = target.has(key)
// 只有在值不存在的情况下,才需要触发响应
const res = target.add(key)
if (!hadKey) {
trigger(target, key, 'ADD')
}
return res
}
}
这段代码调用 target.has 方法判断值是否已存在,只有在值不存在的情况下才需要触发响应。
基于此,我们可以通过类似的逻辑轻松地实现 delete 方法:
javascript
const mutableInstrumentations = {
delete(key) {
const target = this.raw
const hadKey = target.has(key)
const res = target.delete(key)
// 当要删除的元素确实存在时,才触发响应
if (hadKey) {
trigger(target, key, 'DELETE')
}
return res
}
}
delete 方法只在要删除的元素确实存在于集合中时才需要触发响应,这与 add 方法的逻辑相反。
5.8.3 避免污染原始数据
Map 数据类型拥有 get 和 set 这两个方法。当我们通过 get 方法读取数据时,需要调用 track 函数来追踪依赖并建立响应关系;而当我们通过 set 方法设置数据时,则需要调用 trigger 方法来触发响应。以下面的代码为例:
javascript
const p = reactive(new Map([['key', 1]]))
effect(() => {
console.log(p.get('key'))
})
p.set('key', 2) // 触发响应
让我们看看 get 方法的具体实现:
javascript
const mutableInstrumentations = {
get(key) {
// 获取原始对象
const target = this.raw
// 判断读取的 key 是否存在
const had = target.has(key)
// 追踪依赖,建立响应联系
track(target, key)
// 如果存在,则返回结果。这里要注意的是,如果得到的结果 res 仍然是可代理的数据,
// 则要返回使用 reactive 包装后的响应式数据
if (had) {
const res = target.get(key)
return typeof res === 'object' ? reactive(res) : res
}
}
}
这段代码在非浅响应模式下,如果返回的数据还可以被代理,我们需要调用 reactive(res) 将数据转换为响应式数据后再返回。
我们来看看 set 方法的实现,注意触发响应我们需要区分操作的类型是 SET 还是 ADD:
javascript
const mutableInstrumentations = {
set(key, value) {
const target = this.raw
const had = target.has(key)
// 获取旧值
const oldValue = target.get(key)
// 设置新值
target.set(key, value)
// 如果不存在,则说明是 ADD 类型的操作,意味着新增
if (!had) {
trigger(target, key, 'ADD')
} else if (oldValue !== value || (oldValue === oldValue && value === value)) {
// 如果不存在,并且值变了,则是 SET 类型的操作,意味着修改
trigger(target, key, 'SET')
}
}
}
代码的关键在于我们需要判断待设置的 key 是否已存在,任何依赖 size 属性的副作用函数都需要在 ADD 类型的操作发生时重新执行。
上面存在一个问题,set 方法会污染原始数据:
javascript
// 原始 Map 对象 m
const m = new Map()
// p1 是 m 的代理对象
const p1 = reactive(m)
// p2 是另外一个代理对象
const p2 = reactive(new Map())
// 为 p1 设置一个键值对,值是代理对象 p2
p1.set('p2', p2)
effect(() => {
// 注意,这里我们通过原始数据 m 访问 p2
console.log(m.get('p2').size)
})
// 注意,这里我们通过原始数据 m 为 p2 设置一个键值对 foo --> 1
m.get('p2').set('foo', 1)
上述代码我们通过原始数据 m 来读取和设置数据值,却发现副作用函数重新执行了,但是原始数据不应该具备响应式。
这个问题需要我们观察下之前实现的 set 方法:
javascript
const mutableInstrumentations = {
set(key, value) {
const target = this.raw
const had = target.has(key)
const oldValue = target.get(key)
// 我们把 value 原封不动地设置到原始数据上
target.set(key, value)
if (!had) {
trigger(target, key, 'ADD')
} else if (oldValue !== value || (oldValue === oldValue && value === value)) {
trigger(target, key, 'SET')
}
}
}
在 set 方法中,我们将 value 原样设置到了原始数据 target 上,但是如果 value 是响应式数据,设置上去也是响应式数据,这就是数据污染。
解决数据污染,我们可以在调用 target.set 函数设置值之前对值进行检查,即发现设置的是响应式数据,则通过 raw 属性获取原始数据设置到 target 上即可:
javascript
const mutableInstrumentations = {
set(key, value) {
const target = this.raw
const had = target.has(key)
const oldValue = target.get(key)
// 获取原始数据,如果 value.raw 不存在,则直接使用 value
const rawValue = value.raw || value
target.set(key, rawValue)
if (!had) {
trigger(target, key, 'ADD')
} else if (oldValue !== value || (oldValue === oldValue && value === value)) {
trigger(target, key, 'SET')
}
},
}
现在已经不会造成数据污染了。但是我们一直使用 raw 属性来访问原始数据,这可能会与用户自定义的 raw 属性冲突。因此,在一个更为严谨的实现中,我们需要使用一个唯一的标识来作为访问原始数据的键,例如使用 Symbol 类型来代替。
5.8.4 处理 forEach
集合类型的 forEach 方法类似于数组的 forEach 方法,让我们一起看看它的工作原理:
javascript
const m = new Map([
[{ key: 1 }, { value: 1 }]
])
effect(() => {
m.forEach(function (value, key, m) {
console.log(value) // { value: 1 }
console.log(key) // { key: 1 }
})
})
Map 的 forEach 方法接收一个回调函数作为参数,回调函数接收三个参数,分别是 Map 的每个值、键以及原始 Map 对象。
任何修改 Map 对象键值对数量的操作,例如 delete 和 add 方法,都应该触发副作用函数重新执行。
因此,当 forEach 函数被调用时,我们应该让副作用函数与 ITERATE_KEY 建立响应联系:
javascript
const mutableInstrumentations = {
forEach(callback) {
// 取得原始数据对象
const target = this.raw
// 与 ITERATE_KEY 建立响应联系
track(target, ITERATE_KEY)
// 通过原始数据对象调用 forEach 方法,并把 callback 传递过去
target.forEach(callback)
}
}
这样我们就实现了对 forEach 操作的追踪。但是回调函数接收的参数是非响应式数据,如果修改则无法触发副作用函数重新触发。如下所示:
javascript
const key = { key: 1 }
const value = new Set([1, 2, 3])
const p = reactive(new Map([[key, value]]))
effect(() => {
p.forEach(function (value, key) {
console.log(value.size) // 3
})
})
p.get(key).delete(1)
我们尝试删除 Set 类型数据中值为 1 的元素,却发现没能触发副作用函数重新执行。
原因是通过 value.size 访问 size 属性时,这里的 value 是原始数据对象,即 new Set([1, 2, 3]),而非响应式数据对象,因此无法建立响应联系。
不符合直觉,reactive 本身是深响应,forEach 方法的回调函数所接收到的参数也应该是响应式数据才对。
为了解决这个问题我们需要调用原始 forEach 方法之前,先将参数转换为响应式数据:
javascript
const mutableInstrumentations = {
forEach(callback, thisArg) {
// wrap 函数用来把可代理的值转换为响应式数据
const wrap = val => (typeof val === 'object' ? reactive(val) : val)
const target = this.raw
track(target, ITERATE_KEY)
// 通过 target 调用原始 forEach 方法进行遍历
target.forEach((v, k) => {
// 手动调用 callback,用 wrap 函数包裹 value 和 key 后再传给 callback,这样就实现了深响应
callback.call(thisArg, wrap(v), wrap(k), this)
})
}
}
上述代码我们使用 wrap 函数将参数包装成响应式的,这样就实现了深响应。
当我们使用 for...in 循环遍历一个对象时,一般只关心对象的键,而不关心对象的值,我们使用 forEach 遍历集合时,既关心键,又关心值。
但这个规则不适用于 Map 类型的 forEach 遍历,如以下代码所示:
javascript
const p = reactive(new Map([['key', 1]]))
effect(() => {
p.forEach(function (value, key) {
// forEach 循环不仅关心集合的键,还关心集合的值
console.log(value) // 1
})
})
p.set('key', 2) // 即使操作类型是 SET,也应该触发响应
所以对于 Map 类型的数据,即使操作类型是 SET,只要值发生了变化,也应该触发副作用函数重新执行。因此,我们需要进一步修改 trigger 函数的代码:
javascript
function trigger(target, key, type, newVal) {
console.log('trigger', key)
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)
}
})
if (
type === 'ADD' ||
type === 'DELETE' ||
// 如果操作类型是 SET,并且目标对象是 Map 类型的数据,
// 也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行
(type === 'SET' && Object.prototype.toString.call(target) === '[object Map]')
) {
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()
}
})
}
上述代码中即使操作类型是 SET,只要值发生了变化,也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。
5.8.5 迭代器方法
集合类型拥有三个迭代器方法:
- entries
- keys
- values
当我们调用这些方法时,将会得到对应的迭代器,然后我们可以使用 for...of 循环进行迭代。例如:
javascript
const m = new Map([
['key1', 'value1'],
['key2', 'value2']
])
for (const [key, value] of m.entries()) {
console.log(key, value)
}
// 输出:
// key1 value1
// key2 value2
此外,由于 Map 或 Set 类型本身就实现了 Symbol.iterator 方法,因此我们也可以直接使用 for...of 进行迭代:
javascript
for (const [key, value] of m) {
console.log(key, value)
}
// 输出:
// key1 value1
// key2 value2
当然,我们也可以先获取迭代器对象,然后手动调用迭代器对象的 next 方法来获取对应的值:
javascript
const itr = m[Symbol.iterator]()
console.log(itr.next()) // { value: ['key1', 'value1'], done: false }
console.log(itr.next()) // { value: ['key2', 'value2'], done: false }
console.log(itr.next()) // { value: undefined, done: true }
实际上,m[Symbol.iterator] 和 m.entries 是等价的:
javascript
console.log(m[Symbol.iterator] === m.entries) // true
这也是上面为什么使用 for...of 循环迭代 m.entries 和 m 会得到同样的结果。
理解了这些后,我们就可以尝试去实现对迭代器方法的代理:
javascript
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))
effect(() => {
// TypeError: p is not iterable
for (const [key, value] of p) {
console.log(key, value)
}
})
p.set('key3', 'value3')
上述代理对象 p 没有实现 Symbol.iterator 方法,所以我们得到了上面的错误。
当我们试图使用 for...of 循环遍历代理对象时,系统会尝试从代理对象 p 上获取 p[Symbol.iterator] 属性,这会触发 get 拦截函数,我们可以把 Symbol.iterator 方法的实现放到 mutableInstrumentations 中:
javascript
const mutableInstrumentations = {
[Symbol.iterator]() {
// 获取原始数据对象 target
const target = this.raw
// 获取原始迭代器方法
const itr = target[Symbol.iterator]()
// 将其返回
return itr
}
}
上述代码只是返回了原始的迭代器对象后,就可以使用 for...of 循环遍历代理对象 p 了。
但是如果迭代产生的值可以被代理,那么我们也应该将其包装成响应式数据:
javascript
const mutableInstrumentations = {
[Symbol.iterator]() {
// 获取原始数据对象 target
const target = this.raw
// 获取原始迭代器方法
const itr = target[Symbol.iterator]()
const wrap = val => (typeof val === 'object' && val !== null ? reactive(val) : val)
// 返回自定义的迭代器
return {
next() {
// 调用原始迭代器的 next 方法获取 value 和 done
const { value, done } = itr.next()
return {
// 如果 value 不是 undefined,则对其进行包裹
value: value ? [wrap(value[0]), wrap(value[1])] : value,
done
}
}
}
}
}
上述代码,我们自定义了迭代器,如果值 value 不为 undefined,则对其进行包装,最后返回包装后的代理对象。
为了让我们能够追踪 for...of 循环对数据的处理,我们需要调用track函数以建立 ITERATE_KEY 与副作用函数的联系:
javascript
const mutableInstrumentations = {
[Symbol.iterator]() {
const target = this.raw
const itr = target[Symbol.iterator]()
const wrap = val => (typeof val === 'object' && val !== null ? reactive(val) : val)
// 调用 track 函数建立响应联系
track(target, ITERATE_KEY)
return {
next() {
const { value, done } = itr.next()
return {
value: value ? [wrap(value[0]), wrap(value[1])] : value,
done
}
}
}
}
}
由于迭代操作与集合元素的数量相关,集合 size 的变化应该触发迭代操作的重新执行,我们通过以下代码测试:
javascript
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]));
effect(() => {
for (const [key, value] of p) {
console.log(key, value);
}
});
p.set('key3', 'value3'); // 触发响应
我们之前提到,由于 p.entries 和 p[Symbol.iterator] 等效,所以我们可以使用相同的代码来拦截 p.entries 函数:
javascript
const mutableInstrumentations = {
// 共用 iterationMethod 方法
[Symbol.iterator]: iterationMethod,
entries: iterationMethod
}
// 抽离为独立的函数,便于复用
function iterationMethod() {
const target = this.raw
const itr = target[Symbol.iterator]()
const wrap = val => (typeof val === 'object' ? reactive(val) : val)
track(target, ITERATE_KEY)
return {
next() {
const { value, done } = itr.next()
return {
value: value ? [wrap(value[0]), wrap(value[1])] : value,
done
}
}
}
}
但当你尝试运行代码使用 for...of 进行迭代时,会得到一个错误:
javascript
// TypeError: p.entries is not a function or its return value isnot iterable
for (const [key, value] of p.entries()) {
console.log(key, value)
}
因为 p.entries 的返回的具有 next 方法的对象不具有 Symbol.iterator 方法,不是一个可迭代对象。
可迭代协议指的是一个对象实现了 Symbol.iterator 方法,而迭代器协议指的是一个对象实现了 next 方法。
但一个对象可以同时实现可迭代协议和迭代器协议,例如:
javascript
const obj = {
// 迭代器协议
next() {
// ...
},
// 可迭代协议
[Symbol.iterator]() {
return this;
}
}
所以,我们可以在iterationMethod函数中实现可迭代协议来解决上述问题:
javascript
// 独立函数,方便重复使用
function iterationMethod() {
const target = this.raw;
const itr = target[Symbol.iterator]();
const wrap = val => (typeof val === 'object') ? reactive(val) : val;
track(target, ITERATE_KEY);
return {
next() {
const { value, done } = itr.next();
return {
value: value ? [wrap(value[0]), wrap(value[1])] : value,
done
}
},
// 实现可迭代协议
[Symbol.iterator]() {
return this;
}
}
}
现在,无论是使用 for...of 循环还是 p.entries() 方法,都能正常运行且能触发响应。
5.8.6 实现 values 与 keys 方法
values 方法的实现和 entries 方法相似,只不过我们使用 for...of迭代 values 时获取的是值:
javascript
for (const value of p.values()) {
console.log(value)
}
values 方法的实现如下:
javascript
const mutableInstrumentations = {
// 共用 iterationMethod 方法
[Symbol.iterator]: iterationMethod,
entries: iterationMethod,
values: valuesIterationMethod
}
function valuesIterationMethod() {
// 获取原始数据对象 target
const target = this.raw
// 通过 target.values 获取原始迭代器方法
const itr = target.values()
const wrap = val => (typeof val === 'object' ? reactive(val) : val)
track(target, ITERATE_KEY)
// 将其返回
return {
next() {
const { value, done } = itr.next()
return {
// value 是值,而非键值对,所以只需要包裹 value 即可
value: wrap(value),
done
}
},
[Symbol.iterator]() {
return this
}
}
}
iterationMethod 和 valuesIterationMethod 存在以下差异:
- iterationMethod 通过 target[Symbol.iterator] 获取迭代器对象,而 valuesIterationMethod 通过 target.values 获取迭代器对象。
- iterationMethod 处理键值对 [wrap(value[0]), wrap(value[1])],而 valuesIterationMethod 只处理值 wrap(value)。
keys 方法和 values 方法相似,只是它处理的是键,而不是值,我们只需在 valuesIterationMethod 方法中修改一行代码,即可以实现对 keys 方法的代理:
javascript
const itr = target.values();
替换成:
javascript
const itr = target.keys()
但是,如果我们尝试运行以下测试用例,我们会发现一个问题:
javascript
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))
effect(() => {
for (const value of p.keys()) {
console.log(value) // key1 key2
}
})
p.set('key2', 'value3') // 这是一个 SET 类型的操作,它修改了 key2 的值,不应该触发响应
p.set('key3', 'value3') // 能够触发响应
上述代码,我们使用 for...of 循环遍历 p.keys,并调用 p.set 修改 key2 的值,理论上Map 类型数据的所有键没有变化,副作用函数不应该执行,然后却执行了。因为之前我们做了特殊处理,即使操作类型为 SET,也会触发与 ITERATE_KEY 相关的副作用函数。
虽然对于 values 或 entries 方法这是必要的,但对于 keys 方法来说这并不必要,因为 keys 方法只关心 Map 类型数据的键的变化,而不关心值的变化:
解决方案如下所示:
javascript
const MAP_KEY_ITERATE_KEY = Symbol()
function keysIterationMethod() {
// 获取原始数据对象 target
const target = this.raw
// 获取原始迭代器方法
const itr = target.keys()
const wrap = val => (typeof val === 'object' ? reactive(val) : val)
// 调用 track 函数追踪依赖,在副作用函数与 MAP_KEY_ITERATE_KEY 之间建立响应联系
track(target, MAP_KEY_ITERATE_KEY)
// 将其返回
return {
next() {
const { value, done } = itr.next()
return {
value: wrap(value),
done
}
},
[Symbol.iterator]() {
return this
}
}
}
上述代码我们使用 MAP_KEY_ITERATE_KEY 取代了 ITERATE_KEY 来追踪依赖。
此时当 SET 类型的操作只触发与 ITERATE_KEY 相关的副作用函数时,与 MAP_KEY_ITERATE_KEY 相关的副作用函数则会被忽略。
而在 ADD 或 DELETE 类型的操作中,除了触发与 ITERATE_KEY 相关的副作用函数,还需要触发与 MAP_KEY_ITERATE_KEY 相关的副作用函数,需要修改 trigger 函数:
javascript
function trigger(target, key, type, newVal) {
// 省略其他代码
if (
// 操作类型为 ADD 或 DELETE
(type === 'ADD' || type === 'DELETE') &&
// 并且是 Map 类型的数据
Object.prototype.toString.call(target) === '[object Map]'
) {
// 则取出那些与 MAP_KEY_ITERATE_KEY 相关联的副作用函数并执行
const iterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY)
iterateEffects &&
iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
// 省略其他代码
}
这样,我们就可以避免不必要的更新:
javascript
const p = reactive(
new Map([
['key1', 'value1'],
['key2', 'value2']
])
)
effect(() => {
for (const value of p.keys()) {
console.log(value)
}
})
p.set('key2', 'value3') // 不会触发响应
p.set('key3', 'value3') // 能够触发响应