深入 Vue 响应式原理和简单实现
深入 Vue 响应式原理和简单实现
Vue
最独特的特性之一,就是其非侵入性的响应性系统。
什么是响应性
首先以一个Excel
表格作为示范:
这里将C1
单元格设置为一个函数:SUM(A1, B1)
,这里代表的意思就是C1
单元格的值是A1, B1
两个单元格的数值的求和。
上图也能看到,当A1, B1
单元格值发生变化时,C1
单元格的值会自动发生变化,不需要我们手动去重新赋值。这里所展示的特性就可以看作是响应性。
在 JS
中如果我们简单的模拟一下上述的求和行为:
这里会发现C1
的值并没有发生自动变化,只有在我们进行手动重新赋值时,C1
才会发生变化:
那么在 JS
中如何实现当 A1, B1
发生变化时,C1
能够自动发生变化的效果呢?
如果想要实现这个效果,我们会遇到如下几个问题:
A1, B1
发生变化时,我们要知道都有哪些值要自动变化;- 如何去发生这个变化,这个变化的具体行为是什么,并且让这个行为是可以被重复触发的;
- 在什么时机去触发这些变化;
对于第一个问题:将这个问题换一个角度来看,可以看作是A1, B1
发生变化之后产生的副作用都有哪些?其实这里又会产生一些新的问题,我们该如何收集这些副作用呢,在什么时机去收集这些副作用呢?
对于第二个问题:在JS
中如何去封装一个动作,行为。显然这里可以通过函数对这些变化所导致的行为进行封装。然后在其需要的地方进行重新调用即可。这里你是否也会发现,这些函数其实就是A1, B1
变化之后产生的副作用。
对于第三个问题:在A1, B1
发生变化的时候去重新触发这些行为。
然后我们对上述例子进行第一步改造:
这一步改造只解决了上述的第二个问题,就是将具体行为封装为了函数,然后在 A1, B1
发生变化的时候进行重新触发其副作用。
这里又会有几个问题:
- 当会触发的副作用变多时不方便维护,因此这里我们需要一个地方去收集副作用;
- 流程都是手动的,我们需要让收集副作用的流程和触发副作用的流程变成自动的;
分析问题
在后文中,副作用可以看作为依赖。
这里该怎么理解呢?
一个值变化之后会产生一系列的副作用,那我们是不是可以看作这些副作用其实是依赖于这个值的变化的呢?
因此该值的副作用其实可以看作该值的依赖。
为了方便后文描述,这里我们举一个更加贴切的例子:
这里我们需要解决收集依赖和触发的问题。
这里我们引入一个Dep
类,通过它来收集依赖和触发依赖:
然后就可以将我们的例子变为:
到目前为止,我们已经解决了收集依赖和触发依赖的问题,但是流程依旧不是自动化的,我们期望的效果是:
-
当有函数依赖
product.count, product.price
时,只要这个函数被调用,就自动被收集为专属他们的副作用(依赖),这里可以拆分为两点:- 为它们(可以看作对象的每个属性)找一个唯一存储它们各自副作用(依赖)的位置;
- 在它们被获取时,副作用自动被收集进入存储副作用(依赖)的位置;
-
而在它们对应值发生变化时,能够自动触发所有收集的副作用(依赖);
那么在JS
中有没有一种方法能够监听到触发值的变更和获取某个属性的值呢?答案是有的:
- 在
ES5
中是Object.defineProperty
将对象的属性转化为get, set
来对属性的存取行为拦截; - 在
ES5
中还有一个计算属性(属性存取器)也能完成这个效果(get, set
); - 在
ES6
中是通过Proxy
直接为一个对象生成一个代理对象,我们通过这个代理对象访问原始对象的所有行为都会被拦截;
第一种方案就是Vue2
响应式的实现原理,而第三种方案就是Vue3
响应式的实现原理,接下来我们就这两种方案分别模拟实现一下reactive api
。
实现响应性
接下来的实现都是以解析
Vue
的响应式原理为目的,因此都是抽取的最简实现,很多情况都没有考虑。如果想关注更多的细节,可以去研究源码。
Vue2 响应性简单实现
对对象的响应式转换处理
reactive
实现:
这里我们发现在调用dep.depend()
时无法在和之前一样显式的将effect
传入,因此这里我们可以将effect
存到全局的上下文中,这样在不同的上下文中更方便的获取到effect
。
这里引入一个watchEffect
方法,它有两个作用:
- 将
effect
放到外部,方便在触发响应式数据的get
时收集到依赖; - 防止不期望的
effect
被收集,比如我们在console.log(product.price)
时也会触发到响应式数据的get
。这样就能够只有被watchEffect
探测的effect
才会被收集,这个流程就可控了;
然后 Dep
类中的depend
方法也应该改一下:
最终这个例子可以变成:
对数组的响应式转换处理
这里采用的方式不是将数组每个属性转换为get, set
,而是将需要转化的数组的方法进行重写,这些方法是那些会改变自身的方法(不改变引用)。
分别有:'push', 'pop', 'unshift', 'shift', 'splice', 'reverse', 'sort'
。
interceptArr
方法主要做了四件事:
-
新建一个继承自
Array.prototype
的对象; -
重写数组的
'push', 'pop', 'unshift', 'shift', 'splice', 'reverse', 'sort'
的方法,在保证原有实现的同时,扩展了其实现:-
对新增的值进行响应式转换;
-
并且在调用这些方法时,会触发
dep.notify
去触发所有副作用(依赖);
-
-
然后将这些方法放到我们新建的对象上来覆盖它原生继承自
Array.prototype
上的方法; -
将这个新建的对象作为原型,来替换被转换的数组的原型;
然后定义一个reactiveArr
方法:
其实经过上面的实现你会发现,对数组采用的是这种hack
的方法进行的伪响应式转换,它并没有将数组的每个元素都转换成get, set
,这也就是为什么你直接通过arr[index] = 'xx'
进行赋值时不会触发副作用了。
最终得到一个简单的优化过后的reactive
方法:
这里有个细节需要注意,那就是我们需要在interceptArr
方法中去触发或者收集依赖的话就需要获取到专属于这个属性的dep
对象,因此我们在reactive
增加了一步将 dep
实例作为嵌套对象或者数组的_dep
属性,这样就让interceptArr
方法可以获取到dep
实例了。
然后执行这个例子:
reactive 总结
-
优点:
- 闭包中天生自带一个对于当前属性来说独一无二的上下文来存储每个属性的
dep
,不需要我们额外去设计; - 采用
ES5
中的Object.defineProperty API
,兼容性较好;
- 闭包中天生自带一个对于当前属性来说独一无二的上下文来存储每个属性的
-
缺点:
-
闭包产生的内存泄漏,会在响应式数据的数量变多时,产生过多的内存消耗和性能影响;
-
需要显式的为所有的属性进行转换,正是这个特性导致如果后续我们新增属性或者删除属性,不止是新增和删除行为我们监听不到,而且对于新增的属性来说,它也不再是响应式的;
然后我们知道,数组本身就可以看作一个属性增减比较频繁的对象,也正是因此,这里的常规的转换方案无法对其进行使用。我们需要额外对其进行一些
hack
实现,但是即使如此,也依旧有无法覆盖到的情况。 -
转换模式是:
eager-transform
,在第一次转换时就需要将所有的属性都遍历到,无论这个对象有多大,嵌套层级有多深;
-
Vue3 响应性简单实现
对对象或者数组的响应式转换处理
无论是对象(
Plain Object
)还是数组都使用的是Proxy
进行的转换处理。
因为这里我们不再是遍历对象 key
的方式来转换get, set
来实现响应式,因此无法通过闭包为每个对象的 key
简历自己存放依赖的地方。
那么如何将其存放到一个合适的地方呢?
目前我们知道,收集依赖的地方是在属性的 get
中,而在 proxyHandler
的 get
中的能够获取的信息是该对象本身,和访问的当前 key
。
那么能不能通过这两个信息来作为 map
的键,来为每一个响应式对象的每个属性都找到一个唯一的存放依赖的地方,然后将其依赖存放其中呢?
答案是这可以的,首先这里最外层我们利用 weakMap
,原因是 weakMap
的 key
只能是 object
类型,并且他有一个Map
所不具备的特性,weakMap
是弱引用键值对类型,也就是说如果它的键(object
)如果不再有别的人引用的话,那么该 weakMap
的这对键值对都会被垃圾回收,这样可以最大程度上的节省空间。
而 Map
却不会,Map
是强引用键值类型,如果将一个对象作为Map
的键,即使这个对象在外界已经没有人引用了,Map
依旧会持有这个键值对。
这里我们内层用Map
,也就是将Map
作为weakMap
的值,Map
中的键是一个响应式对象的属性名,而它的值就是对应的存放它的副作用(依赖)的位置。
关于WeakMap
和Map
具体可以参考:浅析 Map
和 WeakMap
区别以及使用场景。
收集依赖:
触发依赖:
reactive
简单实现:
使用测试:
reactive 总结
-
优点:
-
lazy(demand)-transform
,在get
中进行嵌套转换,也就是说对于嵌套对象如果你不获取的话,是不会为其生成代理对象的; -
get, set
的拦截层面是在对象级别,我们访问的其实是代理对象,然后代理对象通过Reflect.xxx
来访问原始对象。也正是因为这样,不管该对象有多少属性,或者后面会不会有新增属性,只要我们访问的是代理对象,那么该行为都可以被拦截到;不需要对数组进行额外处理,因为数组也是一种对象,比如
push
方法它内部会对这个对象新增属性,该行为是会被拦截到的,只要你访问的是代理对象; -
Proxy
目前一共支持13
种拦截行为,包括get, set, deleteProperty, has...
等,因此Vue3
的响应式能够对这些行为进行响应式的支持;
-
-
缺点:
- 兼容性不太好,
IE
浏览并未对其实现,并且由于ES5
的限制,ES6
新增的Proxy
无法被转译成ES5
,或者通过Polyfill
提供兼容;
- 兼容性不太好,
ref 简单实现
根据之前的响应式原理我们知道,核心都是解决三个问题:
- 为每一个需要转化的数据找一个唯一的地方存储它的副作用(依赖);
- 在获取值时收集副作用(依赖);
- 在更新值时触发副作用(依赖);
不管怎么做都绕不开一个行为,那就是需要拦截值的 get, set
。
拦截值目前有三种方案:
ES5
的Object.defineProperty
;ES5
的计算属性(属性读写器);ES6
的Proxy
来生成代理对象;
无论是通过哪一种转换方案,都是需要依赖目标是一个对象,因此这里会发现如果要将一个原始类型的值转换为响应式,那就需要为其包裹一个对象。
而这里的ref
采用的是上述第二种方案:ES5
的计算属性(属性读写器):
测试案例:
computed 简单实现
实际使用中:
这里我们期望的效果是:
count.value
或者price.value
发生变化时,totalPrice.value
能够自动发生变化;totalPrice.value
发生变化时,能够触发() => console.log(xxx)
这个副作用;
那么这里有两个问题需要解决:
- 如何让
totalPrice.value
能够收集副作用(依赖)和触发副作用(依赖); - 如何在
getter
中的值发生变化的时候,让totalPrice.value
自动发生变化;
响应性实现总结
这里会发现,不管是Vue2
,还是Vue3
,它们的响应式核心就是解决三个问题:
- 如何去为每个响应式的数据找一个唯一的地方去收集副作用(依赖);
- 何时和如何收集所有副作用(依赖);
- 何时和如何去触发所有副作用(依赖);
然后解决方案就是:
Vue2
:
- 在遍历对象
key
并通过Object.defineProperty
将其转换为get, set
的过程中产生的闭包上下文中的dep
实例中存储依赖; get
中收集依赖;set
中触发依赖;
Vue3
:
- 分别将对象本身和对象的属性作为
key
,通过WeakMap
和Map
来定义一个唯一的地方存储副作用(依赖); get
中收集依赖;set
中触发依赖;
只不过它们两个实现转化get, set
的方式不一样,Vue2
中用的是es5
中的Object.defineProperty
,而Vue3
中用的是es6
的Proxy
。
这两种转换方案的不同使得它们有很多差异:
Vue2
:
-
eager-transform
,在第一次转换时就需要将所有的属性都遍历到,无论这个对象有多大,嵌套层级有多深; -
get, set
的拦截层面是在属性级别,需要显式的用Object.defineProperty
将每个属性转换,对于未转换的就无能为力了,这也是为什么它对新增属性无法做到监听的原因;因此还需要对数组进行额外的
hack
处理(重写push, pop, slice, splice...
等方法); -
由于
Object.defineProperty
的限制,它只能对get, set
行为进行拦截,无法对delete
或者其他行为进行拦截; -
Object.defineProperty
的兼容性比较好;
Vue3
:
-
lazy(demand)-transform
,在get
中进行嵌套转换,也就是说对于嵌套对象如果你不获取的话,是不会为其生成代理对象的; -
get, set
的拦截层面是在对象级别,我们访问的其实是代理对象,然后代理对象通过Reflect.xxx
来访问原始对象。也正是因为这样,不管该对象有多少属性,或者后面会不会有新增属性,只要我们访问的是代理对象,那么该行为都可以被拦截到;不需要对数组进行额外处理,因为数组也是一种对象,比如
push
方法它内部会对这个对象新增属性,该行为是会被拦截到的,只要你访问的是代理对象; -
Proxy
目前一共支持13
种拦截行为,包括get, set, deleteProperty, has...
等,因此Vue3
的响应式能够对这些行为进行响应式的支持; -
在一些浏览器,比如
IE
上兼容性不太好;并且由于
ES5
的限制,ES6
新增的Proxy
无法被转译成ES5
,或者通过Polyfill
提供兼容;