本文探究了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 设计与实现》一书 (作者:霍春阳)。书中对响应系统的实现原理进行了深入的讲解,为我撰写本文提供宝贵的参考。在此我向霍春阳先生表示衷心的感谢。