前言
最近的一次功能开发中,使用Ant Design的动态表单项Form.List时,当表单项达到一定量级时出现了性能问题,最后借助虚拟列表技术将其解决,在此记录一下。
问题定位
问题来源于内部自研的用户调研问卷管理平台,根据使用方反馈,当设置的问卷题目数量达到一定量级时,页面出现卡顿甚至崩溃、失去响应现象。
下面有一个简化的在线demo复现这一场景:
可以看到,当问卷题目数量达到1000的时候,页面加载时间非常漫长,此外在操作表单项的时候也能感觉有明显的卡顿。而使用方希望平台能够支持设置的题目上限是2000,显然现在的平台是不符合要求的(至于真的会有人有耐心填2000道问题嘛🤔,咱也不敢问,咱也不敢说😂)
话不多说,直接开干吧。首先透过现象可以得到一个初步的推论,页面崩溃的情况很大可能是内存占用过大导致的。打开Chrome开发者工具,切换到Memory面板,可以看到当前标签页内存占用达到了1GB以上,而浏览器对每个标签页都有内存限制(参见 Dealing with Memory Limitations)
我们当然可以使用内存快照来分析,但这里强烈安利一下Lighthouse——这个Chrome开发者工具自带的组件:
Lighthouse可以帮助我们从多个指标分析当前网站的常见问题,并给出相应的建议(划重点)。这里我们只关注Performance(性能)这一指标,因此只需勾选这一项,可以减少分析的时间,点击Generate report便可生成报告。(为了节省时间,可以将题目数量设为100来分析)
在Diagnostics(诊断)一栏,可以找到我们的问题以及解决方案:
可以看到,过多DOM元素对内存性能有很大影响,同时也会影响加载性能与运行时性能(Avoid an excessive DOM size)。Lighthouse也检测到我们使用了React,推荐我们使用“windowing”库如react-window来最小化DOM节点的数量。
解决流程
1. 引入虚拟列表
windowing,也就是我们常说的虚拟列表或虚拟滚动,简单的定义就是,只渲染长列表数据在可视区域内的数据,从而避免一次性加载所有DOM节点。其原理不是本文的重点,在此就不再展开说。可以看到,antd的Form.List在呈现效果上也是列表的形式,因此理论上虚拟列表可以适用。为了方便,后文使用列表代指虚拟列表,使用列表项代指Form.List内的每项元素。
秉持不重复造轮子的原则,直接引入react-window,这是一个被广泛使用的虚拟滚动实现库。react-window提供了FixedSizeList、VariableSizeList、FixedSizeGrid、VariableSizeGrid这几个组件,根据具体的使用场景选择,此处有使用示例:react-window.now.sh
由于Form.List支持动态增减项,相应地,列表项数量也不是固定的,因此选择VariableSizeList组件作为虚拟列表外层组件。VariableSizeList有几个必须指定的属性:
itemCount列表项数量height列表高度width: 列表高度itemSize函数,返回列表项尺寸,对于纵向列表,返回列表项高度
列表项数量即Form.List表单项的数量,取fields.length即可。列表尺寸,以及列表项尺寸该如何确定呢,往下看👇
2. 解决尺寸问题
对于列表容器的宽高,我们希望不是固定的,而是100%填充父容器。可以使用react-virtualized-auto-sizer这个库,这个是react-window作者推荐使用的搭配(当然这两个库的作者都是同一人)。
列表的尺寸确定下来了,那列表项的呢?可以看到,在本案例中每个题目的选项数量是不固定的,列表项内容高度也并非固定的,因此需要动态计算高度。好在itemSize属性是一个函数,接收一个列表项索引参数。利用这一点,可以存储每一项列表项的高度,通过索引返回。
const rowHeights = useRef({});
const getRowHeight = (index) => {
return rowHeights.current[index] || 200;
};
这里需要注意的是react-window是先确定itemSize再渲染列表项,因此需要给一个预估的高度值(此处设为200),否则react-window无法计算出哪些列表项需要渲染。然后只需在列表项渲染后更新高度值:
const listRef = useRef(null);
const setRowHeight = (index, size) => {
rowHeights.current = { ...rowHeights.current, [index]: size };
listRef.current.resetAfterIndex(0);
};
const Row = () => {
const rowRef = useRef(null);
useEffect(() => {
if (rowRef.current) {
setRowHeight(index, rowRef.current.clientHeight);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowRef]);
}
此处还需调用VariableSizeList的resetAfterIndex方法,因为VariableSizeList出于性能目的缓存了索引对应列表项的偏移量和测量值,从而当一个列表项的尺寸发生变化时都应该调用该方法,保证可视区域内列表项正确排列。
这里还有一点需要注意的,react-window是通过绝对定位列表项(通过内联样式)来实现虚拟滚动的,所以还需要将style参数附加到渲染的DOM元素上。react-window的README.md中特别说明了这一点:传送门。如果忘记了这一点,滚动的时候会出现滚动条闪烁、内容空白问题。
但当我们添加上style参数时,新的问题又出现了:列表项高度固定不变了!不慌,审查列表项元素看看:
这就是刚才传递的style参数,可以看到style参数内包含了绝对定位信息,以及高度信息,而这个值就是我们设置的预估高度值200。在本案例中,我们需要的是一个动态的列表项高度值,因此只需剔除这个height属性即可。需要注意的是,style是一个不可变对象,是不能直接进行delete操作的,可以对其进行浅拷贝或者将height设为auto:
style={{ ...style, height: 'auto' }}
这样就解决了初始化时,确定列表项高度问题。但在本案例中,可以通过添加增加选项的方式改变列表项高度,因此我们需要监听列表项高度的变化,及时更新列表项的位置信息。同样,秉持着不重复造轮子的原则,我们使用rc-resize-observer中的ResizeObserver组件来实现,这个是antd的依赖之一,不需要单独安装。
import ResizeObserver from 'rc-resize-observer'
// ...
<ResizeObserver
onResize={() => {
setRowHeight(index, rowRef.current.clientHeight)
}}
>
// ...
</ResizeObserver>
到这一步,我们就将Form.List改造成虚拟列表了👏
3. 补充表单校验
虚拟列表实现了,但是还有一些问题没有解决。由于表单项没有全部加载,对于未渲染的表单项,使用antd自带的表单校验将无法对这部分表单项进行校验,导致提交非法表单信息。因此在虚拟列表场景下,需要对表单数据做额外的处理。
思路非常简单:表单提交时校验表单数据的合法性,有一项表单项校验失败,则滚动至该项。简化代码如下:
const scrollToQuestion = (index) => {
listRef.current.scrollToItem(index, 'smart')
// 触发校验
form.validateFields().catch(({ errorFields }) => {
// 校验失败滚动至第一个错误字段
form.scrollToField(errorFields[0].name)
})
}
const onFinish = values => {
const { questions} = values
if (!Array.isArray(questions)) return
let question
for (let i = 0, len = questions.length; i < len; i++) {
question = questions[i]
if (!question.title) return scrollToQuestion(i)
const { options } = question
if (Array.isArray(options)) {
for ( const option of options) {
if (!option.title) return scrollToQuestion(i)
}
}
}
}
// ...
<Form form={form} onFinish={onFinish} scrollToFirstError>
// ...
对于已渲染的表单项,antd Form在提交时会触发校验,只有在校验成功时才会触发onFinish,那么只在onFinish内部再对数据进行一次手工校验就能找到未渲染表单项中的错误项,使用VariableSizeList的scrollToItem方法可以让虚拟列表滚动至指定项,这样就可以渲染错误项,然后再触发antd Form的校验功能就行了。
最终成果
如下👇
思考
本文主要是讲如何使用虚拟列表解决大数据场景下,表单性能问题,同时也针对使用虚拟列表中常见的一些问题提供了一些思路:
- 列表项高度不确定,可以使用预估高度,渲染时使用真实高度替换。预估高度尽量与真实高度接近,在本案例中取的是固定值,但其实列表项高度和选项数量有正相关的,因此更好的做法是根据列表项数据与DOM的关联性去估计,这样可以避免差值过大导致滚动条位置闪烁。
- 渲染完成的列表项高度发生变化,可以通过监测列表项元素尺寸来实现高度值的动态更新。
虽然虚拟列表能提升性能,但只在大量数据场景下有明显的效果,此外虚拟列表也有一些问题,如快速滑动页面会有短暂的空白时间,用户无法调用ctrl + f搜索页面内容等。在数据量还不足以引发性能问题的情况下,甚至可能是一种负优化。因此建议只在性能出现问题的时候再考虑是否需要引入虚拟列表🤔
- End-