面试官的步步紧逼:Vue2 和 Vue3 的响应式原理比对

20,040 阅读28分钟

前言

技术栈是 Vue 的同学,在面试中难免会被问到 Vue2 和 Vue3 的相关知识点的实现原理和比较,面试官是步步紧逼,一环扣一环。

Vue2 的数据响应式原理是怎么样的?

Vue3 的数据响应式原理又是怎么样的?

Vue2 中是怎么监测数组的变化的?

Vue3 中又是怎么监测数组的变化的?

在问完你 Vue2 的数组的响应式原理之后,接着可能会补上一句,为什么要通过重写数组原型的 7 个方法来对数组进行监测?是因为 defineProperty 真的不能监测数组变化吗?Vue3 真的只使用 Proxy 就可以实现对数组的代理了吗?还需要进行什么设置呢?

Vue2 和 Vue3 的数据响应式实现原理具体是非常复杂和细节非常繁琐的,但我们需要在面试中去说清楚其中的原理,这就需要我们进行宏观和高度的概括总结。本文主要从面试的角度去讲解相关的实现原理,相关代码只是一个辅助理解。

问题1:Vue2 的数据响应式原理是怎么样的?

所谓数据响应式就是首先建立响应式数据和依赖之间的关系,当这些响应式数据发生变化的时候,可以通知那些绑定这些数据的依赖进行相关操作,可以是 DOM 更新,也可以是执行一个回调函数。

我们知道 Vue2 的对象数据是通过 Object.defineProperty 对每个属性进行监听,当对属性进行读取的时候,就会触发 getter,对属性进行设置的时候,就会触发 setter。

/**
* 这里的函数 defineReactive 用来对 Object.defineProperty 进行封装。
**/
function defineReactive(data, key, val) {
   // 依赖存储的地方
   const dep = new Dep()
   Object.defineProperty(data, key, {
       enumerable: true,
       configurable: true,
       get: function () {
           // 在 getter 中收集依赖
           dep.depend()
           return val
       },
       set: function(newVal) {
           val = newVal
           // 在 setter 中触发依赖
           dep.notify()
       }
   }) 
}

那么是什么地方进行属性读取呢?就是在 Watcher 里面,Watcher 也就是所谓的依赖。在 Watcher 里面读取数据的时候,会把自己设置到一个全局的变量中。

/**
* 我们所讲的依赖其实就是 Watcher,我们要通知用到数据的地方,而使用这个数据的地方有很多,类型也不一样,有* 可能是组件的,有可能是用户写的 watch,我们就需要抽象出一个能集中处理这些情况的类。
**/
class Watcher {
    constructor(vm, exp, cb) {
        this.vm = vm
        this.getter = exp
        this.cb = cb
        this.value = this.get()
    }

    get() {
        Dep.target = this
        let value = this.getter.call(this.vm, this.vm)
        Dep.target = undefined
        return value
    }

    update() {
        const oldValue = this.value
        this.value = this.get()
        this.cb.call(this.vm, this.value, oldValue)
    }
}

在 Watcher 读取数据的时候也就触发了这个属性的监听 getter,在 getter 里面就需要进行依赖收集,这些依赖存储的地方就叫 Dep,在 Dep 里面就可以把全局变量中的依赖进行收集,收集完毕就会把全局依赖变量设置为空。将来数据发生变化的时候,就去 Dep 中把相关的 Watcher 拿出来执行一遍。

/**
* 我们把依赖收集的代码封装成一个 Dep 类,它专门帮助我们管理依赖。
* 使用这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。
**/
class Dep {
    constructor() {
        this.subs = []
    }
    
    addSub(sub) {
        this.subs.push(sub)
    }
    
    removeSub(sub) {
        remove(this.subs, sub)
    }

    depend() {
        if(Dep.target){
            this.addSub(Dep.target)
        }
    }

    notify() {
        const subs = this.subs.slice()
        for(let i = 0, l = subs.length; i < l; i++) {
            subs[i].update()
        }
    }
}

