从vue响应式再看defineProperty和Proxy

219 阅读8分钟

前言

关于Vue.js的数据响应式,vue2采用Object.defineProperty()来实现,vue3采用Proxy来实现。最近有在源码层面深入理解Vue,在尝试用代码简单实现一个MVVC框架的过程中,一些割裂的原理终于在脑海里形成了闭环,这篇文章因此诞生,希望能让大家有所收获

阅读本文前,你最好了解:

  1. Object.defineProperty()和Proxy
  2. vue响应式原理 当然,文章一开始也会简单介绍一下,帮助后续的阅读

读前回顾

Object.defineProperty()

在JS中,对象的每个属性会具有属性描述符(Property Descriptor),它是一个对象,用来描述这个属性的信息。例如value、configurable、enumerable、writable。相信你能回忆起它们的含义~

如果需要为某个对象添加属性或修改属性,配置其属性描述符,可以使用Object.defineProperty(对象, 属性名, 描述符)

属性描述符中,如果配置了getset中的任何一个,则该属性,不再是一个普通属性,而变成了存取器属性

getset配置均为函数,如果一个属性是存取器属性,则读取该属性时,会运行get方法,将get方法得到的返回值作为属性值。如果给该属性赋值,则会运行set方法

const obj = {
    b: 2
}
Object.defineProperty(obj, "a", {
    get() {
        console.log("运行了属性a的get函数")
    },
    set(val){
        console.log("运行了属性a的set函数", val)
    }
})
console.log(obj);

我们可以看到一开始a属性值看不到,点击...后看到值,并发现运行了a的get函数,修改属性值运行set函数

Proxy

代理(Proxy)是ES6新增内容。感兴趣的朋友在回忆它的时候不妨顺便复习下反射(Reflext),这里就不多赘述。代理就像打官司自己请的律师,希望下面的例子可以帮你回忆起来些什么

const obj = {
    a: 1,
    b: 2
}

const proxy = new Proxy(obj, {
    set(target, propertyKey, value) {
        // console.log(target, propertyKey, value);
        // target[propertyKey] = value;
        console.log("设置完成")
        Reflect.set(target, propertyKey, value);  // 使用一下反射,当然可以使用target[propertyKey] = value
    },
    get(target, propertyKey) {
        if (Reflect.has(target, propertyKey)) {
            return Reflect.get(target, propertyKey);
        } else {
            return "没有找到诶orz";
        }
    },
    has(target, propertyKey) {
        return false;
    }
});

proxy.a = 10;
console.log(proxy.a);

console.log(proxy.d);
console.log("a" in proxy);

vue响应式原理

image.png 我们来看官方的图片,简单的阐述一下vue响应式原理

Object.defineProperty方法遍历对象每一个属性,将其变成gettersetter(这样我们就有机会在属性被赋值和更新的时候做些什么)。render函数运行的时候用到响应式数据,收集了依赖,数据发生变化会通知Watcher,重新运行render函数

关于vue数据响应式原理,在具体实现上,vue用到了几个核心部件:ObserverDepWatcherScheduler与本文联系密切的就是Observer部件。对其余部件感兴趣的朋友可以阅读其他人的文章

简单实现

vue响应式原理我们大致也了解了,我们简单用代码写一下(只考虑数据改变我们能有所操作,不考虑vue实例之类的)

使用defineProperty

function observeObj(target) {
    const obj = {};
    for (const prop in target) {
        Object.defineProperty(obj, prop, {
            set(val) {
                console.log('set', val);  // 有机会操作
                target[prop] = val;
            },
            get() {
                console.log('get');  // 有机会操作
                return target[prop];
            }
        });
        if (target[prop] instanceof Object) {
            target[prop] = observeObj(target[prop]);  // 递归,深度遍历每一个属性
        }
    }
    return obj;
}

看到注释了吗,数据赋值和变化,会运行setget函数,从而我们可以有所操作,在vue中简单来说就是重新运行render函数,重新渲染页面,这就是响应式

使用Proxy

function observeObjByProxy(target) {
    const p = new Proxy(target, {
        set(target, prop, value) {
            console.log(target, prop, value);  // 有机会操作
            target[prop] = value;
        },
        get(target, prop) {
            console.log(target, prop);  // 有机会操作
            return target[prop];
        }
    });
    return p;
}

观察效果

我们可以

