深入vue响应式原理(包含vue3.0)

10,538 阅读9分钟

熟悉vue的小伙伴应该都知道,谈到vue的原理,最重要的莫过于:响应式,虚拟domdiff算法,模版编译,今天,我们一起来深入vue的响应式,探讨vue2.x响应式的实现原理与不足,以及vue3.0版本如何重写响应式实现方案。

1. 什么是响应式

vue是一个MVVM框架,所谓MVVM,最核心的就是数据驱动视图,通俗一点讲就是,用户不直接操作dom,而是通过操作数据,当数据改变时,vue内部监听数据变化然后更新视图。同样,用户在视图上的操作(事件)也会反过来改变数据。而响应式,则是实现数据驱动视图的第一步,即监听数据的变化,使得用户在设置数据时,可以通知vue内部进行视图更新 比如

<template>
    <div>
        <div> {{ name }} </div>
        <button @click="changeName">改名字</button>
    </div>
</template>
<script>
export default {
    data () {
        return {
            name: 'A'
        }
    },
    methods: {
        changeName () {
            this.name = 'B'
        }
    }
}
</script>

上面代码,点击button按钮后,name属性会改变,同时页面显示的A会变成B

2. vue2.x实现响应式

2.1 核心API --- Object.defineProperty()

我想绝大多数人有了解过vue,都应该或多或少的知道一些,vue响应式的核心就是Object.defineProperty(), 这里简单做一个回顾

const data = {}
let name = 'A'
Object.defineProperty(data, 'name', {
    get () {
        return name
    },
    set (val) {
        name = val
    }
})
console.log(data.name) // get()
data.name = 'B' // set()

上面代码中我们可以看到,Object.defineProperty()的用法就是给一个对象定义一个属性(方法),并提供set和get两个内部实现,让我们可以获取或者设置这个属性(方法)

2.2 如何实现响应式

首先,我们定义一个初始数据如下

const data = {
    name: 'A',
    age: 18,
    isStudent: true,
    gender: 'male',
    girlFriend: {
        name: 'B',
        age: '19',
        isStudent: true,
        gender: 'female',
        parents: {
            mother: {
                name: 'C',
                age: '44',
                isStudent: false,
                gender: 'female'
            },
            father: {
                name: 'D',
                age: '46',
                isStudent: false,
                gender: 'male'
            }
        }
    },
    hobbies: ['basketball', 'one-piece', 'football', 'hiking']
}

我们同样定义一个渲染视图的方法

function renderView () {
    // 数据变化时,渲染视图
}

以及一个实现响应式的核心方法,这个方法接收三个参数,target就是数据对象本身,keyvalue是对象的key以及对应的value

function bindReactive (target, key, value) {
    
}

最后我们定义实现响应式的入口方法

function reactive () {
    // ...
}

我们最终调用就是

const reactiveData = reactive(data)

2.2.1 对于原始类型和对象

上面的数据,我们模拟了一个人的简单信息介绍,可以看到对象的字断值有字符串,数字,布尔值,对象,数组。对于字符串,数字,布尔值这样的原始类型,我们直接返回就好了

function reactive (target) {
    // 首先,不是对象直接返回
    if (typeof target !== 'object' || target === null) {
        return target
    }
}
const reactiveData = reactive(data)

如果字段值是对象这样的引用类型,我们就需要对对象进行遍历,分别设置对对象的每一个key值做Object.defineProperty(),注意,这个过程是需要递归调用的,因为如我们给出的数据所示,对象可能是多层嵌套的。我们定义一个函数bindReactive来描述响应式监听对象的过程

function bindReactive (target, key, value) {
    Object.defineProperty(target, key, {
        get () {
            return value
        },
        set (val) {
            value = val
            // 触发视图更新
            renderView()
        }
    })
}

function reactive (target) {
    // 首先,不是对象直接返回
    if (typeof target !== 'object' || target === null) {
        return target
    }
    // 遍历对象,对每个key进行响应式监听
    for (let key in target) {
        bindReactive(target, key, target[key])
    }
}
const reactiveData = reactive(data)