// 删除依赖
function remove(arr, item) {
    if(arr.length) {
        const index = arr.indexOf(item)
        if(index > -1){
            return arr.splice(index, 1)
        } 
    }
}

总的来说就是通过 Object.defineProperty 监听对象的每一个属性,当读取数据时会触发 getter,修改数据时会触发 setter。

然后我们在 getter 中进行依赖收集,当 setter 被触发的时候,就去把在 getter 中收集到的依赖拿出来进行相关操作,通常是执行一个回调函数。

我们收集依赖需要进行存储,对此 Vue2 中设置了一个 Dep 类,相当于一个管家,负责添加或删除相关的依赖和通知相关的依赖进行相关操作。

在 Vue2 中所谓的依赖就是 Watcher。值得注意的是,只有 Watcher 触发的 getter 才会进行依赖收集,哪个 Watcher 触发了 getter,就把哪个 Watcher 收集到 Dep 中。当响应式数据发生改变的时候,就会把收集到的 Watcher 都进行通知。

由于 Object.defineProperty 无法监听对象的变化,所以 Vue2 中设置了一个 Observer 类来管理对象的响应式依赖,同时也会递归侦测对象中子数据的变化。

问题2:为什么 Vue2 新增响应式属性要通过额外的 API?

这是因为 Object.defineProperty 只会对属性进行监测,而不会对对象进行监测,为了可以监测对象 Vue2 创建了一个 Observer 类。Observer 类的作用就是把一个对象全部转换成响应式对象,包括子属性数据,当对象新增或删除属性的时候负债通知对应的 Watcher 进行更新操作。

// 定义一个属性
function def(obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        writable: true,
        configurable: true
    })
}

class Observer {
    constructor(value) {
        this.value = value
        // 添加一个对象依赖收集的选项
        this.dep = new Dep()
        // 给响应式对象添加 __ob__ 属性,表明这是一个响应式对象
        def(value, '__ob__', this)
        if(Array.isArray(value)) {
           
        } else {
            this.walk(value)
        }
    }
    
    walk(obj) {
        const keys = Object.keys(obj)
        // 遍历对象的属性进行响应式设置
        for(let i = 0; i < keys.length; i ++) {
            defineReactive(obj, keys[i], obj[keys[i]])
        }
    }
}

vm.$set 的实现原理

function set(target, key, val) {
    const ob = target.__ob__
    defineReactive(ob.value, key, val)
    ob.dep.notify()
    return val
}

当向一个响应式对象新增属性的时候,需要对这个属性重新进行响应式的设置,即使用 defineReactive 将新增的属性转换成 getter/setter。

我们在前面讲过每一个对象是会通过 Observer 类型进行包装的,并在 Observer 类里面创建一个属于这个对象的依赖收集存储对象 dep, 最后在新增属性的时候就通过这个依赖对象进行通知相关 Watcher 进行变化更新。

vm.$delete 的实现原理

function del(target, key) {
    const ob = target.__ob__
    delete target[key]
    ob.dep.notify()
}

我们可以看到 vm.$delete 的实现原理和 vm.$set 的实现原理是非常相似的。

通过 vm.$deletevm.$set 的实现原理,我们可以更加清晰地理解到 Observer 类的作用,Observer 类就是给一个对象也进行一个监测,因为 Object.defineProperty 是无法实现对对象的监测的,但这个监测是手动,不是自动的。

问题3:Object.defineProperty 真的不能监听数组的变化吗?

面试官一上来可能先问你 Vue2 中数组的响应式原理是怎么样的,这个问题你也许会觉得很容易回答,Vue2 对数组的监测是通过重写数组原型上的 7 个方法来实现,然后你会说具体的实现,接下来面试官可能会问你,为什么要改写数组原型上的 7 个方法,而不使用 Object.defineProperty,是因为 Object.defineProperty 真的不能监听数组的变化吗?

其实 Object.defineProperty 是可以监听数组的变化的。

