非原始值的响应式方案(三)

如何代理Object

前面我们使用get拦截函数去拦截对属性的读取操作。但在响应系统中,“读取”是一个很宽泛的概念,例如使用in操作符检查对象上是否具有给定的key也属于“读取”操作,如下面的代码所示:

1
2
3
effect(() => {
'foo' in obj
})

这本质上也是在进行“读取操作”。响应系统应该拦截一切读取操作,以便当数据变化的时候能够正确触发响应。下面列出了对一个普通对象的所有可能的读取操作。

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

接下来,我们逐步讨论如何拦截这些读取操作。首先是对于属性的读取,例如obj.name,我们知道这可以通过get拦截函数实现:

1
2
3
4
5
6
7
8
9
10
const obj = { name: '张三' }

const p = new Proxy(obj, {
get(target, key, receiver) {
// 建立联系
track(target, key)
// 返回属性值
return Reflect.get(target, key, receiver)
},
})

对于in操作符,应该如何拦截呢?通过查看规范可知,in 操作符的运算结果是通过调用一个叫作HasProperty的抽象方法得到的。关于这个抽象方法,我们查询规范得知它的返回值是通过调用对象的内部方法[[HasProperty]]得到的。而[[HasProperty]]内部方法可以在我们之前的表格中找到,和它对应的拦截器函数名字叫has,因此我们可以通过has拦截函数实现对 in 操作符的代理:

1
2
3
4
5
6
7
8
const obj = { name: '张三' }

const p = new Proxy(obj, {
has(target, key) {
track(target, key)
return Reflect.has(target, key)
},
})

这样当我们在副作用函数中通过 in 操作符操作响应式数据时,就能够建立依赖关系:

1
2
3
effect(() => {
'name' in p // 建立依赖关系
})

再来看看如何拦截 for…in 循环。同样,我们可以查询规范的职责 for…in 循环依赖的抽象语法是EnumerateObjectProperties。该方法返回一个迭代器对象,通过查询规范可以看到该方法是一个generator函数,接收一个入参 obj。实际上 obj 就是被 for…in 循环遍历的对象,其关键点在于Reflect.ownKeys(obj)来获取只属于对象自身拥有的键。有了这个线索以后,如何拦截 for…in 循环的答案就已经很明显了,可以使用ownKeys拦截函数来拦截Reflect.ownKeys操作:

1
2
3
4
5
6
7
8
9
10
11
const obj = { name: '张三' }

const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
},
})

如上面的代码所示,拦截ownKeys可以间接的拦截for...in循环。但是注意的是在使用track函数进行追踪的时候,将ITERATE_KEY作为追踪的 key,为什么这么做呢?这是因为ownKeys拦截函数和get/set拦截函数不同,在get/set中,我们可以得到具体操作的 key,但是在ownKeys中,我们只能拿到目标对象target

既然追踪的是ITERATE_KEY,那么相应的,在触发响应的时候也应该触发它才行:

1
trigger(target, ITERATE_KEY)

但是在什么情况下,对数据的操作需要触发与ITERATE_KEY相关联的副作用函数重新执行呢?为了搞清楚这个问题,我们用一段代码来说明。假设副作用函数内有一段for...in循环:

1
2
3
4
5
6
7
8
9
10
11
const obj = { name: '张三' }
const p = new Proxy(obj, {
/** ... */
})

effect(() => {
// for...in循环
for (const key in p) {
console.log(key) // name
}
})

副作用函数执行之后,会与ITERATE_KEY之间建立响应联系,接下来我们尝试为对象 P 添加新的属性 bar:

1
p.bar = 2

由于对象 p 原本只有name属性,因此for...in循环只会执行一次。现在为它添加了新的属性bar,所以for...in循环会有原来的执行一次变为执行两次。也就是说,当为对象添加新属性时,会对for...in循环产生影响,所以需要触发与ITERATE_KEY相关联的副作用函数重新执行。但是目前我们还做不到这一点,当我们为对象p添加新属性bar时,并没有触发副作用函数重新执行,这是为什么呢?我们来看一下现在的 set 拦截函数的实现:

