面试我会这样答系列之 --了解vue响应式吗

378 阅读8分钟

我是新报道的前端小白🥰,这是我的第一篇笔记,希望路过的大佬多多赐教,若觉得新同学还算认真,请给一个鼓励的👍加关注,谢谢各位💕

什么是响应式

已知Vue声明式操作dom,那么就需要侦测到状态发生了什么变化,响应式就是侦测数据变化并处理变化的

如何实现

实现变化侦测的方案分两种,推和拉,在Vue中采取的是推的模式,也就是当状态发生改变的时候,框架立刻知道,并在一定程度上知道哪些状态改变了

既然想实现立刻知道那么就需要对变化进行侦测,Vue使用Observer将普通object转换成被侦测的object

如何对object实现的侦测,被侦测后的object又长什么样呢

通过defineReactive定义一个响应式数据value,并通过闭包的形式,将通过defineProperty将其封装在data.key的get/set函数中,那么被侦测后的数据每个属性都将拥有一个属于自己的get和set方法

export function defineReactive(data, key, value) 
    observe(value); // 对结果递归拦截
    Object.defineProperty(data,key,{
        get(){
            return value;
        },
        set(newValue){
            if(newValue === value) return;
            observe(newValue); // 如果用户设置的是一个对象 , 就继续将用户设置的对象变成响应式的
            value = newValue
        }
    })
}

侦测对象的变化是为了什么

侦测对象变化的目的就是让框架知道哪里变了,那么侦测到对象的变化了接下来要做的就是通知框架并让其做出响应,也就是对依赖的触发和收集

依赖又是什么呢

依赖就是状态变化后需要通知的对象,也就是用到了数据的地方,Vue中将其抽象为Watcher类,每个用到数据的地方都是一个实例,也就是细粒度监听,由此推断,如果一个数据被使用了很多次那就会创建很多的实例,如此的细粒度监听会造成一些内存开销,所以在Vue2.0之后便引入了虚拟dom,便可将粒度调整的中度,每一个Watcher实例代表一个组件,状态变化后只通知到组件,组件内部再去进行虚拟dom比对找到具体发生变化的位置,也就是前面说的,Vue只能在一定程度上知道哪里变化了

如何收集、触发依赖

根据依赖的概念推出收集依赖必然是要在使用到数据的地方,在修改数据的地方触发依赖,那么就是在getter中收集依赖,在setter中触发依赖,可是依赖放哪去了呢

依赖收集在哪呢

一个数据可以被多个组件使用的,那么也就是说一个数据会有多个依赖,那么就是一个数组形式,可以将这个数组放在每个属性key上,在get的时候将watcher收集进来,在set的时候再依次去通知他们,Vue将其抽象成了Dep类用来管理依赖

export function defineReactive(data, key, value) 
    observe(value); 
		let dep = new Dep()
    Object.defineProperty(data,key,{
        get(){
          	dep.depend() //将依赖注入进去
            return value;
        },
        set(newValue){
            if(newValue === value) return;
            observe(newValue); // 如果用户设置的是一个对象 , 就继续将用户设置的对象变成响应式的
            value = newValue
          	dep.notify() // 通知响应的依赖更新变化
        }
    })
}

如何将watcher收集到dep[]中的,也就是depend都干了些什么

depend要做的就是在获取数据的时候让属性记住watcher,也让watcher记住自己,不过这涉及到三个问题

  1. 首先,虽然是在数据get的时候收集依赖,但是get也分情况,我们收集在模板中使用到了的数据在get时进行watcher收集,此外还有用户自定义的watcher(先不赘述)

    也就是说,仅在$mount中收集依赖,在script中使用数据的时候,我们不对其进行依赖收集

  2. 其次我们知道dep和watcher一定是多对多的关系,也就是说,一个数据可以被多个组件使用,那么一个组件又可以使用多个数据。所以也需要将dep添加到watcher中

  3. 另外一个数据肯定是可以在组件中使用很多次的,也就是说数据中可能要添加很多次当前watcher,watcher也会记录很多次当前数据,那就需要做双向去重处理

