前言
个人学习Vue总结的笔记文章,极为基础扫盲推荐!响应式作为Vue最独特的特征之一,了解其之中的原理有助于我们避开常见问题,因此在这篇文章我们将探讨Vue响应性系统的底层原理,主要讲述何为响应性、Vue 2与3中响应式的原理以及为什么Vue 3需要对响应式进行优化,走完这些步骤你对响应式原理肯定基本了解,若有错误大佬们请务必指出
什么是响应性?
响应性是一种允许我们以声明式的方式去适应变化的编程范例。听起来一脸懵逼对不对。
简单来说就是当A发生变化的时候,依赖于A的B、C及时响应更新,这是官网很直接的Excel案例,
改变A1与A2单元格中的数字,sum函数自动更新A3中的求和结果。
分析sum = val1 + val2其中的步骤:
- 当一个值被依赖时进行追踪,如
val1 + val2中同时依赖val1和val2。 - 当某个值改变时进行监听,如监听
val1被赋值,val1 = 3。 - 监听到更改后重新运行代码来读取原始值,再次运行
sum = val1 + val2来更新sum的值。
那么如何用JavaScript实现类似Excel中的动作呢,简单的代码是这样的。
let val = {
val1: 2,
val2: 3
}
let sum = 0;
let updateSum = () =>{
sum = val.val1 + val.val2;
}
updateSum()
console.log(sum); //5
val.val1 = 3;
updateSum()
console.log(sum); //6
sum依赖着val.val1和val.val2,updateSum承接了运算总和的动作,但当数据改变的时候,我们只能手动更新这与响应式相差甚远。那怎么实现响应式呢?从这个案例出发,我们一步步来看Vue 2到Vue 3是如何实现以上三个步骤的,只需要加亿点点细节。
Vue响应性原理
1. Vue 2实现响应式三部曲
2. 为何需要重写响应式
3. Vue 3船新版本
Vue 2实现响应式三部曲
1. 当一个值被依赖时进行追踪
为知道val对象中的属性在哪些地方被依赖,我们需要一个对象来存储依赖val的一方,这被称为订阅收集。
在Vue 2中会遍历data函数返回值中声明的属性,为每一个属性实例一个Dep(depend)对象并在subs数组中收集watcher(订阅者),所以我们也来创建一个简单的Dep构造函数,
class Dep {
constructor(){
this.subs = [];
}
addSub = function addSub (sub) {
//添加watcher
this.subs.push(sub);
}
};
在响应式对象属性被调用的时候,Vue 2会实例该属性的Watcher并存入Dep的subs数组中,所以我们还得给出Watcher类,初步简单实现如下:
class Watcher {
constructor(value) {
this.value = value
}
}
现在我们有了Dep来收集Watcher,
那怎么在遍历的时候为每一个属性创建自己的Dep对象呢?怎么监听属性被调用被修改呢?
这个时候我们就得搬出来我们的Observer(观察者)了,初始化Observer会遍历传入的对象通过Object.definProperty
将属性转化为响应式的,并为每一个属性创建Dep对象,如下是简单的实现,
class Observe {
constructor(value){
this.value = value //被观察的对象
this.dep = new Dep()
this.walk(value)
}
walk(obj){
let keys = Object.keys(obj) //对象的自身可枚举属性组成的数组
keys.forEach( key => {
let value = obj[key]
const dep = new Dep()
const w = new Watcher(value)
dep.addSub(w) //图方便直接把Watcher在这push进去
Object.defineProperty(obj, key, {
get: function (){
console.log("调用被我逮到了哦")
return value
}
})
})
}
}
new Observer(val)为val的属性构建了自己的dep对象并通过Object.definProperty为对象属性添加get属性使其转变为getter,这样即创建了收集订阅的空间,也可以在val的属性被调用的时候可以捕捉到。
new Observe(val);
let test = val.val1 //调用被我逮到了哦
到现在我们便完成了第一步: 当一个值被依赖时进行追踪,让我们继续第二步
2. 当某个值改变时进行监听
有了第一步的基础,我们要监听值的改变极为简单,只需在Observer中用Object.definProperty为属性添加set使其转变为setter,实现如下:
class Observe {
constructor(value){
……
}
walk(obj){
let keys = Object.keys(obj) //对象的自身可枚举属性组成的数组
keys.forEach( key => {
……
Object.defineProperty(obj, key, {
get: function (){
……
}
set: function (newValue){
console.log("修改被我逮到了哦")
value = newValue;
}
})
})
}
}
new Observe(val);
val.val1 = 6; //修改被我逮到了哦
3. 监听到更改后重新运行代码来读取原始值
通过new Observe(val)我们将val转变为了响应式的,可以监听属性的调用和修改,并且构建了val的属性自己的dep对象用于储存收集的Watcher。对于第三步我们只需要在监听到修改的时候遍历该属性dep对象的subs数组,调用Watcher中的updata进行重新运行的动作来修改sum。
因此我们需要为Watcher添加updata方法,并在监听到修改的时候在dep中遍历Watcher触发updata。
结合前几步代码,加入新需的代码得到完整的代码如下:
let val = {
val1: 2,
val2: 3
}
let sum = 0
let updateSum = function(){
sum = val.val1 + val.val2;
}
class Dep {
constructor(){
this.subs = [];
}
addSub = function addSub (sub) {
//添加Watcher
this.subs.push(sub);
}
notify = function notify () {
// 遍历subs数组中的Watcher进行更新
let subs = this.subs.slice();
for (let i = 0; i < subs.length; i++) {
subs[i].update();
}
}
};
class Watcher {
constructor(value) {
this.value = value
}
update() {
//更新渲染界面
updateSum()
console.log('sum更新啦' + sum)
}
}
class Observe {
constructor(value){
this.value = value //被观察的对象
this.dep = new Dep()
this.walk(value)
}
walk(obj){
let keys = Object.keys(obj) //对象的自身可枚举属性组成的数组
keys.forEach( key => {
let value = obj[key]
const dep = new Dep()
const w = new Watcher(value)
dep.addSub(w) //图方便直接把Watcher在这push进去
Object.defineProperty(obj, key, {
get: function (){
console.log("调用被我逮到了哦")
return value
},
set: function (newValue){
console.log("修改被我逮到了哦")
value = newValue;
dep.notify()
}
})
})
}
}
new Observe(val);
val.val1 = 6
//修改被我逮到了哦
//调用被我逮到了哦
//调用被我逮到了哦
//sum更新啦9
以上我们完成了三个步骤,成功实现了响应性更新sum,也一步步弄明白了Vue 2是如何实现响应性的。
现在来理解官网的图,是不是一目了然了。
但Vue 2终究是2,Vue 3中对响应性原理进行了大修改,那为什么要进行大修改呢?
这就得咱们从Vue 2中响应性的缺陷来看了
为何需要重写响应式
详见尤大亲笔 The process: Making Vue 3
主要原因:
Vue 2通过将状态对象上的属性替换为getter/setter来实现响应式。但对Vue存在限制,例如无法检测新的属性添加、数组元素的直接修改,为提供更好的性能进行重写。
1. 对于对象
Vue 2无法通过以上的响应式来监听property的添加和删除,因为Vue 2是在vue实例化的时候将data中的对象转变为getter/setter响应式的,所以只会为实例化时data中有的对象属性才能被监听,在之后生命周期内添加的将不会转为响应式。
为了应对这个,Vue给出了新的方法
Vue.set(object, propertyName, value)和Vue.delete(object, propertyName)向响应式对象中添加和删除一个property,并确保新 property 是响应式的,视图跟随更新。
Vue.set的代码逻辑如下,Vue.delete和这个大差不差,感兴趣的话可以去查看源码。
虽然Vue.set可以在其他的周期内添加响应式对象,但还是会很麻烦,所以为避免需要去调用Vue.set来实现新增prototype的响应式,我们往往在Vue实例化之前就声明好所有的根级响应式prototype,尽管基本都为空值
var vm = new Vue({
data: {
// 声明 message 为一个空值字符串
message: ''
},
template: '<div>{{ message }}</div>'
})
// 之后设置 `message`
vm.message = 'Hello!'
2. 对于数组
因为数组的数据操作不同,所以响应式原理与对象的实现是不同的,从一个简单的例子就能看出
this.arr.push(val)
我们是通过调用数组的push方法向arr中添加了一个val,这样压根就不会触发get或者set,所以沿用对象的响应式是行不通的。
从刚才的例子中就能看出,对数组的操作基本通过数组原型上的方法来执行,所以 Vue 2通过了覆写Array.prottotype来覆盖原来的,让调用push的时候,先执行我们的处理方法再执行push,起到了拦截器的作用,
覆写的源代码如下
var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto); //拷贝Array.prototype
var methodsToPatch = [
//七种可以改变数组自身内容的方法
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
methodsToPatch.forEach(function (method) {
// 覆盖原始方法
var original = arrayProto[method];
def(arrayMethods, method, function mutator (){
var args = [], len = arguments.length;
while ( len-- ) args[ len ] = arguments[ len ];
var result = original.apply(this, args); //数组方法执行的结果
var ob = this.__ob__;
var inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); } //返回的数组也得转为响应式
// 遍历更新
ob.dep.notify();
return result
});
});
有了以上覆写的方法,那么我们只需要在Observe中判定data中是数组还是对象,若为数组则用arrayMethods去覆盖Array.prototype,若为对象则使用Object.defineProperty来实现响应式。由于篇幅较多就不贴代码了,感兴趣可以查看源码。
因为我们是通过拦截原型的方式实现数组响应式所以仍存在局限,如通过索引直接修改数组和直接修改数组的长度
this.arr[0]="我就是要直接改"
this.arr.length = 8
所以我们还是得依仗Vue.set来解决直接修改不能响应的问题,逻辑同对象,而第二种便得通过arr.splice来变通实现。
由此可见Vue 2对于存在的局限性,只得不断去补洞,效率不够高。
Vue 3船新版本
出于以上Vue 2响应式的种种局限,Vue 3对其进行了大修改,虽然说是船新版本,但其实外表还是一样:收集订阅->监听数据改变->做出修改,只是内部实现上发生了改变
1. 2与3主要区别
不同于Vue 2通过Object.defineProperty来将prototype转变为getter/setter来实现响应式,Vue 3则通过proxy代理的方式来拦截对prototype的操作,简单代码为:
const target = {
message: "hello"
};
const handler = {
get: function (target, prop, receiver) {
console.log('你得先执行我')
return Reflect.get(...arguments); //拦截JavaScript操作,将this指向Proxy
},
};
const proxy = new Proxy(target, handler);
console.log(proxy.message)
//你得先执行我
//hello
以上我们在handle中设置对target的get操作进行拦截,这样我们就做到了监听的能力。而且Proxy不仅仅可以对get和set进行拦截,还可以拦截更多的操作,打破了Object.defineProperty的局限。Proxy就相当于裹住糖果的纸,想要吃糖就必须打开纸,所以这样也就没Vue.set啥事了因为都可以监听到操作了^-^。
现在我们明白了Vue 2和Vue 3的主要区别,但重点是Vue 3响应式原理,下面将简单讲述。
2. Vue 3响应式原理
因为Vue 3的响应式中代码逻辑比较复杂,下面将简单的进行阐述。
Vue 3中会在track函数中为对象属性构建Dep对象用于收集订阅,并且将其存进targetMap(weakMap)建立数据对象 -> 订阅的映射关系,便于之后查询调用。
但不同于在于Vue 2的Dep类是在subs数组中存储Watcher,并写好各类操作方法。而Vue 3的Dep是一个set集合里面放的也不是Watcher而是effect,通过effect来追踪订阅对象,可以简单的理解为更新视图的函数。简单代码如下
export function track(target: object, type: TrackOpTypes, key: unknown) {
//创建dep来收集订阅
if (!isTracking()) {
return
}
let depsMap = targetMap.get(target) //转为WeakMap
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
const eventInfo = __DEV__
? { effect: activeEffect, target, type, key }
: undefined
dep.add(eventInfo) //添加effect
}
结构流程为
收集订阅,基于Proxy来拦截操作、追踪变化当数据发生改变的时候触发trigger函数来更新订阅,完成响应式的整个流程✅。
export function trigger(target: object,
type: TriggerOpTypes,
key?: unknown,){
const depsMap = targetMap.get(target)
deps = [...depsMap.values()]
//遍历更新
for (const effect of isArray(dep) ? dep : [...dep])(
effect => effect.run()
)
}
3.提升在哪
1. proxy打破了Object.property的局限,proxy可以拦截更多的操作进行代理监听。
2. 初始化的时候不必对对象的所有深层的子属性进行响应式定义,而是在需要深层子属性的时候才会定义响应式,降低了初始化时的损耗。
3. weakMap弱引用的数据类型,便于垃圾回收没用的effect。
4. 待挖掘
总结
以上我们从什么是响应式出发,到简单实现Vue 2对对象和数组的响应式,思考Vue 2存在的局限为什么需要进行优化改造,再到浅层探讨Vue 3响应式的实现原理和优点在哪。基本覆盖了响应式的内容。
走完以上这些流程,想必您对Vue响应式原理会基本了解,若有什么不对的地方还请指出。