Code前端首页关于Code前端联系我们

Vue3为什么要弃用Object.defineProperty改用Proxy?这俩到底有啥不一样?

terry 9小时前 阅读数 147 #Vue
文章标签 ProxydefineProperty

最近很多转Vue3的前端小伙伴会发现,原来在Vue2里天天打交道的数据响应式核心原理“Object.defineProperty”,居然被Vue3彻底换成了“Proxy”,刚接触的时候可能会觉得只是换了个API的名字,写代码的方式没太变?但深入一点写,比如给对象动态加新属性、给数组改长度或者用下标直接改,不用再像Vue2那样手动调$set、$delete,还有更复杂的情况比如嵌套了好几层的对象、集合类(Map、Set这些),Vue3的响应式居然都能自动生效,不用提前处理,这背后到底藏着Proxy什么厉害的地方?今天咱们就好好掰扯清楚这俩东西的区别,还有Vue3弃旧用新的真正原因,不是跟风,是真的解决了Vue2一堆让人头疼的痛点问题。

先搞懂基础:Vue2的响应式是怎么靠Object.defineProperty干活的?

要说清楚Proxy的好,得先看看Object.defineProperty的局限,没有对比就没有伤害嘛,那咱们先回忆一下,或者简单说下新手能懂的Vue2响应式逻辑——其实就是把一个普通对象变成“响应式对象”的过程,核心是拦截属性的读取和修改操作。

Vue2会在初始化组件data的时候,遍历data里的所有属性(包括嵌套对象的每一层),给每一个属性都用Object.defineProperty重新定义一遍getter和setter,getter的作用是当代码读取这个属性的时候,比如模板里用了{{ name }},就悄悄把用这个属性的地方(也就是“依赖”,可以理解成哪个模板、哪个计算属性或者watch用到了它)存下来;setter的作用是当代码修改这个属性的时候,比如写this.name = '新名字',就把之前存的依赖都找出来,通知它们更新视图或者重新计算。

哦对了,数组的处理有点特殊,因为如果给数组的每一个下标都用Object.defineProperty定义,性能会特别差——比如一个有1000个元素的数组,得遍历1000次,Vue2觉得这没必要,所以它单独处理了数组的7个常用方法:push、pop、shift、unshift、splice、sort、reverse,这些方法被Vue2“重写”了,底层还是调用原来的数组方法,但调用前后会加一点逻辑:调用前先拿到数组的依赖,调用后触发这些依赖的更新。

那这个时候,你如果直接用下标改数组,比如this.list[0] = '新元素',或者改数组的length,比如this.list.length = 5,因为Object.defineProperty没给数组的下标和length属性定义过getter和setter(下标没遍历到,length没处理),所以Vue2根本不知道数据变了,视图也就不会更新,这时候就得用$set或者splice手动触发,这就是咱们之前常说的“Vue2数组的坑”。

除了数组的坑,Object.defineProperty还有别的问题吗?当然有,咱们后面说Proxy的时候再对比,现在先把基础逻辑记牢:Vue2是逐个属性拦截,不管对象还是数组,处理的时候得先“递归遍历+提前定义”,漏掉的就响应式失效。

再来聊核心:Proxy到底是什么?它的原理和Object.defineProperty有啥本质区别?

Proxy,中文翻译叫“代理”,这个名字其实特别形象——它不是直接修改原来的对象,而是给原来的对象“套”了一层代理层,然后我们所有对原来对象的操作(读取属性、修改属性、添加新属性、删除属性、遍历对象、甚至是调用函数、访问原型链这些操作),都必须先经过这层代理层,代理层想拦截哪个操作,就拦截哪个操作,拦截到之后可以做任何你想做的事,比如存依赖、通知更新、打印日志、修改参数、甚至是拒绝操作。

哦对了,Proxy是ES6(也就是ECMAScript 2015)才新增的原生API,之前没有,所以Vue2的时候浏览器还没普遍支持ES6的话,就没法用,这也是为什么Vue2只能用Object.defineProperty的一个小原因,但不是主要原因,主要原因还是它俩的能力差异。