const arr = [1, 2, 3]
arr.forEach((val, index) => {
  Object.defineProperty(arr, index, {
    get() {
      console.log('监听到了')
      return val
    },
    set(newVal) {
      console.log('变化了:', val, newVal)
      val = newVal
    }
  })
})

其实数组就是一个特殊的对象,它的下标就可以看作是它的 key。

vue2-arry-defineProperty-01.png

所以 Object.defineProperty 也能监听数组变化,那么为什么 Vue2 弃用了这个方案呢?

首先这种直接通过下标获取数组元素的场景就比较少,其次即便通过了 Object.defineProperty 对数组进行监听,但也监听不了 push、pop、shift 等对数组进行操作的方法,所以还是需要通过对数组原型上的那 7 个方法进行重写监听。所以为了性能考虑 Vue2 直接弃用了使用 Object.defineProperty 对数组进行监听的方案。

问题4:Vue2 中是怎么监测数组的变化的?

通过上文我们知道如果使用 Object.defineProperty 对数组进行监听,当通过 Array 原型上的方法改变数组内容的时候是无发触发 getter/setter 的, Vue2 中是放弃了使用 Object.defineProperty 对数组进行监听的方案,而是通过对数组原型上的 7 个方法进行重写进行监听的。

原理就是使用拦截器覆盖 Array.prototype,之后再去使用 Array 原型上的方法的时候,其实使用的是拦截器提供的方法,在拦截器里面才真正使用原生 Array 原型上的方法去操作数组。

拦截器

// 拦截器其实就是一个和 Array.prototype 一样的对象。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(function (method) {
    // 缓存原始方法
    const original = arrayProto[method]
    Object.defineProperty(arrayMethods, method, {
        value: function mutator(...args) {
            // 最终还是使用原生的 Array 原型方法去操作数组
            return original.apply(this, args)
        },
        eumerable: false,
        writable: false,
        configurable: true
    })
})

所以通过拦截器之后,我们就可以追踪到数组的变化了,然后就可以在拦截器里面进行依赖收集和触发依赖了。

接下来我们就使用拦截器覆盖那些进行了响应式处理的 Array 原型,数组也是一个对象,通过上文我们可以知道 Vue2 是在 Observer 类里面对对象的进行响应式处理,并且给对象也进行一个依赖收集。所以对数组的依赖处理也是在 Observer 类里面。

class Observer {
    constructor(value) {
        this.value = value
        // 添加一个对象依赖收集的选项
        this.dep = new Dep()
        // 给响应式对象添加 __ob__ 属性,表明这是一个响应式对象
        def(value, '__ob__', this)
        // 如果是数组则通过覆盖数组的原型方法进来拦截操作
        if(Array.isArray(value)) {
          value.__proto__ = arrayMethods 
        } else {
            this.walk(value)
        }
    }
    // ...
}

在这个地方 Vue2 会进行一些兼容性的处理,如果能使用 __proto__ 就覆盖原型,如果不能使用,则直接把那 7 个操作数组的方法直接挂载到需要被进行响应式处理的数组上,因为当访问一个对象的方法时,只有这个对象自身不存在这个方法,才会去它的原型上查找这个方法。

数组如何收集依赖呢?

我们知道在数组进行响应式初始化的时候会在 Observer 类里面给这个数组对象的添加一个 __ob__ 的属性,这个属性的值就是 Observer 这个类的实例对象,而这个 Observer 类里面有存在一个收集依赖的属性 dep,所以在对数组里的内容通过那 7 个方法进行操作的时候,会触发数组的拦截器,那么在拦截器里面就可以访问到这个数组的 Observer 类的实例对象,从而可以向这些数组的依赖发送变更通知。

// 拦截器其实就是一个和 Array.prototype 一样的对象。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(function (method) {
    // 缓存原始方法
    const original = arrayProto[method]
    Object.defineProperty(arrayMethods, method, {
        value: function mutator(...args) {
            // 最终还是使用原生的 Array 原型方法去操作数组
            const result = original.apply(this, args)
            // 获取 Observer 对象实例
            const ob = this.__ob__
            // 通过 Observer 对象实例上 Dep 实例对象去通知依赖进行更新
            ob.dep.notify()
        },
        eumerable: false,
        writable: false,
        configurable: true
    })
})