让属性记住watcher也就是将watcher实例添加到key的dep[]中去,那么就需要先保存一下当前的watcher,使用Dep.target再好不过了,然后再触发一下watcher自己的getter,也就是会触发vm._ update(vm._render()),在此就会执行render函数,那么就会对模板中用到的数据进行取值,在取值的时候就会触发数据的get函数,那么也就顺利将此时这个watcher添加进了key的dep[]中

知道了watcher的添加方式就可以解决上面两个问题了

  1. 只需要在vm._ update(vm._render())(也就是watcher自己的getter)执行完毕之后将Dep.target置空便可,这样就保证了在此次任务完成之后,再触发数据get的时候Dep.target中再也没有当前watcher了
  2. 只需要在watcher被加到dep[]中的时候先将当前dep加入到watcher中就好了
  3. 只需在dep被加入到watcher的时候做一次去重就好,只在dep未被记录在watcher中的时候才相互添加,否则就不做处理
let id = 0;
class Watcher {
    constructor(vm, exprOrFn, cb, options) {
        this.vm = vm;
        this.cb = cb;
        this.options = options;
        this.id = id++;
        this.getter = exprOrFn;
        this.deps = [];
        this.depsId = new Set();

        this.get(); // 调用传入的函数, 调用了render方法, 此时会对模板中的数据进行取值
    }
    get() { // 这个方法中会对属性进行取值操作
        pushTarget(this); // Dep.target = watcher
        this.getter(); // 会取值  vm__update(vm._render())
        popTarget(); // Dep.target = null
    }
    addDep(dep) {
        let id = dep.id;
        if (!this.depsId.has(id)) { // dep 是非重复的,watcher肯定也不会重
            this.depsId.add(id);
            this.deps.push(dep);
            dep.addSub(this);
        }
    }
}

存在的问题

​ 由于是通过getter/setter实现的变化侦测,所以对象添加属性和删除属性都无法被侦测到,需要通过vm.setvm.set与vm.delete来解决

数组的变化侦测

上面讲完了对象类型的数据侦测,接下来将数组的变化侦测,其二者还是存在显著差异的,导致差异的原因主要是我们操作对象和操作数组的方式不同,对于数组,我们一般不会像对象直接操作属性那样通过下标来操作元素,一般都是通过方法来操作数组,那么想侦测数组的变化,直接从能改变数组的方法下手就可以

能改变数组的方法只有七种,push/pop/shift/unshift/sort/splice/reverse

这些方法都是存在于数组类型的原型身上,所以我们想要该写这些方法的话仅需要改写数组的原型链即可,想要在改写此七种方法的基础上不会对其他方法造成影响,还需将新原型的原型再次指向Array.prototype,这样就仅仅在新原型身上对此上七种方法做了遮蔽效果,并不会影响到原型上的其他属性和方法

let oldArrayProtoMethods = Array.prototype;

// 不能直接改写数组原有方法 不可靠,因为只有被vue控制的数组才需要改写

export let arrayMethods = Object.create(Array.prototype)
let methods = [ // concat slice ... 都不能改变原数组
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'reverse',
    'sort'
];

methods.forEach(method=>{   // AOP切片编程
    arrayMethods[method] = function (...args) { // 重写数组方法
        // todo ...
        // 有可能用户新增的数据时对象格式 也需要进行拦截
        let result = oldArrayProtoMethods[method].call(this,...args);
        return result;
    }
})

初次之外还要考虑对能够添加值的方法做进一步的处理,对添加进来的新值也要做数据侦测,可是其中存在一个问题,就是侦测数组变化的方法是定义在Observer实例上的,在外部是无法直接访问到的

那么仅需要为实例添加一个__ob__属性,并将Observer的this保存在这个属性上就可供实例使用了,不过如果我们直接通过value.__ob__ = this改写的话,__ob__ 便会成为Observer实例的一个属性,在循环侦测时会被枚举出来,导致循环引用爆栈的问题,所以需要通过属性描述符的方式来定义

class Observer {
    constructor(value) { 
        // value.__ob__ = this; 
        Object.defineProperty(value,'__ob__',{
            value:this,
            enumerable:false, // 不能被枚举表示 不能被循环
            configurable:false,// 不能删除此属性
        })
        if(Array.isArray(value)){
            Object.setPrototypeOf(value,arrayMethods); // 循环将属性赋予上去
            this.observeArray(value);
        }
    }
    observeArray(value){
        for(let i = 0; i < value.length;i++){ // 如果数组中是对象的话,就会去递归观测,观测的时候回增加__ob__属性
            observe(value[i]);
        }
    }
}
let oldArrayProtoMethods = Array.prototype;