那和Vue2的Object.defineProperty对比,本质区别到底在哪? 第一个本质区别:拦截的范围不一样,Object.defineProperty只能拦截“已有属性”的“读取和修改”两个操作,刚才说过了,漏一个属性都不行,比如你动态给对象加一个新属性,它根本没被定义过getter和setter,自然就响应式失效了,这时候就得用$set;删除属性也是一样,Object.defineProperty没定义“删除属性”的拦截,所以用delete删除属性,Vue2也不知道,视图不会变,得用$delete,但Proxy不一样,它拦截的是整个对象的所有操作,不管你是读取已有属性、修改已有属性、加新属性、删旧属性、遍历对象、访问Symbol属性、甚至是操作数组的下标和length,都能直接被代理层拦截到,不用提前递归遍历所有属性,也不用提前重写方法,自然就没有$set和$delete的必要了。 第二个本质区别:处理嵌套对象的方式不一样,Vue2是在初始化的时候就递归遍历嵌套对象的每一层,给每一个属性都定义getter和setter,不管你后来会不会用到这些嵌套属性——比如你的data里有个user对象,user对象里又有个address对象,address对象里又有个city属性,就算你整个应用从来没用到过city,Vue2在初始化的时候还是会递归到city那一层,给它定义好,这就导致了初始化阶段的性能浪费,尤其是当你的data里有非常大、嵌套非常深的对象的时候,但Proxy不一样,它是懒拦截——也就是只有当你真正访问到嵌套对象的时候,才会给那个嵌套对象再套一层代理层,比如你第一次访问user.address的时候,代理层发现user.address是个普通对象,不是响应式的,就立刻给它套一层Proxy,变成响应式的,之后你再访问user.address.city,就会触发address代理层的getter,再给city套层代理?不对,city如果是个基本数据类型(比如字符串、数字、布尔值),就不需要套代理了,只有对象、数组、集合这些引用类型才需要套,这种懒拦截的方式,就大大减少了初始化阶段的性能消耗,尤其是大对象、深嵌套的场景。 第三个本质区别:能处理的数据类型不一样,Object.defineProperty只能处理“普通对象”和“数组”的部分功能,集合类(Map、Set、WeakMap、WeakSet)这些ES6新增的数据结构,它根本没法处理——比如你在Vue2的data里放个Map,往里面加个键值对,Object.defineProperty根本拦截不到,视图肯定不会更新,但Proxy不一样,它是原生支持所有引用类型的代理的,不管是普通对象、数组、函数、Date、RegExp,还是Map、Set这些集合类,都能直接套上Proxy,所有操作都能被拦截,所以Vue3的响应式现在能完美支持这些集合类了,不用再自己封装一堆东西。

那Vue3用了Proxy之后,具体解决了Vue2哪些让人头疼的痛点?

刚才讲本质区别的时候其实已经提到了一些,咱们现在把这些痛点整理得更具体、更贴近实际开发场景一点,这样转Vue3的小伙伴一看就知道“哦,原来这个烦了我好久的问题,现在终于解决了”。

动态给对象加新属性、删旧属性,必须用$set、$delete

这个应该是Vue2开发里最常见的坑了吧?比如你在data里初始化了一个user对象,只有name和age两个属性,后来通过接口拿到了用户的gender属性,你直接写this.user.gender = '女',结果视图里的{{ user.gender }}根本不显示,你得改成this.$set(this.user, 'gender', '女');后来用户说不想显示年龄了,你直接写delete this.user.age,结果视图里的{{ user.age }}还在,你得改成this.$delete(this.user, 'age'),很多新手刚学Vue2的时候,都会在这上面踩好几次坑,就算是老手,有时候也会不小心忘写$set或者$delete,然后找半天bug。

但在Vue3里,完全不用这个麻烦了!比如你用ref或者reactive定义了一个user对象,直接this.user.gender = '女',或者delete this.user.age,视图都会自动更新,因为Proxy拦截到了“添加属性”和“删除属性”的操作,会自动触发依赖更新。

直接用下标改数组、改数组length,视图不更新

这个也是Vue2的经典坑,刚才也提到过,比如你在data里初始化了一个list数组,是['a', 'b', 'c'],后来你想把第一个元素改成'x',直接写this.list[0] = 'x',视图里还是显示'a',你得改成this.$set(this.list, 0, 'x')或者this.list.splice(0, 1, 'x');后来你想把数组长度改成2,直接写this.list.length = 2,视图里还是显示三个元素,你得改成this.list.splice(2)。

但在Vue3里,这些操作都没问题了!比如你用ref或者reactive定义了一个list数组,直接this.list[0] = 'x',或者this.list.length = 2,视图都会自动更新,因为Proxy拦截到了“修改数组下标”和“修改数组length”的操作,会自动触发依赖更新。

初始化大对象、深嵌套对象时,性能浪费

刚才讲懒拦截的时候也提到了,比如你在Vue2的data里放了一个从接口拿到的超级大的商品列表,每个商品对象里又嵌套了商品详情、商品评价、商品规格这些对象,商品规格里又嵌套了颜色、尺寸这些对象,就算你整个应用在首页只用到了商品的id、name、price三个属性,Vue2在初始化的时候还是会递归遍历所有商品的所有嵌套属性,给每一个属性都定义getter和setter,这就会导致首页的加载时间变长,用户体验不好,尤其是在低端手机或者网络慢的情况下,这个问题会更明显。

