前言
从事 B 端工作的过程中,也许会遇到一些不能使用分页的表格 + 表单场景,此种情况我们通常成为 长列表, 比如,做一些批量可视化创建数据库字段的时候。 如图: 也许我们一次要创建几百个字段,此时如果不加任何处理,页面的渲染,以及数据的修改输入时,都会变得非常卡顿。 本次会和大家一起从零完成一个高性能,可复用并且易用的表格 + 表单组件。
卡顿表现
假设我们想要一次创建 200 条数据,当我们录入这 200 条数据时,来看看渲染时需要花费多少时间。
为什么不是更多例如 1000 条?怎么会告诉你 1000 条我的电脑会卡死,渲染不出来呢
实验代码模板如下: mock 渲染 200 条数据 简单的用 nextTick 获取渲染时长,为了做对比,简单的记录了一下 js 运行时间。 可以看到渲染时间为 2736 ms ,而 js 运行时间仅仅为 1 ms , 简单说明下两个 console.log 的差异,在 JS 的 EventLoop 中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染。 第一个 console.log 的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间。 第二个 console.log 是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次 EventLoop中执行的。 通过 performace 也能看出主要耗时,在 vue 的渲染 vNode 中
卡顿原因分析
前面介绍的场景,导致页面卡顿的原因就是表格嵌套表单,dom 结构复杂,导致渲染和更新慢: 下图展示的是一个输入框单元格的 dom 结构,这种结构复杂的场景下,有几百行数据,一行 10 多个这种 dom 结构。
加入虚拟列表
面对首次渲染时的卡顿,我们可以采用常规的虚拟列表来进行优化,关于虚拟列表的原理和实现,在此我们就不赘述了,感兴趣的同学可以搜索一下,网上有很多很好的文章介绍。 我们这里使用的虚拟列表是自己实现的一个 hook,接受初始完整数据 data,容器的 class(这里默认是表格)以及行高,偏移量之类的其他属性,返回一个 ref 预设变量(用来在 vue 中实现滚动加载),以及裁剪过后的数据 visibleData。 将数据虚拟化后,表格仅渲染虚拟数据 visibleData。 新的渲染耗时如下: 可以看到 js 运行时间还是为 1 ms,渲染时间缩短为 346 ms,几百 ms 的耗时对于前端渲染几乎没有感知,提示如此巨大,那么优化就到此为止了吗?渲染性能达到了要求,但是更新以及易用性上还有很大的提升空间。
表格组件的结构
首先我们介绍一下一个外界可用的表格组件结构,实质上是一个 ElTable + 虚拟列表以及其他功能组成的 hook。外界则可以包裹 form 组件,且传入 ElTableColumn 的 slot,使用方法和原生 ElTable 类似。
数据本地化隔离
我们都知道当组件使用 props 的数据时,会受到父组件的影响,因此当我们封装一个表格组件时,尽可能的将数据与外界隔离开。这里我参照了许多 web 编辑器的实现,采用了非受控组件的形式,即组件自己维护自己的数据,外界通过 set 与 get 来设置与获取数据。保证内外的数据隔离,从而保证更新渲染时只渲染组件本区块。 具体实现是,对我们前面提供的 useInfinite hook进行改造,先更具初始数据,生成本地数据,然后增加两个抛出函数,getValue 与 setValue。 数据本地化 这里增加 $index 的原因主要是因为数据经过虚拟切割以后,需要一个属性用来定位单条数据在真实数据中的位置。 **实现 getValue 与 setValue ** 外界通过 setValue 实现数据的更新与设置,通过 getValue 获取用户表单输入更新后的真实数据,而 visibleData 则是裁剪过后我们的表格组件用来渲染的数据。setValue 与 geValue 可以有组件透传给更外界。 setVaue 和 getValue 的设计确实让内外数据进行了隔离,但这又带来一个新的问题,当用户进行表单编辑,亦或者删除/添加新数据时,其实用户一次只更改了一行数据,但 setValue 是一个整体的赋值,如果数据量很大,组件间传递也会占用一定的内存,getValue 每次获取到全部的值,如果我仅仅想获取某一条最新的数据,还需要手动取对应的 index(或者条件过滤),同理也会有内存的问题
类数组的按条更新与获取
可以注意到,在 vue 的官方文档中就有提到,Array.splice 也可以触发 vue 的更新,其次我们在更新很多数据类数据时,也会想到用 splice 取精准更新,那么我们的 InfiniteTable 是不是也能这样实现呢。 InfiniteTable 通过外界传入的初始数据 data,生成维护了一份本地的 logicData,而外界想要改变logicData 则只能通过提供的 setValue 的方法,因为 logicData 本身就是数组,所以我们完全可以把 spliceValue 做一个透传,类似下图 通过接受与 splice 一样的参数,并且最终还是调用数组的 splice 方法来进行更新,实现了 spliceValue 方法并且返回给外界组件使用。可以替代许多 setValue 的场景。 既然可以设定指定的值,那获取数据理应也可以指定获取,简单的实现 getValueByIndex ,即可 这里用 cloneDeep 主要是避免一些浅拷贝的 bug。同时尽量返回符合外界条件的数据,将 $index 删除掉。
自定义表单校验
前面提到的都是有关表格相关的处理,但我们一开始说过,针对的是表格+表单的场景,当外界包裹了表单组件时,由于虚拟列表处理后,页面渲染的 dom 数量肯定是小于真实数据的数量,这个时候如果我们直接调用 Form 组件的 validate 方法,获取的结果一定是不准确的,没有被渲染的数据,因为没有 FormItem 组件,所以无法被校验到。 因此我们需要在 InfiniteTabe 或者外层组件(包含Form组件的位置)中自定义的实现一下校验相关的方法。 首先是表单组件上的rules,依然要保留,即在可是区域内就发现错误时,就可以直接阻断用户操作,并且启用 Form 组件的交互,红框加文案提示。 其次需要我们对真实数据的每一条都执行一下我们定义的校验函数集合,对遇到的异常情况记录下来,一次性拼接成 message 返回。通过弹框展示即可。 整体校验的方法,可以参照表单组件的 validate 实现,也可以写成一个 hook,初始接受一个类似 Form 组件 rules 属性的入参,然后对外返回,validate 和 validateField 方法即可。
总结
本次主要向大家介绍了如何完整的利用虚拟列表技术实现一个可以高度复用的表格组件的技术方案。最终的组件结构应该如下 很多代码中实现的具体细节没有展示, 例如虚拟列表/自定义校验/按条更新等的代码量其实并不少,但好在只需要实现一次即可反复复用。希望能对大家有所启发。