网络日志

vue | vue3响应式与vue2的对比

Vue官方上说:“虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。”

所以我们知道Vue框架有借助MVVM的思想。它一个核心思想就是数据驱动。数据驱动就是指视图是由数据驱动生成的,我们若想让视图改变,要通过修改数据来进行修改,而不是像原始 JS那样直接操作DOM。

响应式?

响应式是一种侦测数据变化的机制。首先了解一下Vue是如何实现数据驱动的,这是一幅Vue官方的配图。

  • 黄色那一块是Vue负责渲染的render function,当视图初始化以及视图更新时都会调用这个render function
  • 而每次渲染时都会无法避免地触碰到我们的数据,也就是图中紫色的data部分。渲染时通过触发涉及数据的getter,把涉及数据作为依赖收集到watcher中。(而且这个watcher在每个组件实例中都会对应有一个)
  • 所以在后面我们修改这些收集到的依赖数据时,就会触发数据的settersetter方法会修改数据的值并通知(notify)给watcher,最后watcher再触发render function进行视图的重新渲染。

上面说的就是Vue实现数据驱动的过程,可以看出来实现数据驱动的关键是让我们的数据变成响应式,而gettersetter这两个方法又是让数据变成响应式的关键。但是我们写在data中的原始数据并没有对应功能的gettersetter啊?其实这就是Vue的工作:重写数据的gettersetter

Vue2

那么Vue是怎么实现数据的响应式的?首先我们来看一下Vue2的实现原理。Vue2主要是借用Object.defineProperty()来对数据的gettersetter进行重写。Object.defineProperty()的官方描述是:在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。一个简单使用例子:

let person = {}
let name = 'xiaobai'

Object.defineProperty(person, 'personName', {
    get: function () {
        console.log('触发了get方法')
        return name
    },
    set: function (val) {
        console.log('触发了set方法')
        name = val
    }
})
//当读取person对象的personName属性时,触发get方法
console.log(person.personName)

//当修改name时,重新访问person.personName发现修改成功
name = 'liming'
console.log(person.personName)

// 对person.personName进行修改,触发set方法
person.personName = 'huahua'
console.log(person.personName)

通过这种方法,我们成功监听了person上的name属性的变化。

在这个例子中,我们只监听了一个属性的变化,但是在实际情况中,我们通常需要一次监听多个属性的变化。这时候我们就需要借助Object.keys(obj)来对对象的所有属性来进行遍历。

但是如果此时只是简单地将这两个API结合,就会有问题:

let person={
    name='',
    age=0
}
Object.keys(person).forEach(function (key) {
    Object.defineProperty(person, key, {
        enumerable: true,
        configurable: true,
        // 默认会传入this
        get() {
            return person[key]
        },
        set(val) {
            console.log(`对person中的${key}属性进行了修改`)
            person[key] = val
            // 修改之后可以执行渲染操作
        }
    })
})
console.log(person.age)

运行之后发现报错了:栈溢出。这是因为当我们读取person身上的属性时,就会触发get方法,返回person[key],但是访问person[key]也会触发get方法,导致递归调用,最终栈溢出。

这时候我们需要设置一个中转Obsever,来让get中return的值并不是直接访问obj[key]。

let person = {
    name: '',
    age: 0
}
// 实现一个响应式函数
function defineProperty(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() {
            console.log(`访问了${key}属性`)
            return val
        },
        set(newVal) {
            console.log(`${key}属性被修改为${newVal}了`)
            val = newVal
        }
    })
}
// 实现一个遍历函数Observer
function Observer(obj) {
    Object.keys(obj).forEach((key) => {
        defineProperty(obj, key, obj[key])
    })
}
Observer(person)
console.log(person.age)
person.age = 18
console.log(person.age)

现在实现了对象属性的简单监听,但是如果我们想对对象进行深度监听,要怎么实现呢?其实在上述代码的基础上,加上一个递归,就可以轻松实现啦

function defineProperty(obj, key, val) {
    //如果某对象的属性也是一个对象,递归进入该对象,进行监听
    if(typeof val === 'object'){
    observer(val)
    }
    Object.defineProperty(obj, key, {
        get() {
            console.log(`访问了${key}属性`)
            return val
        },
        set(newVal) {
            console.log(`${key}属性被修改为${newVal}了`)
            val = newVal
        }
    })
}

不过同时也要在observer里面加一个递归停止的条件:

function Observer(obj) {
    //如果传入的不是一个对象,return
    if (typeof obj !== "object" || obj === null) {
        return
    }
    // for (key in obj) {
    Object.keys(obj).forEach((key) => {
        defineProperty(obj, key, obj[key])
    })
    // }

}