// 不能直接改写数组原有方法 不可靠,因为只有被vue控制的数组才需要改写

export let arrayMethods = Object.create(Array.prototype)
let methods = [ // concat slice ... 都不能改变原数组
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'reverse',
    'sort'
];

methods.forEach(method=>{   // AOP切片编程
    arrayMethods[method] = function (...args) { // 重写数组方法
        // todo ...
        // 有可能用户新增的数据时对象格式 也需要进行拦截
        let result = oldArrayProtoMethods[method].call(this,...args);
        let inserted;
        let ob = this.__ob__
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args;
                break;
            case 'splice': // splice(0,1,xxxx)
                inserted = args.slice(2);
            default:
                break;
        }
        if(inserted)  ob.observeArray(inserted);
        return result;
    }
})

数组的依赖收集与触发

既然数组的变化侦测方式都不同了,那么依赖收集的方式肯定也会有所区别,但归根结底还是在使用时收集,在修改时触发

数组的依赖收集和object一样在defineReactive中,因为使用数组是和使用object一样通过访问的形式使用

export function defineReactive(data, key, value) {
    // value 可能也是一个对象
    let childOb = observe(value); // 如果value已经是响应式数据会返回创建的Observer实例,如果未被侦测过则会尝试创建并返回结果
    let dep = new Dep(); // 每次都会给属性创建一个dep  
    Object.defineProperty(data,key,{ // 需要给每个属性都增加一个dep
        get(){ 
            if(Dep.target){
                dep.depend(); 
                // childOb 可能是对象 也可能是数组  
                if(childOb){ // 如果对数组取值 会将当前的watcher和数组进行关联
                    childOb.dep.depend();
                }
            }
            return value;
        },
        set(newValue){
            if(newValue === value) return;
            observe(newValue); 
            value = newValue;
            dep.notify();// 通知dep中记录的watcher让它去执行
        }
    })
}

触发依赖则在新的原型方法中处理

methods.forEach(method=>{   // AOP切片编程
    arrayMethods[method] = function (...args) { // 重写数组方法
        // todo ...
        // 有可能用户新增的数据时对象格式 也需要进行拦截
        let result = oldArrayProtoMethods[method].call(this,...args);
        let inserted;
        let ob = this.__ob__
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args;
                break;
            case 'splice': // splice(0,1,xxxx)
                inserted = args.slice(2);
            default:
                break;
        }
        if(inserted)  ob.observeArray(inserted);
        ob.dep.notify(); // 在此通知依赖
        return result;
    }
})

数组中的数组的依赖收集

对于数组中嵌套数组的情况,不会再为内层数组进行单独的依赖收集,而是简单的将外层数组的依赖给到内层数组,实现修改内层数组也会触发依赖的效果

function dependArray(value){ // 就是让里层数组收集外层数组的依赖,这样修改里层数组也可以更新视图 
    for(let i = 0 ; i < value.length;i++){
        let current = value[i];
        current.__ob__ && current.__ob__.dep.depend(); // 让里层的和外层收集的都是同一个watcher
        if(Array.isArray(current)){
            dependArray(current);
        }
    }
}
function defineReactive(data, key, value) {
    let childOb = observe(value);
    let dep = new Dep(); 
    Object.defineProperty(data,key,{
        get(){ 
            if(Dep.target){
                dep.depend();
                // childOb 可能是对象 也可能是数组  
                if(childOb){ 
                    childOb.dep.depend();
                    if(Array.isArray(value)){// 如果对数组取值 会将当前的watcher和数组进行关联
                        dependArray(value);
                    }
                }
            }
            return value;
        },
        set(newValue){
            if(newValue === value) return;
            observe(newValue);
            value = newValue;
            dep.notify();
        }
    })
}

数组变化侦测的问题

因为数组的变化侦测是通过拦截原型来实现的,所以通过下标和属性来操作数组时都无法侦测到变化

不过以上的问题都在Vue3中通过Proxy得到解决