考虑到递归,我们需要在执行核心方法bindReactive开始时,递归的调用reactive为对象属性进行响应式监听,同时设置(更新)数据时候也要递归的调用reactive更新,于是我们的核心方法bindReactive变为

function bindReactive (target, key, value) {
    reactive(value)
    Object.defineProperty(target, key, {
        get () {
            return value
        },
        set (val) {
            reactive(val)
            value = val
            // 触发视图更新
            renderView()
        }
    })
}
function reactive (target) {
    // 首先,不是对象直接返回
    if (typeof target !== 'object' || target === null) {
        return target
    }
    // 遍历对象,对每个key进行响应式监听
    for (let key in target) {
        bindReactive(target, key, target[key])
    }
}
const reactiveData = reactive(data)

上面的代码可以做一步优化,就是set的时候,如果新设置的值和之前的值相同,不触发视图更新,于是我们的方法变为

function bindReactive (target, key, value) {
    reactive(value)
    Object.defineProperty(target, key, {
        get () {
            return value
        },
        set (newVal) {
            if (newVal !== value) {
                reactive(newVal)
                value = newVal
                // 触发视图更新
                renderView()
            }
        }
    })
}
function reactive (target) {
    // 首先,不是对象直接返回
    if (typeof target !== 'object' || target === null) {
        return target
    }
    // 遍历对象,对每个key进行响应式监听
    for (let key in target) {
        bindReactive(target, key, target[key])
    }
}
const reactiveData = reactive(data)

目前,我们以及实现了对于原始类型和对象的响应式监听,当数据变化时,会在数据更新后,调用renderView方法(这个方法可以做任何事情)进行视图更新。

2.2.2 对于数组

很明显,虽然Object.defineProperty()很好的完成了对于原始类型和普通对象的响应式监听,但是这个方法对数组是无能为力的。那么,vue是如何实现数组的响应式监听的呢? 我们首先再次回到vue的官方文档

可以看到,vue在执行数组的push, pop, shift, unshift等方法的时候,是可以响应式监听到数组的变化,从而触发更新视图的。

但是我们都知道,数组原生的这些方法,是不具有响应式更新视图能力的,所以,我们可以知道,vue一定是改写了数组的这些方法,于是,现在问题就从数组如何实现响应式变成了,如何改写数组的api。

这里要用到的核心方法就是Object.create(prototype),这个方法就是创建一个对象,他的原型指向参数prototype,于是,我们也可以实现对这些数组方法的改写了:

// 数组的原型
const prototype = Array.prototype
// 创建一个新的原型对象,他的原型是数组的原型(于是newPrototype上具有所有数组的api)
const newPrototype = Object.create(prototype)
const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
methods.forEach(method => {
    newPrototype[method] = () => {
        prototype[method].call(this, ...args)
        // 视图更新
        renderView()
    }
})

实现了数组的响应式,我们完善入口方法reactive

function bindReactive (target, key, value) {
    reactive(value)
    Object.defineProperty(target, key, {
        get () {
            return value
        },
        set (newVal) {
            if (newVal !== value) {
                reactive(newVal)
                value = newVal
                // 触发视图更新
                renderView()
            }
        }
    })
}
function reactive (target) {
    // 首先,不是对象直接返回
    if (typeof target !== 'object' || target === null) {
        return target
    }
     // 对于数组,原型修改
    if (Array.isArray(target)) {
        target.__proto__ = newPrototype
    }
    // 遍历对象,对每个key进行响应式监听
    for (let key in target) {
        bindReactive(target, key, target[key])
    }
}
const reactiveData = reactive(data)

到目前为止,我们已经讲述清楚了vue2.x版本的响应式原理

2.3 vue2.x版本响应式实现方案的弊端

通过我们的分析,也就看到了vue2.x版本响应式实现的弊端:

  1. Object.defineProperty()这个api无法原生的对数组进行响应式监听
  2. 实现过程中对于深度嵌套的数据,递归消耗大量性能
  3. 我们注意到,Object.defineProperty()这种实现,以及数组的实现,都存在一个问题,那就是没办法监听到后续的手动新增删除属性元素,比如数组,直接通过索引去设置和改变值是不会触发视图更新的,当然vue为我们提供了vue.setvue.delete这样的api,但终究是不方便的

