合理触发响应
在非原始值的响应式方案(三)这一章中我们处理了很多边界条件。比如我们需要明确知道操作的类型是'ADD'
还是'SET'
,或者是其他操作类型,从而正确的触发响应。但是想要合理的触发响应,还有许多工作要做。
首先我们来看第一个问题,即当值没有发生变化时,应该不需要触发响应才对:
1 | const obj = { name: '张三' } |
如上面的代码所示,p.name
的初始值为’张三’,当为p.name
设置新的值时,如果值没有发生变化时则不需要触发响应。为了满足需求,我们需要修改set
拦截函数的代码,在调用trigger
函数触发响应之前,需要检查值是否真的发生了变化:
1 | const p = new Proxy(obj, { |
如上面代码所示,我们在 set 拦截函数内首先获取旧值 oldVal, 接着比较新值与旧值,只有当他们不完全相等时才会触发响应。现在,如果我们再次测试开头的那个例子,会发现重新设置相同的值已经不会触发响应了。
然而,仅仅进行全等比较是有缺陷的,这体现在对NaN
的处理上。我们知道NaN
与NaN
进行全等比较时总会得到 false:
1 | NaN === NaN // false |
但是要合理的触发响应,仅仅处理关于NaN
的问题还不够。我们接下来讨论一种从原型上继承属性的情况。我们需要封装一个reactive
函数,该函数接收一个对象作为参数,并返回为其创建的响应式数据:
1 | function reactive(obj) { |
可以看到reactive
只是对Proxy
进行了一层封装。接下来,我们基于reactive
创建一个例子:
1 | const obj = { foo: 1 } |
观察如上代码,我们定义了空对象obj
和proto
,分别为二者创建了对应的响应式数据child
和parent
,并且使用Object.setPrototypeOf
方法将parent
设置为child
的原型。接着,在副作用函数中访问 child.bar 的值。从代码中可以看出,child 本身没有 bar 属性,因此在访问 child.bar 时,值是从原型上继承而来的。但无论如何,child 既然是响应式数据,那它与副作用函数之间就会建立联系,因此当我们执行child.bar = 2
时,期望副作用函数会重新执行。但是如果你执行了上面的代码,会发现副作用函数不仅执行了,还执行了两次,这会造成不必要的更新。
为了搞清楚问题的原因,我们需要逐步分析整个过程。当在副作用函数中读取child.bar
的值时,会触发child
代理对象的 get 拦截函数。我们知道在拦截函数内是使用Reflect.get(target, key, receiver)
来得到最终结果的,对应上面这个例子,这句话相当于:
1 | Reflect.get(target, key, receiver) |
这其实是实现了通过 obj.bar 来访问属性值的默认行为。也就是说引擎内部通过调用obj对象所部署的[[Get]]内部方法
来得到最终结果的。因此,我们有必要查看规范 10.1.8.1 节来了解[[Get]]内部方法的执行流程。通过查看流程得知,如果对象自身不存在该属性,那么会获取对象的原型,并调用原型的[[Get]]方法的到最终结果。对应到上面的例子中,child.bar 属性值时,由于对象 child 代理的对象 obj 自身没有 bar 属性,因此会读取对象 obj 的原型,也就是 parent 对象,所以最终得到的实际上是 parent.bar 的值。但是不要忘记,parent 本身也是一个响应式数据,所以在副作用函数中访问 parent.bar 的值时,会导致副作用函数被收集,从而建立响应联系。所以我们可以得出一个结论:child.bar 和 parent.bar 都与副作用函数建立了响应联系。
但是这仍然解释不了为什么当设置 child.bar 的值时,会连续触发两次副作用函数执行,所以接下来我们要看看当设置操作发生时的具体执行流程。我们知道,当执行 child.bar = 2 时,会调用 child 代理对象的 set 拦截函数。同样,在 set 拦截函数内,我们使用Reflect.set(target, key, newVal, receiver)
来完成默认的设置行为,即引擎会调用 obj 对象部署的[[Set]]内部方法,根据规范的 10.1.9.2 节可知[[Set]]内部方法的执行流程。通过流程 2 得知,如果设置的属性不存在于对象上,那么会取得其原型,并调用原型的[[Set]]方法,也就是 parent 的[[Set]]内部方法,由于 parent 是代理对象,所以这就相当于执行了它的 set 拦截函数。前面我们分析过,当读取 child.bar 时,副作用函数不仅会被 child.bar 收集,也会被 parent.bar 收集。所以当 parent 代理对象的 set 拦截函数执行时,就会触发副作用函数重新执行,这就是为什么修改 child.bar 会导致副作用函数重新执行两次。
知道了问题所在,那么就需要思考解决方案。思路也很简单,既然执行两次,那么我们就屏蔽其中一次不就可以了嘛?我们可以把由 parent.bar 触发的那次副作用函数的重新执行屏蔽。怎么屏蔽呢?要知道,两次更新是由于 set 拦截函数被触发了两次导致的,所以我们只要能够在 set 拦截函数内部区分这两次更新就可以了。当我们设置 child.bar 的值时,会执行 child 代理对的 set 拦截函数:
1 | set(target, key, newVal, receiver) { |
此时的 target 是原始对象 obj,receiver 是代理对象 child,我们发现 receiver 其实就是 target 的代理对象。
但由于 obj 上不存在 bar 属性,所以会取得 obj 的原型 parent,并执行 parent 代理对象的 set 拦截函数:
1 | set(target, key, newVal, receiver) { |
我们发现呢,此时 target
是原始对象 proto
,而 receiver
仍然是代理对象 child
,不再是 target
的代理对象。通过这个特点,我们可以看到 target
和 receiver
的区别。由于我们最初设置的是 child.bar
的值,所以无论在什么情况下,receiver
都是 child
,而 target
则是变化的。根据这个区别,我们很容易找到解决办法,只需要判断 receiver
是否是 target
的代理对象即可。只有当 receiver
是 target
的代理对象时才触发更新,这样就能够屏蔽由原型
引起的更新了。
所以接下来的问题就变成了如何确定 receiver
是不是 target
的代理对象,这需要我们为 get
拦截函数添加一个能力,如下代码所示:
1 | function reactive(obj) { |
有了它,我们就能够在 set
拦截函数中判断 receiver
是不是 target
的代理对象了:
1 | function reactive(obj) { |
如上代码所示,我们新增了一个判断条件,只有当receiver
是target
的代理对象时才能触发更新,这样就能屏蔽由原型
引起的更新,从而避免不必要的更新操作。
代码
至此,我们已经写了不少代码了,为了便于理解,将之前写好的代码整理一下。
清除依赖工具函数
1 | // 清除依赖函数 |
副作用函数
1 | // 副作用函数栈 |
计算属性
1 | // 计算属性 |
侦听器
1 | // 侦听器工具函数 用来深度监听 |
依赖收集
1 | // 声明一个桶 用来存储副作用函数 |
派发更新
1 | // 触发的操作类型 |
刷新任务队列
1 | // 使用Promise.resolve()创建一个promise实例,用它将一个任务添加进微任务队列 |
将一个普通对象转为响应式数据
1 | // 将一个普通对象转为响应式数据 |