但在Vue3里,这个问题就好多了!因为Proxy是懒拦截的,只有当你真正访问到某个嵌套属性的时候,才会给那个引用类型套上代理层,比如首页只用到了商品的id、name、price,那Vue3只会给商品列表套一层代理,然后给每个商品对象套一层代理,然后当你访问某个商品的id、name、price的时候,因为它们是基本数据类型,就不会再套代理了,这样初始化阶段的性能消耗就大大减少了,首页加载速度也会变快。

没法直接支持ES6的集合类(Map、Set、WeakMap、WeakSet)

比如你在Vue2里想做一个多选框组件,用Set来存选中的id,因为Set自动去重的特性特别适合这个场景,但你直接在data里放个Set,往里面加id或者删id,视图根本不会更新,你得自己封装一个响应式的Set,比如重写add、delete、clear这些方法,在调用前后触发依赖更新,这就很麻烦,而且封装不好的话还容易出bug。

但在Vue3里,完全不用自己封装了!比如你用ref或者reactive定义了一个selectedIds的Set,直接selectedIds.add(1),或者selectedIds.delete(2),视图都会自动更新,因为Proxy原生支持这些集合类的代理,所有操作都能被拦截,自动触发依赖更新。

对函数、Date、RegExp这些特殊引用类型的支持不好

比如你在Vue2里想存一个响应式的Date对象,修改它的年份,比如this.date.setFullYear(2025),结果视图根本不会更新,因为Object.defineProperty只能拦截Date对象属性的读取和修改,没法拦截Date对象的方法调用,你得自己封装一个响应式的Date,比如重写setFullYear、setMonth这些方法,或者每次修改Date的时候,都重新赋值一个新的Date对象,比如this.date = new Date(2025, this.date.getMonth(), this.date.getDate()),这也很麻烦。

但在Vue3里,这个问题也解决了!比如你用ref或者reactive定义了一个date对象,直接this.date.setFullYear(2025),视图都会自动更新吗?等下,这里有个小细节,得说清楚:如果是用reactive定义的Date对象,Proxy确实能拦截到方法调用吗?不对,其实Date、RegExp这些内置对象,它们的内部状态是存储在内部槽里的,不是存储在公开属性里的,所以如果直接给它们套一层Proxy,调用它们的方法的时候,内部槽可能会找不到,从而导致报错或者不生效,那Vue3是怎么解决这个问题的?哦对了,Vue3的reactive内部对这些内置对象做了特殊处理,比如Date对象,Vue3会给它套一个自定义的代理层,当调用setFullYear、setMonth这些修改内部状态的方法的时候,会自动触发依赖更新,当调用getFullYear、getMonth这些读取内部状态的方法的时候,也会自动收集依赖,所以你在Vue3里用reactive定义一个Date对象,直接调用它的修改方法,视图是会自动更新的,不用再自己封装或者重新赋值了。

那Proxy有没有什么缺点?为什么Vue2的时候不用?

刚才说了一堆Proxy的优点,好像它是完美的,但其实它也有一个小小的缺点,还有一个之前的限制,咱们也得客观地说一下,不能只吹它的好。

缺点:没法完全兼容低版本浏览器

Proxy是ES6才新增的原生API,那低版本浏览器肯定不支持啊,比如IE11及以下的浏览器,完全不支持Proxy,而且Polyfill(也就是浏览器垫片,用来给低版本浏览器补新API的)也没法完全模拟Proxy的所有功能——因为Proxy是底层的API,它的拦截能力太强了,很多操作Polyfill根本模拟不出来,比如拦截函数的调用、拦截原型链的访问、拦截集合类的操作这些。

那为什么Vue2的时候不用?哦,刚才已经提到过了,就是因为之前浏览器的兼容性问题——在Vue2刚出来的时候(大概2016年),ES6还没普及,IE11还有很多用户,要是用了Proxy,IE11的用户就完全用不了了,所以Vue2只能用兼容性更好的Object.defineProperty,但现在不一样了,IE11已经基本退出历史舞台了,国内的话,比如微信浏览器、支付宝浏览器、各大国产浏览器的最新版本,还有Chrome、Firefox、Safari这些主流浏览器的最新版本,都已经完全支持Proxy了,所以Vue3就果断弃旧用新了。

