本文探究了Vue.js中响应式系统的实现,通过介绍副作用函数与响应式数据,利用Proxy实现了一个相对完善的响应系统,同时讨论了分支切换导致冗余副作用的清理问题。
副作用函数
如果我们使用js中的某个函数改变了html标签里的某个内容,由于在这个函数以外的地方都能访问或者设置这个html标签的内容
所以我们说这个函数可能会直接或者间接影响到其他函数的执行,也就是产生了副作用。
1 | const obj = { text: 'nihao' } |
响应式数据
还是上面的代码:
1 | const obj = { text: 'nihao' } |
在effect中我们将body.innerText与obj.text的值绑定在了一起。
朴素做法
我们的所希望的是body.innerText总是显示obj.text,要达到这个效果,只要每次修改obj.text的时候都调用一次effect就行了。
1 | const obj = { text: 'nihao' } |
这未免也有些太麻烦了,我们是否可以让js智能一点?
在每次修改obj的时候调用一下所有会读取obj内容的副作用函数。
这样我们就把obj变成了所谓的响应式数据。
基本实现响应式数据
按照前面的说法,我们需要在obj被读取的时候,把这个函数给记录下来,作为obj的副作用函数。
在obj被修改的时候,把之前记录下来的关于它的副作用函数再调用一遍。
接下来的关键在于如何拦截obj的读取和修改操作。
Vue.js 3使用ES2015+中的代理对象Proxy来实现这个拦截操作。
Proxy代理对象
根据MDN文档中对Proxy的说明:
语法
1 | new Proxy(target, handler) |
Proxy会对target(可以是任意类型)进行包装,最后返回一个Proxy对象。
handler: 一个对象,其属性是定义了在对代理执行操作时的行为的函数。
正常情况下我们不能拦截target的一些行为,但我们使用Proxy把它"绑架"之后,返回的一个跟它一模一样的Proxy对象就可以被我们拦截了。
在这里我们只拦截对target的读取和修改操作。
1 | // 存储当前的副作用函数 |
当页面运行的时候第一个副作用函数被调用,把obj.text绑定到body.innerText上,
同时将这个副作用函数放到Set内,以便以后调用。
当时间经过2s后,obj受到修改,同时调用与之相关的副作用函数(也就是前一个绑定obj.text到body.innerText的副作用函数),更新body.innerText的值。
细心的读者可能发现上面的代码有些不合适,我们的劫持操作是对obj整个对象来做的
同时副作用函数也绑定在整个对象上。
如果我们修改obj对象,但是我们修改的是obj别的属性(例如value),似乎也会触发对set()的劫持,把所有绑定到obj上的副作用函数都调用一遍。
我们应该让副作用函数跟对象的属性绑定在一起,如下图的结构:
effect1和effect2会读取obj1.text属性,所以在修改obj1.text属性的时候应该执行这两个函数。
effect1和effect3会读取obj1.value属性,所以在修改obj1.value属性的时候应该执行这两个函数。
现在修改一下上面的代码:
只存储涉及副作用函数的对象和属性,并不是追踪所有属性。
下文所有对象、属性都为跟副作用函数的有关的对象和属性
1 | let activeEffect |
weakMap
根据MDN文档中对weakMap的说明:
weakMap和Map很相似,但区别在于
- 不会阻止垃圾回收,直到垃圾回收器移除了键对象的引用
- 任何值都可以被垃圾回收,只要它们的键对象没有被
WeakMap以外的地方引用
weakMap不会阻止它的键被垃圾回收,一旦键的生命周期结束,键被垃圾回收了,那么weakMap的键值对也都会被回收。
我们使用weakMap存储对象->属性的原因也在于此。
如果这个响应式数据被销毁了或是生命周期结束了,那么它所存储的那些副作用函数都应该被释放。这样才能避免发生内存泄露。
现在让我们来尝试使用一下这个简单的响应式数据。
1 | ./index.html |
1 | ./js/my.js |
effect1和effect2被注册为副作用函数。
在读取obj.text和obj.value的时候被get方法劫持,将对应的副作用函数与obj对应的属性绑定。
1 | obj -> text -> effect1 |
过了1s之后,修改obj.text的值,被set方法劫持,得到text属性对应的所有副作用函数effect1,重新执行effect1函数,更新页面。
过了2s之后,修改obj.value的值,被set方法劫持,得到value属性对应的所有副作用函数effect2,重新执行effect2函数,更新页面。
优化分支切换
分支切换
也许我们可能会根据对象的某个属性的值来执行不同的操作,如果某个操作分支当中又发生了get操作,那么可能会产生遗留的副作用函数。
1 | const data = {text: 'hello world', ok: true}; |
这里的三元运算就产生了分支操作。
当obj.ok的值为真时,读取obj.text的值,触发两次get操作(读取ok一次,读取text一次),所以让effect1这个函数被绑定在ok和text属性上。
1 | obj -> ok -> effect1 |
如果我们修改obj.text的值时,调用effect1函数,更新页面内容。
如果我们修改obj.ok的值时,也会调用effect1函数
但由于ok控制了分支切换,如果它为false,那么div1内容就是固定的not,与obj.text无关了。
但由于obj.text仍然绑定着这个effect1副作用函数,修改obj.text仍然会重新执行effect1函数,尽管这并不会导致页面更新(因为obj.ok此时为false)。
理想情况下我们希望在obj.ok为false的时候解除obj.text对effect1的绑定,因为obj.text已经与这个副作用函数脱钩了。
解决的思路也简单,我们需要在每次副作用函数执行的时候先把它自己从所有与之有关的对象属性中删除,称为cleanup操作。
在副作用函数执行的过程中重新把自己添加进相应的对象属性中。
还是上面的例子:
obj.ok为true,读取了obj.ok与obj.text,所以把副作用函数绑定到了这两个属性上。
接下来修改obj.ok为false,触发set方法,调用effect1函数。
在调用的时候先把effect1从所有属性上脱离绑定,接着运行函数。
触发对obj.ok的读取,触发get方法,把effect1重新注册到obj.ok属性上。
读取到当前obj.ok为false,显示not。(不会触发读取obj.text的操作,因此effect1不会被注册到obj.text上了)
接下来就是实现以上思路了。
我们需要完成两个操作:
- 收集
effect1与哪些属性绑定的信息 - 对绑定了
effect1的属性进行cleanup操作
既然要收集信息,所以我们要先创建一个list来用于存放相关的对象属性。
修改一下注册副作用函数时的effect函数与track函数与trigger函数。
1 | // 存储当前的副作用函数 |
感谢您阅读到这里,本文就先写到这里吧。
响应式系统还有更多复杂的内容,例如effect函数嵌套和调度执行,以及computed和watch等属性,这些内容以后再写写吧。
我的理解和分析很大程度上得益于《Vue.js 设计与实现》一书 (作者:霍春阳)。书中对响应系统的实现原理进行了深入的讲解,为我撰写本文提供宝贵的参考。在此我向霍春阳先生表示衷心的感谢。