目前这样看上去通过Object.defineProperty()实现响应式已经差不多了,但其实通过Object.defineProperty()有一些缺陷:

  1. 由于Object.defineProperty本质是监听已存在的属性,所以其实当我们对对象新增或删除属性时无法被检测到。因为这个原因,使用vue给 data 中的数组或对象新增属性时,需要使用vm.$set才能保证新增的属性也是响应式的。其本质也是给新增的属性手动监听。
  2. 虽然Object.definePropoerty()可以做到对数组的监听(不过对pushunshift这种会增加数组索引的操作无法检测到),在Vue2中由于对性能的考虑,尤雨溪并没有选择用Object.defineProperty()来对数组进行监听,而是通过重写Array上的原型方法来对数组进行监听。

    如果你知道数组的长度,理论上是可以预先给所有的索引设置 getter/setter 的。但是一来很多场景下你不知道数组的长度,二来,如果是很大的数组,预先加 getter/setter 性能负担较大。

Vue3

可以看到,通过Object.definePorperty()进行数据监听是比较麻烦的,需要大量的手动处理。这也是为什么在Vue3.0中尤雨溪转而采用Proxy。我们先来看看Proxy

Proxy用于创建一个对象的代理,从而实现基本操作的拦截和自定义。

可以理解为:可以理解为在对象之前设置一个“拦截”,当监听的对象被访问的时候,都必须经过这层拦截。现在我们来看看Proxy是怎么解决前面Object.defineProperty()的一些缺陷的。

不需要遍历属性,提高性能

Object.defineProperty()需要遍历所有的属性,这就造成了如果vue对象的data/computed/props中的数据规模庞大,那么遍历起来就会慢很多。由于Proxy代理的是整个对象,而不像Object.defineProperty()一样是对对象的某个特定属性,不需要我们通过遍历来逐个进行数据绑定。

//定义一个需要代理的对象
let person = {
    age: 0,
    school: '西电'
}
//定义handler对象
let hander = {
    get(obj, key) {
        // 如果对象里有这个属性,就返回属性值,如果没有,就返回默认值66
        return key in obj ? obj[key] : 66
    },
    set(obj, key, val) {
        obj[key] = val
        return true
    }
}
//把handler对象传入Proxy
let proxyObj = new Proxy(person, hander)

// 测试get能否拦截成功
console.log(proxyObj.age)//输出0
console.log(proxyObj.school)//输出西电
console.log(proxyObj.name)//输出默认值66

// 测试set能否拦截成功
proxyObj.age = 18
console.log(proxyObj.age)//输出18 修改成功

不需要对对象的新增属性手动监听

这一点也和前面一样,由于Proxy代理的是整个对象,自然而然也能监听到对象新增删除属性的变化。这样就不用像使用Object.defineProperty()那样对新增属性手动添加监听了。

在深度监听上性能优化

由于Object.defineProperty()的深度监听是一次性就全部监听的,而proxy的深度监听是在真正读取/操作数据的时候才去递归的,这就起到了一个懒加载的作用,这也大大提升了性能。

不需要再对数组的原型方法重写

前面说到由于Object.defineProperty()无法对新增数组索引的操作进行监听,加上性能的考虑,vue2是通过另外对数组的重写进行操作拦截的。但是proxy可以直接对数组进行拦截,并且不需要一次性对数据全部进行监听,所以也不会造成性能上的太大影响。

Proxy支持多种拦截操作

对比起Object.defineProperty仅支持两种操作拦截,Proxy支持的操作拦截显得格外丰富,P它支持13种:

  • get(target, propKey, receiver):拦截对象属性的读取,比如proxy.foo和proxy['foo']。
  • set(target, propKey, value, receiver):拦截对象属性的设置,比如proxy.foo = v或proxy['foo'] = v,返回一个布尔值。
  • has(target, propKey):拦截propKey in proxy的操作,返回一个布尔值。
  • deleteProperty(target, propKey):拦截delete proxy[propKey]的操作,返回一个布尔值。
  • ownKeys(target):拦截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
  • getOwnPropertyDescriptor(target, propKey):拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
  • defineProperty(target, propKey, propDesc):拦截Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。
  • preventExtensions(target):拦截Object.preventExtensions(proxy),返回一个布尔值。
  • getPrototypeOf(target):拦截Object.getPrototypeOf(proxy),返回一个对象。
  • isExtensible(target):拦截Object.isExtensible(proxy),返回一个布尔值。
  • setPrototypeOf(target, proto):拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
  • apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。
  • construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)。

参考:一文搞懂 Vue3.0 为什么采用 Proxy