那如果你的项目确实需要兼容IE11怎么办?哦,那你还是得用Vue2,或者用Vue3的“Legacy Build”——也就是兼容低版本浏览器的版本,但Legacy Build的响应式核心还是用的Object.defineProperty,所以它还是有Vue2的那些痛点,比如需要用$set、$delete,没法直接用下标改数组,没法直接支持集合类这些,相当于你用了Vue3的语法,但用的还是Vue2的响应式核心,有点“换汤不换药”的感觉,所以如果必须兼容IE11的话,还是直接用Vue2更稳妥。

那在Vue3里,Proxy是怎么和ref、reactive配合工作的?

刚才咱们一直在说Proxy的原理和优点,那它在Vue3的实际开发里是怎么用的?其实咱们平时写代码的时候,不会直接用new Proxy()来创建响应式对象,而是用Vue3提供的两个API:ref和reactive,这两个API的底层其实都是用Proxy实现的,咱们简单说一下它们的底层逻辑,这样你能更深入地理解Vue3的响应式。

先说reactive

reactive是用来给引用类型(普通对象、数组、集合类这些)创建响应式对象的,它的底层逻辑就是直接给传入的引用类型套一层Proxy,然后通过Proxy的getter和setter来收集依赖和触发更新,哦对了,刚才讲懒拦截的时候也提到了,如果传入的引用类型里还有嵌套的引用类型,reactive不会在初始化的时候就给它们套Proxy,只有当你真正访问到它们的时候,才会给它们套,这样就减少了性能消耗。

再说ref

ref是用来给基本数据类型(字符串、数字、布尔值、null、undefined、Symbol这些)和引用类型都能创建响应式对象的,那为什么基本数据类型也能创建响应式对象?因为Proxy只能代理引用类型,不能代理基本数据类型,所以ref的底层逻辑是:把传入的值包装成一个“普通对象”,这个普通对象只有一个value属性,然后给这个普通对象套一层Proxy?不对,其实ref的底层逻辑是用了Object.defineProperty吗?哦,不对,刚才讲了Vue3的响应式核心是Proxy,那ref是怎么处理基本数据类型的?等下,咱们可以看一下Vue3的源码(大概的逻辑,不用看具体代码):其实ref创建的是一个RefImpl类的实例,这个实例有一个value属性,还有一个dep属性(用来存依赖的),当你读取ref.value的时候,会收集依赖;当你修改ref.value的时候,会触发依赖更新,那这个收集依赖和触发更新的逻辑,是不是用了Proxy?哦,不对,RefImpl类的实例是个普通对象,它的value属性是用Object.defineProperty定义的吗?还是说Vue3的响应式有一套统一的依赖收集和触发更新的机制,不管是reactive用的Proxy,还是ref用的RefImpl,都会调用这套机制?哦对,Vue3的响应式有一套核心的API:track(用来收集依赖)和trigger(用来触发更新),不管是reactive的Proxy的getter里,还是ref的RefImpl的value的getter里,都会调用track;不管是reactive的Proxy的setter里,还是ref的RefImpl的value的setter里,都会调用trigger,所以其实ref的底层不是直接用Proxy,但是它和reactive用的是同一套依赖收集和触发更新的机制,而且如果ref传入的是引用类型,那ref.value其实就是用reactive包装过的响应式对象,所以ref也能享受到Proxy的所有优点。

写在最后:Vue3用Proxy是技术进步的必然选择

说了这么多,咱们可以总结一下:Vue3弃用Object.defineProperty改用Proxy,不是为了炫技,也不是为了跟风,而是因为Proxy真的解决了Vue2一堆让人头疼的痛点问题,比如动态加删属性不用$set、$delete,直接用下标改数组不用splice,初始化大对象性能更好,能直接支持ES6的集合类和特殊引用类型这些,而且现在浏览器的兼容性问题已经基本解决了,所以Vue3用Proxy是技术进步的必然选择。

对于转Vue3的小伙伴来说,不用太担心Proxy的原理有多复杂,其实平时写代码的时候,只要记住用ref和reactive来定义响应式数据就行,不用直接用new Proxy(),但了解一下Proxy的原理,能帮助你更好地理解Vue3的响应式,避免踩一些不必要的坑,比如不要把reactive定义的响应式对象直接解构赋值给普通变量,不然会失去响应式(这个坑也是因为Proxy的原理,解构赋值相当于把响应式对象的属性值取出来,赋值给普通变量,普通变量没有Proxy的代理层,自然就响应式失效了,要是想解构赋值还保持响应式,可以用toRefs或者toRef)。

好了,今天的分享就到这里,希望能帮助你更好地理解Vue3的Proxy和响应式原理,要是还有什么疑问,欢迎在评论区留言讨论哦。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门