全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2024-08-26_Vue2 重写了数组方法,你知道 Vue3 也重写了吗?

您的位置:首页 >> 新闻 >> 行业资讯

Vue2 重写了数组方法,你知道 Vue3 也重写了吗? 点击公众关注号,“技术干货”及时达!扯皮最近一直在投简历准备面试,三天时间就把官网 + boss 上的应届前端岗位全投完了,只能说前端校招这块基本上已经烂完了(92?除外),也后悔自己大学期间没敢跑出去实习积累经验,校招确实是学历不行 + 实习经历不行就寄了。 这段几天都在背八股文刷算法,到头来一个面试都没有,只有自己在不断内耗,所以不浪费时间了,后面还是自己学习技术搞项目吧?? 言归正传不扯那么多了,投了几百份简历只有一个小厂面试,正如标题所说,这个问题就是这家公司的面试官提的,个人感觉作为小厂这样延申还是有丶东西的 当时第一时间就想到霍春阳大佬在设计与实现那本书上写到关于数组的代理方式,确实重写了几个方法,但是为什么重写让我给忘完了??,赶紧下来自己总结一下 正文关于 Vue2 数组方法重写其实是一道很常见的八股文,如果有去系统背 Vue 相关面试题的话很容易就能了解到,但是自己也调研了一下很少有人提到关于 Vue3 数组的重写问题 开始之前,我们先来看瞟一眼源码打包后关于数组方法重写部分确认一下??: vue2数组重写.pngVue3数组重写.png接下来针对于 Vue2、Vue3 的数组方法重写我们分开探讨?? 浅谈 Object.defineProperty提到 Vue2 数组方法重写的时候就要先提到 Vue2 的响应式原理,提到 Vue2 的响应式原理就又要提到一个 API:Object.defineProperty 关于这个 API 具体使用方法就不再过多介绍了,不清楚的直接查看文档??: Object.defineProperty() - JavaScript | MDN (mozilla.org) 我们在这里只讨论该 API 的局限性,根据其描述可以看出它是用来自定义对象上的属性,专业些来讲就是定义属性描述符,所以其实它并没有强调数据劫持的操作,只是在属性描述符中提供了访问器描述符:get、set 而 Vue2 就是借助这两个访问器进行数据劫持实现了响应式数据,我们精简一下核心源码就是这样: functiondefineReactive(obj,key){ letval=obj[key];//??get它,set它 Object.defineProperty(obj,key,{ get(){ console.log("get操作");//依赖收集 return }, set(newValue){ val=newValue; console.log("set操作");//触发依赖 }, } functionwalk(){ constkeys=Object.keys(data); for(leti=0;ikeys.length;i++){ defineReactive(data,keys[i]); } } constdata={name:"hello" walk(); 其实按照我个人的想法来讲这种数据代理劫持并不完美,可以看到 Vue2 主要是通过在外获取了对应的 val,然后针对于该 val 变量以闭包的形式进行 get、set 操作 如果按照我对数据劫持的设想它应该是这样才对??: Object.defineProperty(obj,key,{ get(){ console.log("get操作"); returnobj[key];//? }, set(newValue){ obj[key]=newValue;//? console.log("set操作"); }, }); 但毫无疑问这种方式按照访问器的规则肯定是有问题的,比如针对于 get 操作中又进行了一次 get 操作,所以会造成无限递归爆栈,set 操作也是一样的问题 所以后续 ES6 的 Proxy 才是数据劫持的真正解决方案,这点我们放到后续再讲 Vue2 重写数组方法那问题来了,数组或者其他引用类型也可以通过该 API 劫持吗?答案是可以的,毕竟它们的本质还是对象 拿数组来讲,通过下标访问数组元素的本质也是在访问属性,所以同样能够被 get、set 访问器劫持到 但是我们要考虑数组的方法调用,它的 push、pop 等方法调用的是 Array.prototype 上的属性,也就是说要想劫持的话需要这样: constarr=[1,2,3] Object.defineProperty(data,"push",{ get(){ //dosomething... returnArray.prototype.push; }, }); 很显然它与一开始封装的数据劫持方法 defineReactive 不兼容,而且这样劫持的意义不大,想象一下我们调用 push 需要关注两个点:push 的内容、push 的结果 然而上面这种方式只能劫持到 push 属性的访问(注意劫持不到调用)其他什么都拿不到,所以自然而然不会使用这种方法,(当然在最后的总结部分有提到也可以使用该方法,但会遇到性能问题,个人认为这就是我们常说的使用 Object.defineProperty 无法劫持数组的原因??) 深入研究的话并不是劫持不到数组,而是只使用该 API 无法满足响应式系统的实现,比如 push 一个新的元素它是一个对象,那我们依然需要对该对象进行数据劫持,但现在我们连这个对象都拿不到,更别说劫持了 为了解决上面的问题, Vue2 没有选择对数组进行劫持而是选择了一个巧妙的方式:重写数组方法 首先明确一下需要对哪些方法进行重写,可以发现我们只需要针对于会修改自身数组的方法进行劫持,而像查找遍历的相关的方法正常使用就可以 数组修改自身的方法:push、pop、shift、unshift、splice、sort、reverse 源码其实很简单没多少行,就是最开始截图的部分,可以明确针对于劫持数组方法的调用会有三个操作: 调用原生的数组方法拿到结果,最后将其返回针对于插入操作获取到插入的内容,对插入的内容进行数据劫持通知依赖收集的函数执行简单画张图来感受一下重写数组的魅力: Vue2重写数组过程.png当然聪明的你一定能想到关于数组的增删还有一些歪门邪道的做法,比如直接通过索引进行设置来添加元素,以及调用 delete 关键字来删除元素,同样也适用于对象的增删 关于这一点不管是之前实现 defineReactive 还是数组重写是都无法拦截到的,直接进行修改的话由于无法拦截自然就无法触发对应的响应式流程,所以 Vue2 提供了 、delete 两个全局方法来解决这个问题 同样这两个方法的核心源码也没几行,本质就是调用数组上的 splice 方法做到添加、删除元素,由于 splice 方法已被重写,因此针对于添加的元素会被数据劫持且通知该数组收集的所有依赖函数执行 浅谈 ProxyProxy 作为 ES6 新增特性给 JS 提供了强大的代理功能,该 API 的介绍就是针对于对象的基本操作能够进行拦截,而且这里的基本操作并不局限于 get、set,大概有十几种操作,具体可以看文档??: Proxy - JavaScript | MDN (mozilla.org) 当然这里我们还是考虑响应式数据这块,依旧先使用 get、set 实现一个数据劫持的效果: constobj={ name:"test", age:"20", }; constproxy=newProxy(obj,{ get(target,key){ console.log("get操作");//track依赖收集 returntarGET@[key]; }, set(target,key,value){ tarGET@[key]=value; console.log("set操作");//trigger触发依赖 returntrue; }, }); 可以看出这种 Proxy 代理方式要比 Object.defineProperty 省事的多,最主要的区别在于 Proxy 的最小操作单元是对象,而 Object.defineProperty 最小操作单元是对象属性 这就导致了 Vue2 需要针对于某个对象还需要进行属性遍历,针对于每个属性进行 Object.defineProperty,也导致了直接添加和删除对象属性无法被劫持到 除此之外再来看这样的例子,下面通过 cdn 分别引入 Vue2 和 Vue3,我们声明一个响应式数组: !DOCTYPEhtml htmllang="en" head metacharset="UTF-8"/ metaname="viewport"content="width=device-width,initial-scale=1.0"/ titleDocument/title /head body divid="app"{{data}}/div scriptsrc="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.js"/script script //Vue2 constvm=newVue({ el:"#app", data(){ return{ data:[1,2,3,4], }, mounted(){ setTimeout(()={ this.data[2]=100; },2000); }, /script /body /html !DOCTYPEhtml htmllang="en" head metacharset="UTF-8"/ metaname="viewport"content="width=device-width,initial-scale=1.0"/ titleDocument/title /head body divid="app"{{data}}/div scriptsrc="https://cdn.bootcdn.net/ajax/libs/vue/3.3.4/vue.global.js"/script script //Vue3 Vue.createApp({ data(){ return{ data:[1,2,3,4], }, mounted(){ setTimeout(()={ this.data[2]=100; },2000); }, }).mount("#app"); /script /body /html 这个 demo 主要展示了数组元素的响应式问题,为了统一风格 Vue3 我也使用了 Options API,当数组元素是基本数据类型并手动修改数组中的元素内容时 Vue2、Vue3 有不同的效果 在视图上的效果是当数组元素都是基础数据类型 Vue3 依旧做了劫持,修改元素内容后视图上也发生了更新,而 Vue2 定时器 2s 后界面上依旧没有变化 ?? 我们打印 Vue2 中响应式数组来看看结果,可以看到定时器后数组元素发生改变,且也有对应更新视图的依赖函数,但就是没有触发更新视图: Vue2响应式数组依赖.png归根究底如果你有去研究源码的话可以发现 Vue2 针对于数组从始至终都没有进行 defineReactive,只不过给它增加了一个 observer 对象罢了,当遇到一个 value 是数组时 Vue2 会进行遍历针对于每个元素执行 defineReactive 操作,唯独数组本身没有 然而 Vue3 能够实现这一点要归功于 Proxy API,针对于一个数组代理只需要在 getter 中根据你访问的属性增加额外的判断处理逻辑即可 Vue3 重写数组方法由最开始的截图可以发现 Vue3 针对于数组方法分了两组重写: 第一组针对于查找相关的方法:includes、indexOf、lastIndexOf 第二组针对于增删相关的方法:push、pop、shift、unshift、splice 我们根据设计与实现中的讲解,分别介绍两组重写的原因?? 首先来看关于查找相关的方法,书中举了这样的例子: constobj={name:"test",age:100 constarr=[obj]; functionreactive(obj){ returnnewProxy(obj,{ get(target,key){ constres=tarGET@[key]; if(Object.prototype.toString.call(res)==="[objectObject]"){ returnreactive(res); } return }, set(target,key,value){ tarGET@[key]=value; }, } constarrReactive=reactive(arr); console.log(arrReactive.includes(obj));//false? 我抽离了响应式中代理的核心逻辑代码复现了书中的问题,其主要关键在于最后代理的数组对象通过调用 includes 方法居然返回 false,这其实不是我们想要看到的结果, 首先我们知道 Vue3 数据劫持是惰性的,因为 Proxy 本身的特性,它不需要一开始就遍历对象的属性然后对每个属性进行劫持,而是以一个对象为整体,当访问到该属性时再去进行劫持,因此如果访问该属性其 value 值是一个引用值时,才会进行递归代理 也就是代理后的对象已经不再是原来的对象了,而是一个 Proxy 对象: console.log(arrReactive[0],obj,arrReactive[0]===obj); Vue3代理对象差别.png而数组的 includes 方法底层也是帮我们遍历数组找到对应的 value,这一点我们在 getter 中打印一下 key 就能发现: functionreactive(obj){ returnnewProxy(obj,{ get(target,key){ console.log(key);//? constres=tarGET@[key]; if(Object.prototype.toString.call(res)==="[objectObject]"){ returnreactive(res); } return }, set(target,key,value){ tarGET@[key]=value; }, } includes方法调用.png它会先访问数组的 includes 属性,接着再访问 length 属性,然后开始遍历访问数组下标进行查找 关于 includes 具体执行流程可以自行查阅 ECMA262 文档??: ECMAScript? 2025 LanguageSpecification (tc39.es) 或者看设计与实现这部分的内容,霍春阳大佬已经介绍了这个整个流程 所以我们最终解决问题的方案在 includes 方法上,假如我们数组存储的全是普通对象,那经过 reactive 代理后这里的普通对象会全部变成代理对象,所以 includes 底层进行遍历的时候拿到的都是代理对象进行比对,因此才不符合我们的预期?? Vue3 对于这个问题的处理很简单,直接重写 includes 方法,先针对于代理数组中调用 includes 方法查找,如果没有找到再拿到原始数组中调用 includes 方法查找,两次查找就能完美解决这个问题 我们简单来尝试一下,首先改造原来的代理,需要增加一个 raw 字段来保存原始数据,然后只针对于 includes 方法进行重写。具体见注释,没有按照源码封装,精简下来只实现该功能: constobj={name:"test",age:20 constarr=[obj]; functionreactive(obj){ constproxyData=newProxy(obj,{ get(target,key){ letres=tarGET@[key]; //访问includes属性拦截使用我们自己重写的返回 if(key==="includes")res=includes; if(Object.prototype.toString.call(res)==="[objectObject]"){ returnreactive(res); } return }, set(target,key,value){ tarGET@[key]=value; }, //保存原始数据 proxyData.raw= returnproxyData; } //原始includes方法 constoriginIncludes=Array.prototype.includes; //重写方法 functionincludes(...args){ //遍历代理对象 letres=originIncludes.apply(this,args); if(res===false){ //代理对象找不到,再去原始数据查找 res=originIncludes.apply(this.raw,args); } return } constarrReactive=reactive(arr); console.log(arrReactive.includes(obj));//true?? 这样就解决了最开始的问题,而关于数组的查找还有 indexOf、lastIndexOf 这两个 API,统一进行重写即可,都是一样的思路 下面来看第二组重写,是针对于数组的增删方法?? 为了复现这个问题就需要回顾 Vue3 的响应式数据整体实现了,借这个机会简单复习一下依赖收集和触发依赖的过程,无非就是实现 track、trigger 函数,再提供一个 effect 的方法来触发一开始的依赖收集: !DOCTYPEhtml htmllang="en" head metacharset="UTF-8"/ metaname="viewport"content="width=device-width,initial-scale=1.0"/ titleDocument/title /head body divclass="box"/div script constboxDom=document.querySelector(".box"); constobj={name:"test",age:20 constwm=newWeakMap(); letactiveEffect=null; //触发依赖收集 functioneffect(fn){ activeEffect= fn(); } functionreactive(obj){ returnnewProxy(obj,{ get(target,key){ letres=tarGET@[key]; track(target,key);//依赖收集 return }, set(target,key,value){ tarGET@[key]=value; trigger(target,key);//触发依赖 returntrue; }, } //weakMap=Map=Set结构进行依赖收集 functiontrack(target,key){ if(activeEffect){ letmap=wm.get(target); if(!map){ map=newMap(); wm.set(target,map); } letdeps=map.get(key); if(!deps){ deps=newSet(); map.set(key,deps); } deps.add(activeEffect); activeEffect=null; } } //根据target找到对应的deps取出执行收集的副作用函数 functiontrigger(target,key){ constmap=wm.get(target); if(!map)return; constdeps=map.get(key); if(!deps)return; for(consteffectofdeps){ effect(); } } constobjProxy=reactive(obj); //手动执行副作用函数触发依赖收集 effect(()={ boxDom.textContent=objProxy.name; console.log("更改DOM内容"); /script /body /html 稍微了解一些 Vue3 响应式原理源码实现的应该都能看明白,这里只是实现了一个丐版响应式,可以直接复制到 html 里查看效果: 响应式效果.gif但假如我们去代理一个数组,然后添加一个副作用函数,该副作用函数里进行 push 操作: constarr=[1,2,3]; constarrProxy=reactive(arr); effect(()={ arrProxy.push(4); }); 这时候会发现直接就爆栈了: push爆栈.png我们来分析一下原因,主要来研究 push 操作的流程,在设计与实现中也根据了 ECMA262 文档分析其过程,这里不再过多展开,需要关注的一点当调用 push 方法时会有这个过程: 访问数组的 push 属性(getter)访问数组的 length 属性(getter)修改数组的 length 属性 +1(setter)问题就出在 length 属性上,当执行副作用函数时 getter 会进行依赖收集,而它的 setter 又会导致该副作用函数重新执行,因此就这样无限循环下去爆栈 所以 Vue3 给到的解决方案就是针对于这些内部会改动 length 属性的数组方法,会屏蔽掉 length 属性的依赖收集操作,实现方式简单粗暴,给个 flag 标志控制是否收集依赖就行,重点在于该 flag 应该在何时改变 其实就在 push 调用上,调用之前我们修改标志禁止收集,调用结束后再解开即可,而重写的过程和上面 includes 思路一样: constwm=newWeakMap(); letactiveEffect=null; //new:增加是否进行依赖收集标志 letshouldTrack=true; functioneffect(fn){ activeEffect= fn(); } functionreactive(obj){ returnnewProxy(obj,{ get(target,key){ letres=tarGET@[key]; //new:访问push属性,返回重写的方法 if(key==="push")returnpush; track(target,key); return }, set(target,key,value){ tarGET@[key]=value; trigger(target,key); returntrue; }, } functiontrack(target,key){ //new:补充新的判断是否收集依赖的逻辑 if(!activeEffect||!shouldTrack)return; letmap=wm.get(target); if(!map){ map=newMap(); wm.set(target,map); } letdeps=map.get(key); if(!deps){ deps=newSet(); map.set(key,deps); } deps.add(activeEffect); activeEffect=null; } functiontrigger(target,key){ constmap=wm.get(target); if(!map)return; constdeps=map.get(key); if(!deps)return; for(consteffectofdeps){ effect(); } } //new:重写push方法 functionpush(...args){ shouldTrack=false; constres=Array.prototype.push.apply(this,args); shouldTrack=true; return } constarr=[1,2,3]; constarrProxy=reactive(arr); effect(()={ arrProxy.push(4); }); 我们来看看源码这部分怎么实现的: Vue3源码重写部分1.pngVue3源码重写部分2.png都是一样的控制 shouldTrack 变量实现,至于为什么还用了 stack 存储个人猜测跟嵌套依赖收集有关,毕竟函数调用是栈结构嘛,我们只是实现了一个很基础的响应式,实际响应式系统的业务应用场景是比较复杂的,这里就不展开深究了 End(总结)最后我们针对于 Vue2、Vue3 这两种重写数组方法的方式进行一个总结,谈谈我的个人看法?? 首先两者要解决的问题完全不一样,其根本原因在于 Object.defineProperty 和 Proxy 的特性不同 Vue2 中使用的 Object.defineProperty 操作的最小单元是对象的属性,因此如果数组进行 push 添加新元素时,需要针对于该元素再调用 Object.defineProperty 进行劫持操作,所以需要扩展原有的 push 方法 但了解到 Vue3 的重写方式后我产生了一个疑问??, Vue2 也完全可以按照 Vue3 中的模式,针对于每个数组枚举出需要进行重写的方法,然后通过 Object.defineProerty 拦截到对应的方法名,然后返回重写的数组方法,这样就可以不使用以原型继承的方式来重写,且该方式也会避免 Vue3 针对于 length 属性造成爆栈的问题,因为就没有对 length 属性进行劫持操作 ??: 思考重写方式1.png不过很快我就打消了这个念头??,这样的做法会导致每个数组实例都需要先通过 Object.defineProperty 添加这几个需要重写的数组方法,但Vue2 中重写的方式不管有多少个数组实例,都始终只有一个中间对象来存储重写的方式,所以开销较小?? 而且在我们的认知中数组方法往往是挂载到原型上的,以这种挂载到实例上方式其实并不合适?? Vue3 中使用的 Proxy 操作的最小单元是对象,也就是说无论该对象动态添加多少个属性同样都能劫持到,因此无需考虑 Vue2 上面的问题,但这种方式同样也引出了其他问题: 第一个问题:由于 proxy 返回的是一个新的代理对象,因此如果一个数组中的元素都是引用类型,则通过代理后会发现产生的新代理对象不再是原始的引用值,这就导致数组中查找元素的方式产生问题,Vue3 就针对于这几个查找的方式进行重写,先在代理后的数组对象中查找,再去原始数组中查找,两次查找便能解决上述问题 第二个问题:由于 proxy 是对象级别的代理,那么针对于数组常用方法操作时会产生不必要的劫持属性:length 属性,比如针对于 push 方法的调用底层会进行访问 length、修改 length 两个操作,因此会导致收集的副作用函数无限循环下去造成爆栈,而 Vue3 解决方式就是避免 length 属性的依赖收集操作,通过重写对应的数组方法动态修改 flag 值,其依赖收集的 track 方法会根据该 flag 来判断是否进行收集 点击公众关注号,“技术干货”及时达! 阅读原文

上一篇:2023-05-04_没预算全部自己上!走电影节的作者电影怎么拍?《这个女人》导演专访 下一篇:2024-02-02_做了品牌才知道 , 为什么有钱也找不到代理公司

TAG标签:

19
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价