因为 Vue2 的实现方法决定了在 Vue2 中对数组的一些操作无法实现响应式操作,例如:

this.list[0] = xxx

由于 Vue2 放弃了 Object.defineProperty 对数组进行监听的方案,所以通过下标操作数组是无法实现响应式操作的。

又例如:

this.list.length = 0

这个动作在 Vue2 中也是无法实现响应式操作的。

问题5:Vue3 的数据响应式原理是怎么样的?

Vue3 是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据,然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter,在 getter 里面把对当前的副作用函数保存起来,将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。

具体是副作用函数里面读取响应式对象的属性值时,会触发代理对象的 getter,然后在 getter 里面进行一定规则的依赖收集保存操作。

简单代码实现:

// 使用一个全局变量存储被注册的副作用函数
let activeEffect
// 注册副作用函数
function effect(fn) {
    activeEffect = fn
    fn()
}
const obj = new Proxy(data, {
    // getter 拦截读取操作
    get(target, key) {
        // 将副作用函数 activeEffect 添加到存储副作用函数的全局变量 targetMap 中
        track(target, key)
        // 返回读取的属性值
        return Reflect.get(target, key)
    },
    // setter 拦截设置操作
    set(target, key, val) {
        // 设置属性值
        const result = Reflect.set(target, key, val)
        // 把之前存储的副作用函数取出来并执行
        trigger(target, key)
        return result
    }
})
// 存储副作用函数的全局变量
const targetMap = new WeakMap()
// 在 getter 拦截器内追踪依赖的变化
function track(target, key) {
    // 没有 activeEffect,直接返回
    if(!activeEffect) return
    // 根据 target 从全局变量 targetMap 中获取 depsMap
    let depsMap = targetMap.get(target)
    if(!depsMap) {
       // 如果 depsMap 不存,那么需要新建一个 Map 并且与 target 关联
       depsMap = new Map()
       targetMap.set(target, depsMap)
    }
    // 再根据 key 从 depsMap 中取得 deps, deps 里面存储的是所有与当前 key 相关联的副作用函数
    let deps = depsMap.get(key)
    if(!deps) {
       // 如果 deps 不存在,那么需要新建一个 Set 并且与 key 关联
       deps = new Set()
       depsMap.set(key, deps)
    }
    // 将当前的活动的副作用函数保存起来
    deps.add(activeEffect)
}
// 在 setter 拦截器中触发相关依赖
function trgger(target, key) {
    // 根据 target 从全局变量 targetMap 中取出 depsMap
    const depsMap = targetMap.get(target)
    if(!depsMap) return
    // 根据 key 取出相关联的所有副作用函数
    const effects = depsMap.get(key)
    // 执行所有的副作用函数
    effects && effects.forEach(fn => fn())
}

通过上面的代码我们可以知道 Vue3 中依赖收集的规则,首先把响应式对象作为 key,一个 Map 的实例做为值方式存储在一个 WeakMap 的实例中,其中这个 Map 的实例又是以响应式对象的 key 作为 key, 值为一个 Set 的实例为值。而且这个 Set 的实例中存储的则是跟那个响应式对象 key 相关的副作用函数。

来看看图表表示的结构:

weakmap-set.png

那么为什么 Vue3 的依赖收集的数据结构这里采用 WeakMap 呢?

所以我们需要解析一下 WeakMap 和 Map 的区别,首先 WeakMap 是可以接受一个对象作为 key 的,而 WeakMap 对 key 是弱引用的。所以当 WeakMap 的 key 是一个对象时,一旦上下文执行完毕,WeakMap 中 key 对象没有被其他代码引用的时候,垃圾回收器 就会把该对象从内存移除,我们就无法该对象从 WeakMap 中获取内容了。

另外副作用函数使用 Set 类型,是因为 Set 类型能自动去除重复内容。

