一顿操作,我把 Table 组件性能提升了十倍
时间:2023-03-12 08:39:43
科技观察
一次操作,我将Table组件的性能提升了十倍,在渲染和交互上会有一定程度的卡顿。通常我们优化表格有两种方式:一种是分页,一种是虚拟滚动。这两种方式的优化思路是减少DOM渲染的次数。在我们公司的项目中,会选择分页的方式,因为虚拟卷轴无法正确读取行数,会出现Accessibility问题。记得2019年,我在Zoom实现了基于Vue.js前后端分离的优化方案,基于ElementUI组件库开发了ZoomUI。其中,我们在重构用户管理页面时,使用了ZoomUI的Table组件来替代旧的jQuery开发的Table组件。因为大部分场景Table组件都是分页的,所以不会有性能问题。但是在一个特殊的场景:关键词搜索,可能有200*20个结果,没有分页,而且表格有一列有checkbox,也就是可以选中一些行进行操作。当我们点击其中一行的时候,发现选中它的时间比较长,而且有明显的卡顿感,但是之前的jQuery版本并没有出现这种问题,还是比较意外的。好的技术改造一定要牺牲用户体验吗?由于在Table组件的第一次优化尝试中出现了性能问题,我们首先想到的应该是找出性能问题的原因。列显示优化首先ZoomUI渲染的DOM量比jQuery渲染的Table要多,所以第一个思考的方向就是让Table组件尽量减少DOM渲染量。20列数据通常不能完全显示在屏幕上。老jQueryTable的实现很简单,底部有一个滚动条,ZoomUI在这种可滚动的场景下支持固定左右两列,这样在左右滑动的时候,可以固定一些列为一直显示,用户体验更好,但这种实现有一定的代价。ElementUI要实现这种固定列的布局,是使用了6个table标签来实现的,那么为什么我们需要6个table标签呢?首先,为了让Table组件支持丰富的表头功能,表头和表体分别实现了一个table标签。所以对于一个表格,会有2个表格标签,再加上左边的固定表格和右边的固定表格,一共有6个表格标签。在ElementUI的实现中,左边的fixedtable和右边的fixedtable从DOM中渲染出完整的column,然后通过style来控制它们的外观和隐藏:但是这种实现是比较浪费性能的,因为没有需要渲染那么多Column,其实只需要渲染固定显示的column的DOM,然后做好高层同步即可。ZoomUI就是这样实现的,效果如下:当然,仅仅通过减少固定表格渲染的列,性能提升还不够明显。有什么办法可以继续优化柱状渲染的维度?这是从业务层面的优化。对于一个20列的表,往往关键列并不多,那么我们是否可以只渲染关键列进行第一次渲染,其他列通过配置来渲染呢?根据以上需求,我在Table组件中添加了如下功能:Table组件添加了一个initDisplayedColumn属性,通过该属性可以配置首次渲染的列,当用户修改了首次渲染的列时,会保存在前端,供下次渲染使用。这样,我们可以渲染更少的列。显然,如果要渲染的列少了,整个表格渲染的DOM数量就会减少,这也会在一定程度上提高性能。更新渲染优化当然,仅仅优化列的渲染是不够的。我们遇到的问题是,当点击某一行时,渲染卡住了。为什么会导致冻结?为了定位问题,我使用Table组件创建了一个1000*7的表格,打开Chrome的Performance面板,记录下checkbox被点击前后的性能。点击几个checkbox选择框后,可以看到如下火焰图:黄色部分是Scripting脚本的执行时间,紫色部分是Rendering占用的时间。我们再截取更新过程:然后观察JS脚本执行的CallTree,发现时间主要花在了Table组件的更新渲染上:我们发现组件渲染到vnode的时间大概是600ms;vnodepatchtoDOM花费的时间大约160ms。为什么需要这么长时间?因为checkbox被选中,其维护的选中状态数据在组件内部被修改,这个状态数据在整个组件的render过程中被访问。因此,当数据被修改时,整个组件的重新渲染。并且因为有1000*7条数据,整个表需要循环1000*7次才能创建最里面的td,整个过程时间会比较长。那么循环内部还有优化的空间吗?对于ElementUI的Table组件,还有很大的优化空间。其实优化思路主要参考了我之前写的《揭秘 Vue.js 九个性能优化技巧》里面的Localvariables技巧。比如在ElementUI的Table组件中,在渲染每一个td的时候,有这样一段代码:constdata={store:this.store,_self:this.context||this.table.$vnode.context,column:columnData,row,$index}相信很多小伙伴都是随意写代码,却忽略了内部潜在的性能问题。由于Vue.js响应式系统的设计,每次访问this.store时,都会触发响应式数据内部的getter函数执行其依赖收集。当这段代码循环1000*7次时,this.store的依赖收集会被执行7000次,造成性能上的浪费,真正的依赖收集只需要执行一次。解决这个问题其实并不难。由于Table组件中的TableBody组件是用render函数写的,所以我们可以在组件render函数的入口定义一些局部变量:render(h){const{store/*...*/}=thisconstcontext=this.context||this.table.$vnode.context}然后在渲染整个render的过程中,将局部变量作为内部函数的参数传入,从而在内部的渲染中再次渲染renderingtd访问这些变量不会触发依赖收集:rowRender({store,context,/*...othervariables*/}){constdata={store:store,_self:context,column:columnData,row,$index,disableTransition,isSelectedRow}}这样,我们修改了类似的代码,实现了在TableBody组件的渲染函数内部访问这些响应变量,并且只触发一次依赖收集的效果,从而优化了render的性能。看一下优化后的火焰图:从面积上看,好像是减少了Scripting的执行时间。我们来看一次更新所需的JS执行时间:我们发现组件渲染到vnode大约需要240ms;vnodepatch到DOM花费的时间大约是127ms。可以看出,ZoomUITable组件的渲染时间和更新时间明显少于ElementUI的Table组件。渲染时间的减少是由于响应式变量依赖收集的时间大大减少,而更新时间的减少是由于固定表渲染的DOM数量减少。从用户的角度来看,除了脚本时间,DOM更新还有渲染时间。他们共享一个线程。当然,由于ZoomUITable组件渲染的DOM较少,渲染时间也较短。手写的benchmark不是一个特别准确的benchmark,单从performancepanel测试来看,我们可以给Table组件写一个benchmark。我们可以先创建一个按钮来模拟Table组件的选择操作:
切换第二行的选中状态
更新所需时间:{{renderTime}}
然后实现这个toggleSelection函数:methods:{toggleSelection(row){consts=window.performance.now()if(row){this.$refs.table.toggleRowSelection(row)}setTimeout(()=>{this.renderTime=(window.performance.now()-s).toFixed(2)+'ms'})}}我们在点击事件的回调函数,通过window.performance.now()记录开始时间,然后在setTimeout的回调函数中根据时间差计算整个更新渲染需要的时间。由于JS执行和UI渲染占用同一个线程,所以这两个任务会在一个宏任务执行的过程中执行,setTimeout0会在下一个宏任务中加上相应的回调函数。当执行回调函数时,意味着执行完最后一个宏任务,按时间差计算性能比较准确。基于手写benchmark,得到如下测试结果:ElementUITable组件的一次更新时间约为900ms。ZoomUITable组件更新一次大约需要280ms,比ElementUI的Table组件快了大约三倍。受到v-memo的启发,经过这次优化,基本解决了文章开头提到的问题。在200*20的表格中选择一列时,没有明显的卡顿感,但是和jQuery实现的Table相比,效果还是差了点。虽然性能优化了三倍,但是我还是有一个问题:我只更新了一行数据的选中状态,但是我还是重新渲染了整个表格。在组件的渲染过程中仍然需要执行多次循环。在patch过程中,使用diff算法进行比较和更新。最近在研究Vue.js3.2v-memo的实现。看完源码,我很激动,因为我发现这个优化技巧似乎适用于ZoomUI的Table组件,尽管我们的组件库是基于Vue2版本开发的。花了一个下午,试了几次,成功了,请问是怎么做到的?别着急,我们先从v-memo的实现原理说起。v-memo的实现原理v-memo是Vue.js3.2版本新增的指令。它可以用于普通的标签或列表,与v-for结合使用。在官网文档中,有这样的介绍:v-memo只是针对性能敏感场景的针对性优化,应该很少用到。渲染v-for长列表(长度大于1000)可能是它最有用的场景:
ID:{{item.id}}-selected:{{item.id===selected}}
...morechildnodes
当组件是selected状态发生变化时,即使大部分项目没有发生变化,仍然会创建大量的VNode。这里使用的v-memo本质上意味着“只有在未选中时才更新项目,反之亦然”。这允许每个未受影响的项目重用以前的VNode并完全跳过差异比较。请注意,我们不需要将item.id包含在内存依赖数组中,因为Vue可以自动从项目的:key中推断出它。其实说白了,v-memo的核心就是复用vnodes。借助在线模板编译工具,可以看到以上模板对应的渲染函数:("p",null,"...morechildnodes",-1/*HOISTED*/)exportfunctionrender(_ctx,_cache,$props,$setup,$data,$options){return(_openBlock(true),_createElementBlock(_Fragment,null,_renderList(_ctx.list,(item,__,___,_cached)=>{const_memo=([item.id===_ctx.selected])if(_cached&&_cached.key===item.id&&_isMemoSame(_cached,_memo))return_cachedconst_item=(_openBlock(),_createElementBlock("div",{key:item.id},[_createElementVNode("p",null,"ID:"+_toDisplayString(item.id)+"-selected:"+_toDisplayString(item.id===_ctx.selected),1/*TEXT*/),_hoisted_1]))_item.memo=_memoreturn_item},_cache,0),128/*KEYED_FRAGMENT*/))}v-for-basedlist内部是通过renderList函数渲染的,我们看它的实现:functionrenderList(source,renderItem,cache,index){letretconstcached=(cache&&cache[index])if(isArray(source)||isString(source)){ret=newArray(source.length)for(leti=0,l=source.length;i
{const_memo=([item.id===_ctx.selected])if(_cached&&_cached.key===item.id&&_isMemoSame(_cached,_memo))return_cachedconst_item=(_openBlock(),_createElementBlock("div",{key:item.id},[_createElementVNode("p",null,"ID:"+_toDisplayString(item.id)+"-selected:"+_toDisplayString(item.id===_ctx.selected),1/*TEXT*/),_hoisted_1]))_item.memo=_memoreturn_item}在renderItem函数里面,一个_memo变量是maintained,用于判断是否从缓存中获取vnode的条件数组;第四个参数_cached对应item缓存对应的vnode。接下来使用isMemoSame函数判断memo是否相同,看它的实现:0;i{//renderItem实现},_cache,0),128/*KEYED_FRAGMENT*/))}所以其实listcache的vnode是保存在_cache中的,也就是instance.renderCache。那么为什么patch过程可以通过使用缓存的vnode来优化,因为patch函数在执行的时候,如果新旧vnode相同,什么都不做直接返回。constpatch=(n1,n2,container,anchor=null,parentComponent=null,parentSuspense=null,isSVG=false,slotScopeIds=null,optimized=false)=>{if(n1===n2){return}//。..}显然,由于使用了缓存的vnode,它们指向同一个对象引用,直接返回,节省了后续patch执行的时间。在Table组件中应用v-memo的优化思路很简单,就是复用缓存的vnode,这是一种用空间换取时间的优化思路。那么,我们前面提到,表组件中状态没有发生变化的行也可以从缓存中获取吗?顺着这个思路,我为Table组件设计了useMemoprop,其实就是专门用于选中列的场景。然后在TableBody组件的created钩子函数中,创建了一个用于缓存的对象:created(){if(this.table.useMemo){if(!this.table.rowKey){thrownewError('foruseMemo,row-keyisrequired.')}this.vnodeCache=[]}}这里之所以在创建的钩子函数中定义了vnodeCache,是因为它不需要成为响应式对象。还要注意,我们将使用每行的键作为缓存键,因此需要Table组件的rowKey属性。然后在渲染每一行的过程中,加入useMemo相关的逻辑:cached=this.vnodeCache[key]constcurrentSelection=store.states.selectionif(cached&&!this.isRowSelectionChanged(row,cached.memo,currentSelection)){returncached}memo=currentSelection.slice()}//渲染行,返回对应的vnodeconstret=rowVnodeif(useMemo&&columns.length){ret.memo=memothis.vnodeCache[key]=ret}returnret}这里的memo变量是用来记录选中的行数据的,同样会保存在最后的vnode中功能备忘,方便下次对比。每次渲染该行的vnode之前,都会尝试根据该行对应的key从缓存中获取;如果在缓存中存在,则使用isRowSelectionChanged判断该行的选中状态是否发生了变化;如果没有变化,则直接返回缓存的vnode。如果没有命中缓存或者行选择状态改变,它会重新渲染得到一个新的rowVnode,然后更新到vnodeCache。当然这个实现比起v-memo就没那么通用了,它只比较了行选择的状态,没有比较其他数据的变化。你可能会问,如果这一行某列的数据被修改了,但是选中状态没有改变,再使用缓存不就错了吗?这个问题确实存在,但是在我们的使用场景中,当遇到数据修改时,会向后端发送一个异步请求,然后获取新的数据来更新表数据。所以只需要观察表数据的变化,清空vnodeCache即可:watch:{'store.states.data'(){if(this.table.useMemo){this.vnodeCache=[]}}}另外,我们支持columns可选的渲染功能,当窗口发生变化时,隐藏的列也可能发生变化,所以在这两种场景下,vnodeCache也需要被清除:watch:{'store.states.columns'(){if(this.table.useMemo){this.vnodeCache=[]}},columnsHidden(newVal,oldVal){if(this.table.useMemo&&!valueEquals(newVal,oldVal)){this.vnodeCache=[]}}}的上面的实现是基于v-memo的思想,实现了表格组件的性能优化。我们从火焰图上看一下它的效果:我们发现黄色的Scriptingtime几乎没有了,再看一次更新所需的JS执行时间:我们发现组件渲染到vnode大约需要20ms;vnodepatchtoDOM耗时1ms左右,在整个更新渲染过程中大大减少了JS的执行时间。另外,我们通过了基准测试,得到了如下结果:经过优化,ZoomUITable组件的更新时间约为80ms,与ElementUI的Table组件相比,性能提升了约十倍。这个优化效果还是比较惊人的,在性能上也没有输给jQueryTable,我两年的心结也解开了。总结一下,Table表的性能提升主要体现在三个方面:减少DOM数量、优化渲染流程、复用vnodes。有的时候,我们也可以站在业务的角度去思考,做一些优化。useMemo的实现虽然还比较粗糙,但是目前已经满足了我们的使用场景,当数据量较大,需要渲染的行数和列数较多时,优化效果会更加明显。如果以后有更多的需求,就更新迭代。由于某些原因,我们公司还在使用Vue2,但这并不妨碍我学习Vue3,了解它的一些新特性的实现原理和设计思路,会让我开拓很多思路。从分析定位问题到最终解决问题,希望本文能给大家提供一些组件性能优化的思路,并应用到日常工作中。