3. vue3.0实现响应式

前不久vue3.0也正式发布了,虽然还没有正式的推广,不过里面的一些变化是值得我们去关注和学习的

3.1 ProxyReflect

因为vue2.x版本响应式的实现存在的那些问题,vue官方在3.0版本中完全重写了响应式的实现,改用ProxyReflect代替Object.defineProperty()

3.1.1 Proxy

首先来看MDN对Proxy的定义:

The Proxy object is used to define custom behavior for fundamental operations(e.g. property lookup, assignment, enumeration, function invocation, etc).

翻译为中文大概就是:Proxy对象用来给一些基本操作定义自定义行为(比如查找,赋值,枚举,函数调用等等) 基本用法:

let proxy = new Proxy(target, handler)

上面的参数意义:(注意target可以是原生数组)

  1. target: 用Proxy包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
  2. handler: 一个对象,其属性是当执行一个操作时定义代理的行为的函数。

举个栗子:

let handler = {
    get: function(target, name){
        return name in target ? target[name] : 'sorry, not found';
    }
};
let p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;
console.log(p.a, p.b);    // 1, undefined
console.log('c' in p, p.c);    // false, 'sorry, not found'

3.1.2 Reflect

首先来看MDN对Reflect的定义:

Reflect is a built-in object that provides methods for interceptable JavaScript operations. The methods are the same as those of proxy handlers. Reflect is not a function object, so it's not constructible.

大概意思就是说:Reflect 是一个内置的对象,提供拦截 JavaScript 操作的方法。这些方法与proxy的 handlers相同。Reflect不是一个函数对象,因此它是不可构造的。

Refelct对象提供很多方法,这里只介绍实现响应式会用到的几个常用方法:

  1. Reflect.get(): 获取对象身上某个属性的值,类似于 target[name]
  2. Reflect.set(): 将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true
  3. Reflect.has(): 判断一个对象是否存在某个属性,和 in 运算符 的功能完全相同。
  4. Reflect.deleteProperty(): 作为函数的delete操作符,相当于执行 delete target[name]。

于是,我们可以联合ProxyReflect完成响应式监听

3.2 ProxyReflect实现响应式

下面直接贴出代码,对之前我们实现的方法进行改造:

function bindReactive (target) {
    if (typeof target !== 'object' || target == null) {
        // 不是对象或数组,则直接返回
        return target
    }
    // 因为Proxy原生支持数组,所以这里不需要自己实现
    // if (Array.isArray(target)) {
    //    target.__proto__ = newPrototype
    // }
    // 传给Proxy的handler
    const handler = {
        get(target, key) {
            const reflect = Reflect.get(target, key)
            // 当我们获取对象属性时,Proxy只会递归到获取的层级,不会继续递归子层级
            return bindReactive(reflect)
        },
        set(target, key, val) {
            // 重复的数据,不处理
            if (val === target[key]) {
                return true
            }
            // 这里可以根具是否是已有的key,做不同的操作
            if (Reflect.has(key)) {
               
            } else {
            
            }
            const success = Reflect.set(target, key, val)
            // 设置成功与否
            return success 
        },
        deleteProperty(target, key) {
            const success = Reflect.deleteProperty(target, key)
             // 删除成功与否
            return success
        }
    }
    // 生成proxy对象
    const proxy = new Proxy(target, handler)
    return proxy
}
// 实现数据响应式监听
const reactiveData = bindReactive(data)

上述代码我们可以看到,对于vue2.x响应式存在的问题,都得到了很好的解决:

  1. Proxy支持监听原生数组
  2. Proxy的获取数据,只会递归到需要获取的层级,不会继续递归
  3. Proxy可以监听数据的手动新增和删除

那是不是vue3.0的响应式方案就是完美的呢,答案是否定的,主要原因在于ProxyReflect的浏览器兼容问题,且无法被polyfill

4. 总结

本文详细深入的剖析了vue响应式原理,对于2.x3.0版本的实现差异,各有利弊,没有什么方案是完美的,相信未来,当浏览器兼容问题越来越少的时候,生活会更美好!