上述方法只实现了对引用类型的响应式处理,因为 Proxy 的代理目标必须是非原始值。原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined 和 null 等类型的值。在 JavaScript 中,原始值是按值传递的,而非按引用传递。这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。

Vue3 中是通过对原始值做了一层包裹的方式来实现对原始值变成响应式数据的。最新的 Vue3 实现方式是通过属性访问器 getter/setter 来实现的。

class RefImpl{
    private _value
    public dep
    // 表示这是一个 Ref 类型的响应式数据
    private _v_isRef = true
    constructor(value) {
        this._value = value
        // 依赖存储
        this.dep = new Set()
    }
	// getter 访问拦截
    get value() {
        // 依赖收集
        trackRefValue(this)
        return this._value
    }
	// setter 设置拦截
    set value(newVal) {
        this._value = newVal
        // 触发依赖
        triggerEffect(this.dep)   
    }
}

ref 本质上是一个实例化之后的 “包裹对象”,因为 Proxy 无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。 由于实例化之后的 “包裹对象” 本质与普通对象没有任何区别,所以为了区分 ref 与 Proxy 响应式对象,我们需要给 ref 的实例对象定义一个 _v_isRef 的标识,表明这是一个 ref 的响应式对象。

最后我们和 Vue2 进行一下对比,我们知道 Vue2 的数据响应式存在很多的问题,例如:

  • 初始化时需要遍历对象所有 key,如果对象层次较深,性能不好
  • 通知更新过程需要维护大量 dep 实例和 watcher 实例,额外占用内存较多
  • 无法监听到数组元素的变化,只能通过劫持重写了几个数组方法
  • 动态新增,删除对象属性无法拦截,只能用特定 set/delete API 代替
  • 不支持 Map、Set 等数据结构

而 Vue3 使用 Proxy 实现之后,则以上的问题都不存在了。

问题6:Vue3 中是怎么监测数组的变化?

我们知道在 Vue2 中是需要对数组的监听进行特殊的处理的,其中在 Vue3 中也需要对数组进行特殊的处理。在 Vue2 是不可以通过数组下标对响应式数组进行设置和读取的,而 Vue3 中是可以的,数组中仍然有很多其他特别的读取和设置的方法,这些方法没经过特殊处理,是无法通过普通的 Proxy 中的 getter/setter 进行响应式处理的。

数组中对属性或元素进行读取的操作方法。

  • 通过索引访问数组的元素值
  • 访问数组的长度
  • 把数组作为对象,使用 for ... in 循环遍历
  • 使用 for ... of 迭代遍历数组
  • 数组的原型方法,如 concat、join、every、some、find、findIndex、includes 等

数组中对属性或元素进行设置的操作方法。

  • 通过索引修改数组的元素值
  • 修改数组的长度
  • 数组的栈方法
  • 修改原数组的原型方法:splice、fill、sort 等

当上述的数组的读取或设置的操作发生时,也应该正确地建立响应式联系或触发响应。

当通过索引设置响应式数组的时候,有可能会隐式修改数组的 length 属性,例如设置的索引值大于数组当前的长度时,那么就要更新数组的 length 属性,因此在触发当前的修改属性的响应之外,也需要触发与 length 属性相关依赖进行重新执行。

遍历数组,使用 for ... in 循环遍历数组与遍历常规对象是一致的,也可以使用 ownKeys 拦截器进行设置。而影响 for ... in 循环对数组的遍历会是添加新元素:arr[0] = 1 或者修改数组长度:arr.length = 0,其实无论是为数组添加新元素,还是直接修改数组的长度,本质上都是因为修改了数组的 length 属性。所以在 ownKeys 拦截器内进行判断,如果是数组的话,就使用 length 属性作为 key 去建立响应联系。

在 Vue3 中也需要像 Vue2 那样对一些数组原型上方法进行重写。

当数组响应式对象使用 includes、indexOf、lastIndexOf 这方法的时候,它们内部的 this 指向的是代理对象,并且在获取数组元素时得到的值要也是代理对象,所以当使用原始值去数组响应式对象中查找的时候,如果不进行特别的处理,是查找不到的,所以我们需要对上述的数组方法进行重写才能解决这个问题。

