Vue响应式原理2到3「扫盲推荐」

473 阅读11分钟

前言

个人学习Vue总结的笔记文章,极为基础扫盲推荐!响应式作为Vue最独特的特征之一,了解其之中的原理有助于我们避开常见问题,因此在这篇文章我们将探讨Vue响应性系统的底层原理,主要讲述何为响应性、Vue 23中响应式的原理以及为什么Vue 3需要对响应式进行优化,走完这些步骤你对响应式原理肯定基本了解,若有错误大佬们请务必指出

什么是响应性?

响应性是一种允许我们以声明式的方式去适应变化的编程范例。听起来一脸懵逼对不对。

简单来说就是当A发生变化的时候,依赖于A的B、C及时响应更新,这是官网很直接的Excel案例,

改变A1与A2单元格中的数字,sum函数自动更新A3中的求和结果。
分析sum = val1 + val2其中的步骤:

  1. 当一个值被依赖时进行追踪,如 val1 + val2 中同时依赖 val1 和 val2
  2. 当某个值改变时进行监听,如监听val1 被赋值, val1 = 3
  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.val1val.val2,updateSum承接了运算总和的动作,但当数据改变的时候,我们只能手动更新这与响应式相差甚远。那怎么实现响应式呢?从这个案例出发,我们一步步来看Vue 2Vue 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并存入Depsubs数组中,所以我们还得给出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终究是2Vue 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中设置对targetget操作进行拦截,这样我们就做到了监听的能力。而且Proxy不仅仅可以对getset进行拦截,还可以拦截更多的操作,打破了Object.defineProperty的局限。Proxy就相当于裹住糖果的纸,想要吃糖就必须打开纸,所以这样也就没Vue.set啥事了因为都可以监听到操作了^-^。
现在我们明白了Vue 2Vue 3的主要区别,但重点是Vue 3响应式原理,下面将简单讲述。

2. Vue 3响应式原理

因为Vue 3的响应式中代码逻辑比较复杂,下面将简单的进行阐述。
Vue 3中会在track函数中为对象属性构建Dep对象用于收集订阅,并且将其存进targetMap(weakMap)建立数据对象 -> 订阅的映射关系,便于之后查询调用。
但不同于在于Vue 2Dep类是在subs数组中存储Watcher,并写好各类操作方法。而Vue 3Dep是一个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响应式原理会基本了解,若有什么不对的地方还请指出。