1
2
3
4
5
6
7
8
9
10
11
const p = new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
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 相关联的副作用函数也取出来执行就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 &&
effcts.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 相关联的副作用函数取出来执行。

经过上述代码的修改以后对于添加新的属性来说已经没有什么问题了。但是如果我们是修改已有属性的值,而不是添加新的属性,那么问题就来了:

1
2
3
4
5
6
7
8
9
10
11
const obj = { name: '张三' }
const p = new Proxy(obj, {
/** ... */
})

effect(() => {
// for...in循环
for (const key in p) {
console.log(key) // name
}
})

当我们修改 p.name 值时:

1
p.name = '李四'

与添加新属性不同,修改属性不会对for...in循环产生影响。因为无论怎么修改一个属性的值,对于for...in循环来说都只会循环一次。所以在这种情况之下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。然而无论是添加新属性,还是修改已有属性的值,其基本语义都是[[Set]],我们都是通过set拦截函数来实现拦截的,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
const p = new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)
// 把副作用函数从桶里拿出来执行
trigger(target, key)

return res
},
})

既然想要解决上述问题,当设置属性操作发生时,就需要我们在 set 拦截函数内能够区分操作的类型,到底是添加新属性还是修改已有属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 来区分当前操作类型,并且只有当操作类型 type 为‘ADD’时,才会触发与ITERATE_KEY相关联的副作用函数重新执行,这样就避免了不必要的性能损耗:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function trigger(target, key, type) {
// 从桶里拿出来与这个对象相关的依赖集合
const depsMap = bucket.get(target)
if (!depsMap) return
// 取得与key相关联的副作用函数集合
const effects = depsMap.get(key)

const effectsToRun = new Set()
// 将与key相关联的副作用函数添加进effectsToRun
effects &&
effcts.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
// 只有当操作类型为'ADD'时,才触发与ITERATE_KEY相关联的副作用函数重新执行
if (type === 'ADD') {
// 取得与ITERATE_KEY相关联的副作用函数集合
const iterateEffects = depsMap.get(ITERATE_KEY)
// 将与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()
}
})
}

通常我们会将操作类型封装为一个枚举值,例如:

1
2
3
4
const TriggerType = {
SET: 'SET',
ADD: 'ADD'
}

关于对象的代理,还剩下最后一项工作需要做,那就是删除属性操作的代理:

1
delete p.name

通过查阅规范得知,delete操作符的行为依赖[[Delete]]这个内部方法。接着我们查看表格可以知道该内部方法可以用deleteProperty拦截:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
}
})

如上代码所示,首先检查被删除属性是否属于对象自身,然后调用Object.deleteProperty函数完成属性的删除工作,只有当这两步的结果满足条件时,才调用trigger函数触发副作用函数重新执行。需要注意的是,在调用trigger函数时,我们传递了新的操作类型DELETE。由于删除操作会使得对象的键变少,它会影响for...in循环的次数,因此当操作类型为’DELETE’时,我们也应该触发那些与ITERATE_KEY相关联的副作用函数重新执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function trigger(target, key, type) {
// 从桶里拿出来与这个对象相关的依赖集合
const depsMap = bucket.get(target)
if (!depsMap) return
// 取得与key相关联的副作用函数集合
const effects = depsMap.get(key)

const effectsToRun = new Set()
// 将与key相关联的副作用函数添加进effectsToRun
effects &&
effcts.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
// 只有当操作类型为'ADD'时,才触发与ITERATE_KEY相关联的副作用函数重新执行
if (type === 'ADD' || type === 'DELETE') {
// 取得与ITERATE_KEY相关联的副作用函数集合
const iterateEffects = depsMap.get(ITERATE_KEY)
// 将与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()
}
})
}

在这段代码中,我们添加了type===’DELETE’判断,使得删除属性操作能够触发与ITERATE_KEYX相关联的副作用函数重新执行。