首先 arr.indexOf 可以理解为读取响应式对象 arr 的 indexOf 属性,这就会触发 getter 拦截器,在 getter 拦截器内我们就可以判断 target 是否是数组,如果是数组就看读取的属性是否是我们需要重写的属性,如果是,则使用我们重新之后的方法。

const arrayInstrumentations = {}
;(['includes', 'indexOf', 'lastIndexOf']).forEach(key => {
  const originMethod = Array.prototype[key]
  arrayInstrumentations[key] = function(...args) {
    // this 是代理对象,先在代理对象中查找
    let res = originMethod.apply(this, args)

    if(res === false) {
       // 在代理对象中没找到,则去原始数组中查找
       res = originMethod.apply(this.raw, args)
    }
    // 返回最终的值
    return res
  }
})

上述重写方法的主要是实现先在代理对象中查找,如果没找到,就去原始数组中查找,结合两次的查找结果才是最终的结果,这样就实现了在代理数组中查找原始值也可以查找到。

在一些数组的方法中除了修改数组的内容之外也会隐式地修改数组的长度。例如下面的例子:

Vue3-proxy-arry-01.png

我们可以看到我们只是进行 arr.push 的操作却也触发了 getter 拦截器,并且触发了两次,其中一次就是数组 push 属性的读取,还有一次是什么呢?还有一次就是调用 push 方法会间接读取 length 属性,那么问题来了,进行了 length 属性的读取,也就会建立 length 的响应依赖,可 arr.push 本意只是修改操作,并不需要建立 length 属性的响应依赖。所以我们需要 “屏蔽” 对 length 属性的读取,从而避免在它与副作用函数之间建立响应联系。

相关代码实现如下:

const arrayInstrumentations = {}
// 是否允许追踪依赖变化
let shouldTrack = true
// 重写数组的 push、pop、shift、unshift、splice 方法
;['push','pop','shift', 'unshift', 'splice'].forEach(method => {
    // 取得原始的数组原型上的方法
    const originMethod = Array.prototype[method]
    // 重写
    arrayInstrumentations[method] = function(...args) {
        // 在调用原始方法之前,禁止追踪
        shouldTrack = false
        // 调用数组的默认方法
        let res = originMethod.apply(this, args)
        // 在调用原始方法之后,恢复允许进行依赖追踪
        shouldTrack = true
        return res
    }
})

在调用数组的默认方法间接读取 length 属性之前,禁止进行依赖跟踪,这样在间接读取 length 属性时,由于是禁止依赖跟踪的状态,所以 length 属性与副作用函数之间不会建立响应联系。

总结

本文通过一个一个问题的方式,分别解答 Vue2 和 Vue3 的响应式实现原理。

Vue2 部分

Vue2 是通过 Object.defineProperty 将对象的属性转换成 getter/setter 的形式来进行监听它们的变化,当读取属性值的时候会触发 getter 进行依赖收集,当设置对象属性值的时候会触发 setter 进行向相关依赖发送通知,从而进行相关操作。

由于 Object.defineProperty 只对属性 key 进行监听,无法对引用对象进行监听,所以在 Vue2 中创建一个了 Observer 类对整个对象的依赖进行管理,当对响应式对象进行新增或者删除则由响应式对象中的 dep 通知相关依赖进行更新操作。

Object.defineProperty 也可以实现对数组的监听的,但因为性能的原因 Vue2 放弃了这种方案,改由重写数组原型对象上的 7 个能操作数组内容的变更的方法,从而实现对数组的响应式监听。

Vue3 部分

Vue3 则是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据,然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter,在 getter 里面把对当前的副作用函数保存起来,将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。

Vue3 对数组实现代理时,用于代理普通对象的大部分代码可以继续使用,但由于对数组的操作与对普通对象的操作存在很多的不同,那么也需要对这些不同的操作实现正确的响应式联系或触发响应。这就需要对数组原型上的一些方法进行重写。

