宝哥软件园

谈Vue性能优化的深挖阵列

编辑:宝哥软件园 来源:互联网 时间:2021-08-28

背景

最近,Vue被用来重构一个历史项目,一个考试系统,有大量的题目,所以核心组件的性能就成了焦点。先拍两张图看看核心部件Paper的风格。

从图中可以看出,分为回答区和选择面板区。

稍微拆解一下交互逻辑:

答题模式和学习模式可以相互切换,正确答案可以控制显示和隐藏。直接点击单项选择和真假题,记录答案的正确性;选择题是选择答案后点击确定,记录正确性。选择面板记录已完成主题的情况,分为六种状态(未完成、未完成和当前选中、错误、错误和当前选中、正确、正确和当前选中),通过不同的风格进行区分。点击选择面板,可以在回答区剪切对应的问题号。基于以上考虑,我认为我必须有三个响应数据:

当前选定主题的序列号。问题:所有问题的信息都是一个数组,维护着问题的信息,选项和每个问题的正确性。CardData:主题分组的信息也是一个数组,根据章节名称对不同的主题进行分类。数组中每个项目的数据结构如下:

CurrentIndex=0 //用于标记当前所选主题问题的索引=[{ secId: 1,//章节的ID TID 3360 1,//主题ID Content : ' Topic Content '//主题描述类型3360 1,//问题类型,1 ~ 3(单项选择、多项选择、判断)选项3360 ['选项1 ','选项2 ','选项3 ','选项4 ',] //所选每个选项的描述: [1、2、4],//多项选择——//标记当前主题是否已被应答: undefined///标记当前主题是否正确}] carddata=[{startindex : 0,//用于记录循环分组数据的起始索引,等于之前数据的长度累加。SecName: '章节名称',secId: '章节Id ',tid :[1,2,3,11] //本章下所有主题的id }]因为主题可以左右切换,所以每次我都会从问题中提取三个数据进行渲染,使用cube-ui的Slide组件,只要我使用这个. currentIndex

这一切看起来都很美好,尤其是在完成一个历史项目的核心组成部分的编写之前。

但是,转折点出现在渲染和选择面板样式的步骤中

代码的逻辑很简单,但是发生了一件让我困惑的事情。

div class=' card-content ' div class=' block ' V-for=' item in card data ' : key=' item . secname ' div class=' subtitle ' { item . secname } }/div class=' group ' span @ click=' card click(index item . startindex)' class=' item ' : class=' getitem class(index item . startindex)V-for=' item(子项,索引)。Sect ids“: key=”子项“{indexitem。startindex1}}/span/div/div/div实际上是用cardData生成DOM元素,这是分组数据(首先,章节作为维度,章节下面有对应的主题)。上面的代码实际上是

但是,只要我切换话题或者点击面板,或者触发任何有响应的数据变化,页面就会被卡住!

探索

目前第一反应肯定是js在某一步执行时间太长,所以我们用Chrome自带的Performance工具进行跟踪,发现问题出在getItemClass的函数调用上,占用了99%的时间,时间已经超过1s。瞅了眼自己的代号:

Getitem类(索引){ const ret={ }//如果是正确的主题,但ret ['item _ true']=this。问题[索引].//如果是正确的题目,并且当前选中了ret ['item _ true _ active']=this。问题[索引].//如果是错误的主题,则当前没有选中ret ['item _ false']=this。问题[索引].//如果是错误的主题,并且当前选中了ret ['item _ false _ active']=this。问题[索引].//如果是未完成的主题,但当前没有选中,ret ['item _ undo']=this。问题[索引].//如果是未完成的题目,并且ret ['item _ undo _ active']=this。问题[索引].返回ret}是当前选择的。该函数主要用于计算选择面板中每个小圆的样式。每一步都是对问题的精彩操作。乍一看好像没什么问题,但之前看了Vue的源代码,觉得不对。

首先,webpack将转换的模板。vue文件转换成一个render函数,也就是在实例化一个组件的时候,其实就是一个评估responsive属性的过程,这样responsive属性就可以将renderWatcher添加到依赖项中,这样当responsive属性发生变化的时候,就可以触发组件重新渲染。

让我们首先理解renderWatcher的概念。首先,Vue的源代码中有三种守望者。我们只看renderWatcher的定义。

//位于vue/src/core/instance/life cycle . jsnewwatcher(VM,update component,noop,{before () {if (VM。_ ismounted) {call hook (VM,'在更新之前')},true/* isRenderWatcher */)updateComponent=()={ VM。_update(vm。_render()、补水)}//位于vue/src/core/instance/render . jsvue . prototype . _ render=function(): vnode {.const {render,_parentVnode }=vm。$ options try { vnode=render . call(VM。_渲染代理,虚拟机。$ create element)} catch(e){ 0.}返回vnode}稍微分析一下下面的过程:在实例化Vue实例的时候,会去选项获取模板编译生成的render函数,然后执行renderWatcher收集依赖关系。_render返回组件的vnode,并传入_update函数来执行组件的补丁并最终生成视图。

SecO(n dly,来自我写的模板,为了渲染选择面板的DOM,有两层for循环,每个内部循环将执行getItemClass函数,内部函数将对响应的问题数组执行getter求值。目前时间复杂度为o (n),如上图所示。我们有2000多个问题。我们假设有10章,每章200个问题。问题在getItemClass中被评估六次,大概是12000个左右。按照js的执行速度,不可能这么慢。

那么,问题是不是出现在出题的过程中,O (n)的复杂度出现了?

于是,我打开了Vue的源代码,由于之前对源代码的研究比较深入,我找到了vue/src/core/instance/state.js中把数据转换成getter/setter的部分。

函数initdata (vm:组件){ 0.//observe data observe (data,true/* asrootdata */)}定义组件数据的响应,从observe函数开始,其定义位于vue/src/core/observer/index.js中。

导出函数observe (value: any,asRootData:boolean): Observer | void { if(!isObject(value)| | value instance of VNode){ return } let ob : Observer | void if(hasOwn(value,' __ob__ '值)。__ob__ instanceof Observer) { ob=值。_ _ ob _ _ _ } else if(should observe!isserveryrinthinding()(array . isarray(value)| | isplayanobject(value))object . iseextensible(value)!价值。_ isvue){ ob=new Observer(value)} if(as rootdata ob){ ob . VM count } returnob } observer函数接受一个对象或数组,并且observer类在内部实例化。

导出类别观察者{ value : any dep : depvmcount :号;构造函数(值: any){ this。value=值这个。Dep=new Dep()这个。vmcount=0 def(值,__ob__ ',this)if(数组。isarray(value)){ if(HasProto){ protault(value,Arraymmethods)} else { copy e增广(value,arrayMethods,arrayKeys)}这个。observer array(value)} else { this。walk(value)} } walk(obj : Object){ const key=Object key(key).长度;I){ definere active(obj,keys[I])} }观察者数组(items : Arrayany){ for(让i=0,l=items.lengthI lI){ 0观察者(项目我)} } }观察者的构造函数很简单,就是声明了dep、值属性,并且将价值的_ ob _属性指向当前实例。举个栗子:

//刚开始的选项导出默认{ data : { msg : }消息,arr: [1],item: { text: '文本' } }}//实例化伏特计的时候,变成了以下数据: { msg: '消息,arr: [1,__ob__: { value:dep:新dep(),vmCount:}],item: { text: '文本,__ob__: { value:dep:新dep(),vmCount:} },__ob__: { value:dep:新dep(),vmCount:}}也就是每个对象或者数组被观察之后,多了一个_ ob _属性,它是观察者的实例。那么这么做的意义何在呢,稍后分析。

继续分析观察者构造函数的下面部分:

//如果是数组,先篡改数组的一些方法(推动、拼接、移动等等),使其能够支持响应式if(数组。isaarray(value)){ if(hasProto){ proteoample(value,arrayMethods)} else { copyeample(value,arrayMethods,arrayKeys) } //数组里面的元素还是数组或者对象,递归地调用观察函数,使其成为响应式数据这个。观察者数组(值)} else {//遍历对象,使其每个键值也能成为响应式数据这个。walk(值)} walk(obj : Object){ const key=Object。用于(让I=0;长度;i ) { //将对象的键值转换成getter/setter,//getter收集依赖//setter通知看守人更新defineReactive(obj,keys[I])} }观察者数组(items : Arrayany){ for(让i=0,l=items.lengthI lI){ 0观察(项目[i]) }}我们再捋一下思路,首先在初始状态里面调用初始化数据,初始化数据得到用户配置的数据对象后调用了观察,观察函数里面会实例化观察者类,在其构造函数里面,首先将对象的_ ob _属性指向观察者实例(这一步是为了检测到对象添加或者删除属性之后,能触发响应式的伏笔),之后遍历当前对象的键值,调用定义活动去转换成吸气剂/设置剂。

所以,来分析下定义活动。

//如果是数组,先篡改数组的一些方法(推、拆分、移位等)。),这样它就可以支持响应的if(数组。ISARRAY(value)){ if(has proto){ proto enhancement(value,Arraymethods)} else { copy enhancement(value,Arraymethods,Arraykeys)}//数组中的元素是数组或对象,所以递归调用observe函数。使其成为响应数据的遍历对象。观察array(value)} else {///,并使每个键值也成为响应数据这一点。walk (value)} walk (obj3360对象){constkeys=object。keys (obj) for(让I=0;长度;I) {//将对象的键值转换为getter/setter,//getter收集依赖项//setter通知watcher更新definer active(obj,keys[I])} } observe array(items : array any){ for(让I=0,l=items.lengthI l;I) {observe(items[i]) }}首先,我们可以从defineReactive中看到,每个响应属性都有一个Dep实例,用于收集观察器。因为getter和setter都是函数,都引用dep,所以它们形成一个闭包,dep总是存在于内存中。因此,如果在渲染一个组件时使用了responsive属性A,就会达到上面的语句1,dep实例会收集组件renderwatcher,因为在对A执行setter赋值操作时,会调用dep.notify()通知renderwatcher进行更新,从而触发新一轮的Watcher进行响应数据收集。

那么语句2和3的功能是什么

我们来做个栗子分析

Div {{person}} div导出默认{ data(){返回{ person : { name : ' Zhang San,age : 18}}}此。person . gender=' male '//组件视图将不被更新。因为Vue无法检测到向对象添加属性,所以没有时间触发渲染瓦特。

为此,Vue提供了一个API,这个。$set,它是Vue.set的别名

导出函数集(target: Arrayany | Object,key: any,val : any): any { if(array . isarray(target)is validarayindex(key)){ target . length=math . max(target . length,key) target.splice(key,1,val) return val } if (key in target!(Object.prototype中的键){ target[key]=val return val } const ob=(target : any)。__ob__ if(目标。_ isVue | |(ob . vmcount)){ process . ENV . NODE _ ENV!=='production' warn('避免在运行时向Vue实例或其根$data ' '添加反应性属性-在数据选项中提前声明它。')返回val } if(!Ob) {target [key]=val return val}定义反应性(Ob。值、键、值)ob。离开notify () return val} set函数接受三个参数,第一个参数可以是Object或Array,其他参数分别是key和value。如果使用这个API给person添加一个属性呢?

这个。$set(this.person,' gender ',' male ')//组件视图重新呈现。为什么重新渲染可以由set函数触发?注意这句话,ob.dep.notify(),ob是怎么来的?你必须回到之前的观察功能。事实上,数据被观察后会变成这样。

{人物: {姓名: '张三',年龄: 18,_ _ ob _ _ : {价值:dep: new dep ()}},_ _ ob _ _ : {value3360.Dep: new Dep() }}//只要是对象,就定义__ob__属性,它是Observer类的一个实例。从模板的角度来看,视图依赖于人的属性值,renderWatcher收集在人属性的Dep实例中。它对应于由defineReactive函数定义的语句1,语句2的功能是将renderWatcher收集到person中。_ ob _。dep,所以在给person添加属性时,可以调用set方法来获取person。_ ob _。dep,然后触发renderWatcher更新。

然后得出结论,语句2的功能是触发重新呈现,以便在响应数据是对象时检测属性的添加和删除。

再举一个栗子来说明语句3的作用。

div { { books } } div export default { data(){ return { books :[{ id 33601,name3360' js'}]}}因为组件计算books,books是一个数组,所以它将转到语句3的逻辑。

If (Array.isArray(value)) {//语句3 depend array (value)}函数depend array(value : array any){ for(设e,I=0,l=value.lengthI l;I) {e=值[I]即_ _ ob _ _ e. _ _ ob _ _。离开depend () if(数组。Isarray (e)) {depend array (e)}}逻辑上,它是循环帐簿中的每个项目,如果该项目是数组或

如果没有这样的判决会怎么样?考虑以下场景:

这个。$ set (this.books[0],' comment ',' great ')//不触发组件更新。如果理解renderWatch不评估这一点。books [0],所以更改它不需要导致组件更新,那么这种理解是错误的。确切地说,因为数组是元素的集合,并且需要反映任何内部修改,所以语句3是将renderWatcher收集到每个项目项中。_ ob _。当renderWatcher评估数组时,在数组中使用dep,这样如果有任何内部更改,可以通过dep获得renderWatcher,并通知它进行更新。

然后结合我的业务代码,分析出问题出现在语句3中。

div class=' card-content ' div class=' block ' v-for=' card data ' : key=' item . secname ' div class=' subtitle ' { item . secname } }/div class=' group ' span @ click=' card click(Index item . startindex)' class=' item ' : class=' getitem class(Index item . startindex)' v-for='(subItem,Index) initem。Sect ids“: key=”子项“{indexitem。startindex 1 } }/span/div/div/div getitem class(index){ constrat={ }//如果是正确的主题,但是,ret ['item _ true']=this。问题[索引].//如果是正确的题目,ret ['item _ true _ active']=this。问题[索引].//如果是错误的题目,但是,ret ['item _ false']=this。问题[索引].//如果是错误的题目,ret ['item _ false _ active']=this。问题[索引].//如果是未完成的题目,但是ret ['item _ undo']=this。问题[索引].//如果是未完成的题目,ret ['item _ undo _ active']=this。问题[索引].ret}返回当前选中,首先cardData是分组数据,循环内部有一个循环。如果有10个章节,每个章节有200个问题,那么getitemclash函数将被执行2000次,并且问题将在getitemclash中被评估六次,并且每次都将转到dependArray。每次执行dependArray都会循环2000次,所以粗略估计2000 * 6 * 2000=2400万次。如果一次执行4条语句,也将执行近1亿次,性能自然原地爆炸!

既然已经从源头上分析了原因,就应该从源头上找出解决的办法。

拆分组件

很多人都明白拆分组件是为了重用,但当然,它的作用不止于此。拆分组件更多的是为了可维护性,可以更加语义化。当同事看到您的组件名称时,他们可能会猜测其中的功能。这里拆分组件的目的是隔离由不相关的响应数据导致的组件呈现。从上图可以看出,只要任何响应数据发生变化,Paper都会被重新渲染。例如,当我单击“收藏”按钮时,纸张组件将被重新渲染。重新渲染收藏夹按钮的DOM是合理的。

不要在嵌套循环中使用函数

性能问题的原因是我用getItemClass计算了每个小圆的样式,还在函数中求值了问题,所以时间复杂度从o (n)变成了o (n)(因为源代码的dependArray也会循环)。最终的解决方案,我放弃了getItemClass函数,直接把cardData的tids的数据结构改成tInfo,也就是在构造数据的时候,计算样式。

这个。carddata=[{startindex: 0,secname: '章节名称',secId: '章节Id ',tinfo3360 [{id3360 1,klass3360' item _ false'},{id: 2,Klass : ' item _ false _ active ' }]这样就不会出现O (n)时间复杂度的问题。

充分利用缓存

我发现我自己在getItemClass的写作非常糟糕。事实上,我应该使用一个变量来缓存问题,这样就不会多次评估问题,然后多次访问源代码的依赖关系。

常见问题=this.questions//好//坏//问题[0]这个。问题[0]//问题[1]这个。问题[1]//问题[2]这个。问题[2].//前者只会评价这个。提问一次,后者评价三次

我从这一课中学到了很多。

当遇到问题时,我们应该使用现有的工具来分析问题的原因,比如Chrome本身的Performance。

追根究底自己的技术,很庆幸之前对Vue的源代码研究透彻,可以轻松解决问题,不然还是不知所措。如果有想深入了解Vue的朋友,我可以参考Vue.js技术来揭秘。我在GitHub上看过很多源代码分析,这个应该是最全面最好的。对于这个源代码分析,我自己也提到了PR。

满足一个需求很容易,但为了获得最佳性能,成本可能会大幅增加。

以上就是本文的全部内容。希望对大家的学习有帮助,支持我们。

更多资讯
游戏推荐
更多+