const ob = observerObjByProxy({ a: 1, b: 2, c: { d: 3 }, e: [8, 9] });`

然后在控制台输出ob,并且试着赋值观察控制台输出,感兴趣的朋友快动起小手,这里就不放图片了

两者区别

那么可能有人好奇,这两种方法有什么区别?我先把主要的列出来,然后再慢慢解释

区别definePropertyProxy
是否可监听数组变化
是否可以劫持整个对象
兼容性支持主流浏览器(IE8及以上)不支持IE

兼容性? 这个就不多扯了,Proxy出现于ES6,是晚于defineProperty的。不带IE玩才是真快乐,作为现在不用怎么考虑兼容的前端学习人真happy!

劫持整个对象? 从代码量我们就能看出来了,defineProperty要遍历到每个属性,在属性上设置。而Proxy直接传入一个待处理的对象,返回proxy对象。这样对数据的访问是动态的,当访问某个属性的时候,动态的获取和设置

我们思考一下,假如我们将响应式的对象再增加一个属性,那个新属性是响应式的吗?如果是defineProperty获得的,答案是否定的。因为我们只提前遍历了一开始有的所有属性 。如果是Proxy获得的,答案则是肯定的,我们从这个现象,可以感受到Proxy的强大,代理整个对象,同时,对动态二字也有了更深的体会

劫持数组? 这个是本篇文章最想聊的

重点就是理解这句话。还记得刚才我们的两个简单实现吗,你会发现操作defineProperty处理后的对象中的数组,下标访问与重新赋值,控制台的输出显示它仍是响应式的,那为什么说它不能劫持数组?

其实这句话针对的是数组原型上的方法!不信你可以试试push()defineProperty得到的对象就没有响应式了,而Proxy得到的对象响应式依然在!

问题来了!!先附上vue2深入响应式原理的官方文档链接:cn.vuejs.org/v2/guide/re…

看文档的关于数组部分,再顺便回忆一下自己平时的开发。你可能会发现vue2中不同的地方

  1. 我们使用数组的方法仍具有响应式!
  2. 我们的下标访问居然不行了!

让我们走向本文的最后一段!

再探vue响应式

特殊的数组

function observeObj(target) {
    const obj = {};
    for (const prop in target) {
        Object.defineProperty(obj, prop, {
            set(val) {...},
            get() {...}
        });
        if (target[prop] instanceof Object) {
            target[prop] = observeObj(target[prop]);  // 递归,深度遍历每一个属性
        }
    }
    return obj;
}

再看一遍我们之前的代码。首先我们要明确一个概念,数组本质上是对象。代码中,属性值是数组,这个数组会进入递归接着处理。而我们所谓的数组下标访问,例如arr[0],可以看成arr["0"],换成对象访问属性值就是obj["prop"],当然对象可以obj.prop

vue2中我们对象可以obj["prop"],为什么数组不可以了呢?实际上,vue2在实现响应式的时候,多了一层判断,假如属性值是数组,我们单独处理

function constructObjectProxy(vm, obj) {...}  // 像我们之前写的方法,只代理对象,不代理数组

// 对数组处理部分
const arrayProto = Array.prototype;  // 数组原型
// 重写数组方法,给我们操作的机会,从而实现响应式
function defArrayFunc(obj, func, vm) {
    Object.defineProperty(obj, func, {
        enumerable: true,
        configurable: true,
        value: function (...args) {
            let original = arrayProto[func];
            const result = original.apply(this, args);  // 调用数组原型上的方法
            // 我们可以在这个方法里进行操作,实现响应式
            return result;
        }
    })
}
// 代理数组
function proxyArr(vm, arr, namespace) {
    let obj = {  // vue自定义对象
        push() { },
        pop() { },
    }
    defArrayFunc.call(vm, obj, "push", vm);  // 调用函数重写方法
    defArrayFunc.call(vm, obj, "pop", vm);

    arr.__proto__ = obj;  // 数组的原型指向我们自定义的对象
    obj.__proto__ = arrayProto;  // 我们对象的原型指向Array原型
    return arr;
}

// 将对象变成响应式的总方法
export function constructProxy(vm, obj) {
    let proxyObj = null;

    if (obj instanceof Array) {  // 这个对象是数组
        proxyObj = new Array(obj.length);
        for (let i = 0; i < obj.length; i++) {  // 数组每一项深入去代理
            proxyObj[i] = constructProxy(vm, obj[i]);
        }
        proxyObj = proxyArr(vm, obj);  // 代理数组的方法
    }
    else if (obj instanceof Object) {
        proxyObj = constructObjectProxy(vm, obj);
    }
    else {
    }

    return proxyObj;
}

不要被这段伪代码吓到,重在理解原理。建议从最下面的constructorProxy方法读起,其中vm是vue实例,不用管它

vue2对数组的处理重在方法的重写上,然后遍历数组每一项,再对值进行深入代理,判断值是不是对象。而数组的下标0、1、2等,并没有变成存取器属性

对方法的处理也不难理解,如下图(第一次在掘金上写文章,用ppt画图了):

这下可以结合代码阅读了吧。arr.__proto__ === vue自定义对象vue自定义对象.__proto__ === Array.prototype

动态的增删

最后再聊聊我们刚才提到的 “动态”

在vue2中,我们有两个API:vm.$set()vm.$delete()。借助它们,我们就可以对已有响应式对象添加或删除属性,新的属性仍是响应式的

vm.$set()vm.$delete()不要滥用,很有可能造成性能上的影响,具体原因,我可能会在之后可能出现的响应式原理文章上出现

vue3中我们使用了Proxy,它能监控到属性的新增和删除。这就方便多了。我们看vue3的API文档也会发现少了不少内容,“罪魁祸首”就是Proxy了。原先过分的暴露一些内部的方法,这是很不好的,vue3里将这个问题解决了

关于vue2与vue3的区别,在这里再挖个坑,嘿嘿

后记

感谢你看到了这里,这是我在掘金发表的第一篇博客。希望能对大家有所帮助

我的期盼就是我写的每一篇文章,尽可能的通俗易懂、循序渐进,无论是新加入前端的朋友,还是前辈大牛,读完后都能有所思考