简介
在上一篇文章中,我们增强了对对象的拦截,解决了以下问题:
- 拦截
in
操作符 - 拦截
for in
循环 - 拦截对象的删除操作
接下来我们在对响应式系统做一些优化,避免一些不必要的响应
代码地址: https://github.com/SuYxh/share-vue3
代码并没有按照源码的方式去进行组织,目的是学习、实现 vue3 响应式系统的核心,用最少的代码去实现最核心的能力,减少我们的学习负担,并且所有的流程都会有配套的图片,图文 + 代码,让我们学习更加轻松、快乐。
每一个功能都会提交一个 commit
,大家可以切换查看,也顺变练习练习 git 的使用。
新值与旧值相等
单元测试
it('newValue === oldValue', () => {const mockFn = vi.fn();const obj = reactive({ foo: 1, bar: NaN })effect(function effectFn() {mockFn()console.log(obj.foo);console.log(obj.bar);})expect(mockFn).toHaveBeenCalledTimes(1);obj.foo = 1expect(mockFn).toHaveBeenCalledTimes(1);obj.bar = NaNexpect(mockFn).toHaveBeenCalledTimes(1);
})
代码实现
// 拦截设置操作
set(target, key, newVal, receiver) {// 先获取旧值const oldVal = target[key]// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性const type = Object.prototype.hasOwnProperty.call(target, key)? TriggerType.SET: TriggerType.ADD;// 设置属性值const res = Reflect.set(target, key, newVal, receiver);// 较新值与旧值,只有当它们不全等,并且不都是 NaN 的时候才触发响应if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {trigger(target, key, type);}return res;
},
在 set
拦截函数内首先获取旧值 oldVal
,接着比较新值与旧值,只有当它们不全等的时候才触发响应。这里需要注意的是 NaN
的问题:
NaN === NaN // false
NaN !== NaN // true
所以,需要在新值和旧值不全等的情况下,要保证它们都不是 NaN 。
原型继承属性
单元测试
it("原型继承属性", () => {const mockFn = vi.fn();const obj = {};const proto = { bar: 1 };const child = reactive(obj);const parent = reactive(proto);// 使用 parent 作为 child 的原型Object.setPrototypeOf(child, parent);effect(() => {mockFn()console.log(child.bar); // 1});expect(mockFn).toHaveBeenCalledTimes(1);// 修改 child.bar 的值child.bar = 2; // 会导致副作用函数重新执行两次expect(mockFn).toHaveBeenCalledTimes(2);});
执行单测看看:
从单测可以看出:副作用函数不仅执行了,还执行了2次,这会造成不必要的更新。
问题分析
我们知道,如果对象自身不存在该属性,会往对象的原型上找。当读取child.bar
属性值时,由于child
代理的对象 obj
自身没有bar
属性,因此会获取对象obj
的原型,也就是parent
对象,所以最终得到的实际上是 parent.bar
的值。
同时,parent
本身也是响应式数据,因此在副作用函数中访问 parent.bar
的值时,会导致副作用函数被收集,从而也建立响应联系。所以我们能够得出一个结论,即 child.bar
和parent.bar
都与副作用函数建立了响应联系。
如果设置的属性不存在于对象上,那么会取得其原型,并调用原型的 [[Set]]
方法,也就是 parent
的 [[Set]]
内部方法。由于 parent
是代理对象,所以这就相当于执行了它的 set
拦截函数。换句话说,虽然我们操作的是 child.bar
,但这也会导致 parent
代理对象的 set
拦截函数被执行。
所以当parent
代理对象的 set
拦截函数执行时,就会触发副作用函数重新执行,这就是为什么修改 child.bar
的值会导致副作用函数重新执行两次。
解决
既然执行两次,那么只要屏蔽其中一次不就可以了吗?
屏蔽掉原型上的那次副作用函数的重新执行,即 parent.bar
触发的那次。
如何屏蔽呢?
通过 set
函数的第三个参数 receiver
来进行区分:
// child 的 set 拦截函数
set(target, key, value, receiver) {// target 是原始对象 obj// receiver 是代理对象 child
}// parent 的 set 拦截函数
set(target, key, value, receiver) {// target 是原始对象 proto// receiver 仍然是代理对象 child
}
当parent
代理对象的set
拦截函数执行时,此时target
是原始对象proto
,而receiver
仍然是代理对象 child
,而不再是target
的代理对象。
代码实现
代理对象可以通过 raw
属性读取原始数据:
其实这里最好我们应该通过
Symbol('raw')
的方式来进行定义,避免引起冲突。比如,target
中存在一个raw
属性呢?
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
的代理对象了:
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;}// 省略其他拦截函数});
}
运行单测
运行测试
pnpm test
一个前端小学生的学习之路,如果你喜欢前端,我们可以一起进行学习、交流、共建。可以添加好友,结伴学习,成长的路上不孤单!