比如通过索引为数组设置新的元素,可能会隐式地修改数组的 length 属性的值。同时如果修改数组的 length 属性的值,也可能会间接影响数组中的已有元素。另外用户通过 includes、indexOf 以及 lastIndexOf 等对数组元素进行查找时,可能是使用代理对象进行查找,也有可能使用原始值进行查找,所以我们就需要重写这些数组的查找方法,从而实现用户的需求。原理很简单,当用户使用这些方法查找元素时,先去响应式对象中查找,如果没找到,则再去原始值中查找。

另外如果使用 push、pop、shift、unshift、splice 这些方法操作响应式数组对象时会间接读取和设置数组的 length 属性,所以我们也需要对这些数组的原型方法进行重新,让当使用这些方法间接读取 length 属性时禁止进行依赖追踪,这样就可以断开 length 属性与副作用函数之间的响应式联系了。

最后,其实在 Vue3 部分还有 WeakMap、Map、Set 这部分的响应式原理还没进行讲解,日后有时间再进行补写。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

读者互动补充

  1. Vue2 数组是如何收集依赖和触发依赖的?

评论区中有同学说到: 数组是如何收集依赖的那里,只说了数组是如何派发依赖的,并没有讲明白如何收集的

我的回答:

是的呢,同学真的非常认真好学呀 ~
你说的应该是 Vue2 的数组的依赖收集吧。

其实关于依赖如何收集,网上已经有太多相关文章了,只不过大家在写这一块的内容的时候,侧重点不一样,且篇幅有限,难以面面俱到。

既然你说到这个问题,那么趁此机会,在这里,我作一些补充吧,以供大家相互交流学习 ~
其实不管是对象还是数组的依赖都是在 getter 中进行依赖收集的。
例如:{ list: [1,2,3,4] }
你要获取到 list 数组的内容,首先是通过 list 这个 key 进行获取的,所以当通过 list 这个 key 进行获取数组内容的时候,就触发了 list 这个 key 的 getter。
在 getter 中会进行 key 的依赖收集,收集到的依赖保存在对应 key 的 Dep 对象中,同时也会判断,key 的值如果是一个对象,还会对这个对象进行依赖收集,收集到的依赖则保存在这个对象的 __ob__ 属性对象上的 Dep 上,而这个 __ob__ 属性对象就是上文的 "问题2" 中提到的 Observer 类,在 Observer 类中也有一个 Dep 属性用于专门保存响应式对象的依赖的。这样无论是对象还是数组数据都可以通过 __ob__ 属性拿到 Observer 实例,然后拿到 Observer 实例中的 dep。然后就可以进行相关的响应式依赖通知操作了。

  1. Vue2 对原始类型是怎么做响应式处理的呢?

因为 Vue2 不像 Vue3 那样是通过 API 的方式创建响应式数据的,所以不存在处理基本数据类型的问题。

在 Vue2 中你一定是在 data() 中设置的,而 data() 函数要求必须返回一个对象,所以一般不存在处理基本数据类型的情况。

vue2 对原始类型进行处理的情况,就只有在数组的元素存在原始类型的时候才会存在对原始类型的处理。在处理数组的响应式时,会遍历数组的每一项调用 observe 函数进行处理,而在 observe 函数中会判断如果是原始类型,则不做任何处理。
那么该项数组元素发生了改变呢?值得注意的是,在 Vue2 中通过响应式数组的下标来修改内容是不会重新渲染的,而需要通过数组的原生方法对数组内容进行修改。

  1. 数组及其子元素的依赖总是一样的吗?

过了一年趁着另一位掘友的对此问题的关注,我再详细回答一下。
数组和其子对象(子数组)元素的依赖,有可能一样,也有可能不一样。
如果数组和其子元素对象都在一个组件中进行调用的情况下他们的依赖是一样的,但如果它们的调用的地方不在同一个组件中,那么数组和其子元素对象的依赖是不一样的,例如以下的情况:

<div v-for='item in arr' :key='item.id'>  
<child-comp :data='item'>  
</div>

像这种情况,arr 的依赖是父组件,它的子元素对象 item 的依赖是子组件 child-comp

  1. 在 Vue2 中数组元素存在基本数据类型的时候是怎么处理的?

例如在 data() 中返回 { arr:[123,{a:1}] },一开始会使用 observe 函数对整个对象进行响应式处理。

image.png

接着使用 Observer 对传进来的数据进行处理。

image.png

然后在 Observer 初始化的时候会对传进来的数据进行判断是数组还是其他类型,很明显对应我们上面的例子,初始化的时候,不是数组会走第二个红框处。

image.png

接着我们来看看 walk 函数:

image.png

对应我们刚传进来的数据是 { arr:[123,{a:1}] },那么 Object.keys({ arr:[123,{a:1}] }) 得到 ['arr'],然后循环 ['arr'] 调用 defineReactive 函数对 arr 进行劫持监听。

image.png

defineReactive 中先会创建一个 dep,然后对 arr 属性的值又调用 observe 处理,然后在 observe 中又用 Observer 处理 arr 属性的值。我们知道 arr 属性的值是一个数组,所以在 Observer 初始化的时候走数组的处理分支。

image.png

如果是数组最后会调用 observeArray 方法处理数组中的每一项元素。

image.png

我们可以看到 observeArray 方法就是循环数组的内容,然后对每一项内容又采用 observe 方法处理。我们知道 arr 属性的值是一个数组 [123, {a:1}],那么该数组的第一项就是 123,那么对数组中的这种基础数据类型的处理也在 observe 方法中处理了,那么我们来看看 observe 方法是怎么处理的。

image.png

我们可以看到在 observe 方法中如果不是引用类型则不进行处理,所以上述例子中的 123 最后是不会对其进行依赖收集的。它也不存在 dep 实例之类的。

那么同时如果是引用类型那么又重头开始走流程处理了。

最后做一个总结:

在处理数组响应式的时候会遍历数组,不管数组的子元素类型是什么都会使用 observe 进行处理,在 observe 方法中会进行判断如果不是引用类型则不进行处理。

  1. 对于基本数据类型 ref 的处理方式还是 Object.defineProperty 的get()和set()完成,这说法对吗?

我们知道在 Vue2 中是通过 Object.defineProperty 方法进行显式声明属性访问器的,而在 Vue3 中是通过类的方式进行声明属性访问器的,本质都是属性访问器进行数据劫持,只是声明的方式不一样。

  1. Vue2 中对象和数组的响应式数据实现的区别

首先不管是对象还是数组它本质还是一个对象,在Vue2中每个对象都有一个自己的依赖管理的变量 __ob__,那么这个 ob 具体管理啥呢?就是这个对象的元素的新增和删除时去通知那些依赖。

那么首先要了解这个对象的依赖是什么时候收集的,在 Vue2 中一个对象的读取都必然通过属性读取的,例如:obj.list,那么当在读取这个 list 的值时,就会触发 list 这个属性的依赖收集,同时也会判断这个 list 的值如果是一个对象或者是数组,也会把当前的依赖收集到这个 list 的对象或者数组的 __ob__ 上。

那么接下来如果是对象,继续循环已有的属性进行劫持(这部分大家一般都很熟了,省略...),那么将来往这个对象上添加元素的时候,就需要通知这个对象上的依赖,也就是上面收集到对象 __ob__ 上的值,同时删除对象上的属性也是一样。

那么如果是数组的话,数组的添加元素是通过很多方法进行的,比如 push,pop,那么跟上面对象一样,新增了元素,就需要通知数组的依赖,也是通过数组上的 __ob__ 上的值

其实分析一下,不管是对象还是数组,实现思路都是一样的,只是实现方式不一样。

其实结合发布订阅模式去理解,口感更加好,从发布订阅模式的角度看,Vue2 响应式对象上的 __ob__ 变量本质就是一个订阅者存储中心。