放弃antd table,基于React手写一个虚拟滚动的表格
缘起
标题有点夸张,并不是完全放弃antd-table,毕竟在react的生态圈里,对国人来说,比较好用的PC端组件库,也就antd了。即便经历了2018年圣诞彩蛋事件,antd的使用者也不仅不减,反而有所上升。
客观地说,antd是开源的,UI设计得比较美观(甩出其他组件库一条街),而且是蚂蚁金服的体验技术部(一堆p7,p8,p9,基本都是大牛级的)在持续地开发维护,质量可以信任。
不过,antd虽好,但一些组件在某一些场景下,是很不适用的。例如,以表格形式无限滚动地展示大量数据(1w+)时,antd-table就特别蹩脚了,光是首次渲染就能卡个五秒白屏。如果这个表格还要求能编辑,甚至不同列之间发生联动呢?对不起,antd-table无能为力,会把页面卡炸的。
antd-table本身是基于rc-table的扩展,而rc-table所属的react-component素来有自己的主张,在react社区其他的组件库都支持无限滚动时(例如react-data-grid, react-virtualized, react-tabulator..),很抱歉,它不支持。
爹爹不支持,作为儿女的antd-table也不好反对,顺其自然咯。
于是,部分使用antd的开发者就脑阔疼了,想使用其他支持无限滚动的表格组件吧,会发现诸多的问题:
1.UI太丑,真的,特别是react-data-grid,不能再丑了。虽然它的功能很强大,但颜值是个硬伤。想给它整容,符合antd一惯的审美风格,还真的挺繁杂的,从上手到放弃系列。
2.扩展起来,不接地气。有的组件库,功能很强,但封装得太厉害,说的就是上面的react-data-grid,还有react-tabulator,要想用起来,可不容易。说是react组件,可怎么用都觉得是反react,有点jq的倾向,惹不起。
3.文档的可读性差。react-data-grid,react-virtualized好歹还有基础的API文档,虽然写的不咋地,但也比react-tabulator这个只能让人去看源码的强。
4.版本不稳定。react-tabulator很任性,release直接从2,x升级到4.x...
5.不支持树形表格编辑。说的是react-virtualized,或许新版本支持了,但不得不对它说抱歉。
6.圈子不活跃,人少。人少、不活跃就意味着这个库可能不长久,比如react-tabulator。
一番比较下来,你会发现,还是react-component舒服,文档友好,扩展灵活,版本稳定,社区活跃,完全可以嵌套和插入自己写的react组件(就是丑了点),想必这也是antd基于它来做扩展的一个重要考量。antd或许是意识到了无限滚动地重要性,比如移动端的瀑布流,PC端商品列表的无限下拉刷新,在3.x版本已经基于react-data-grid做了一层扩展,增加了List组件,用来支持无限滚动。
但,对于表格而言,还是没有人性化的解决方案。
没办法,需求来了,不上也得上,自己手写一个吧。
目前为止,无限滚动没去做,只做了纵向虚拟滚动,滚动有些许延迟,但首次渲染和编辑的实时响应,还是可以接受的,而且支持固定左右列,横向滚动,完全支持自定义react组件的嵌套和插入,扩展起来太容易了。基本支持antd-table的用法。
实战
在动手写之前,要考虑一些问题:
1.是采用原生table,还是用div来模拟?
2.对于树形表格,采取怎样的虚拟滚动方案?
3.组件的职责边界怎么界定?
一、原生table Vs div模拟表格
table之所以叫table,用意很明显了,在你想要以表格形式展示数据的时候,首先要想到的,就是用table。
table布局有浏览器的特定算法实现加速绘制,且对静态表格来说,页面结构是很稳定的。
虽然div模拟表格绘制的速度也不慢,但要达到跟静态表格一样的结构稳定性,可就做许多额外的维护工作了,css辅助,js控制,浏览器背后对table做的脏活累活,你基本都得接手,从零开始。
但table也有硬伤,首先是样式不好自定义,想改装原生table,让它变得好看,还真不是一件快活的事,具体参考antd-table。其次,如果要求表格左右列能固定,中间列可滚动,原生table就很绝望了,它不得不多叫来两个table兄弟,让他们来辅佐自己,一个在左,一个在右,跟自己装载同样多的数据,但却只显示固定列。三兄弟之间,还要时不时保持联络,确保大家每行高度都是一样的。
如果这中间出了什么偏差,就会导致滚动的表格看起来左边或右边的行像是掉了下来....用过antd-table的人,应该会有这样的体会。
而div模拟表格就不一样了,它是从零开始的,一张白纸,想怎么画就怎么画,要多美就能多美。
要实现左右固定列滚动也不必装载三份一模一样的数据,一份就够了,它要做的,仅仅是把列固定,将固定列邻居的位置计算好,就能达到同样的效果。
这里,想看示例,可以看看阿里这位大爷写的div模拟表格。
基于这个角度的比较,我得给div模拟表格投一票。
二、虚拟滚动方案
首先,得先理解虚拟滚动的概念。
滚动,相信大家都了解,无非就是块级盒子的内容长度或宽度超出了盒子的宽高,盒子若设置了溢出内容可滚动,那我们就会看到滚动条,可滚动的距离,跟溢出内容所占的长度或宽度是相等的。
<div style="height:30px;overflow:scroll"> <p style="height: 10px">1</p> <p style="height: 10px">2</p> <p style="height: 10px">3</p> <p style="height: 10px">4</p> <p style="height: 10px">5</p> <p style="height: 10px">6</p> </div>
如上述例子,4、5、6是溢出的。它们的高度是30px,即可滚动的距离。
可以预见,如果还有7、8、9…9999等等近一万条数据,那么这个div同一时刻,最多只能展示4条数据,剩下的9997条数据,都需要滚动才能看到。
创建一个dom节点,成本完全能接受,十个百个千个也可以接受,但上万数十万呢?就算能接受,也不该如此浪费。
既然只能在同一时刻看到4个节点,为什么不能只创建4个节点,剩下的节点都是通过滚动要展现的时候,才去创建呢?
这自然是可以的。
虚拟滚动,就是出于这个目的来设计的。
假设数据有6条,这里只讨论高度。
如果只创建4个节点,马上就会发现,滚动条能滚动的距离不对,只有10px。与预期的30px不符。这是因为,滚动距离是浏览器根据盒子和盒子里的节点的高度计算出来的。我们只能调整节点的高度,无法直接修改滚动距离的值。
我们可以通过在后面创建一个辅助节点,将高度设为20px来解决这个问题。
<div style="height:30px;overflow:scroll"> <p style="height: 10px">1</p> <p style="height: 10px">2</p> <p style="height: 10px">3</p> <p style="height: 10px">4</p> <p style="height: 20px">占位符</p> </div>
现在,通过监听div的滚动事件,我们可以知道滚动条滚到了哪个位置,通过计算,得知展示的第一条数据在所有数据中,处于哪个位置,是第2条,还是第1条等等信息...
然后,进一步得知,哪一个未创建的节点,要立即被创建,并且,占位符的高度要对应变化。
例如上述例子里,展示2345的时候,占位符高度就要设为10px,并且最上面也要设置一个10px高的占位符,如:
<div style="height:30px;overflow:scroll"> <p style="height: 10px">占位符</p> <p style="height: 10px">2</p> <p style="height: 10px">3</p> <p style="height: 10px">4</p> <p style="height: 10px">5</p> <p style="height: 10px">占位符</p> </div>
遵循的原则就是,确保2345节点(我们称之为视图区)的高度,与占位符的高度加起来,等于总数据的实际总高度。
因此引申出的一个问题就是,每个节点的高度得固定(在表格里,就是固定表格行高)。或者,至少是在彻底展示完成之前,计算出实际高度。前面讨论过的组件库,除了react-data-grid,没有哪个不是固定行高的。
并且,视图区的高度也要指定。
如此一来,有了这些不变高度的数值,就能通过监听滚动来计算上下占位符各自的高度。
虚拟滚动的效果,也就达成了。剩下都是优化的工作,例如缓存节点,diff计算每次滚动时要改变的节点等等。
到这里,我们已经得出了扁平数据列表的虚拟滚动方案。
那么树形表格呢?
树形表格,准确的说,指的是数据在表格中以树形的形式来展现。这样的表格,可以展开/收起父节点,并且可以嵌套无限层级。参考antd-table的例子。
让树形表格支持虚拟滚动,可以利用刚才讨论的虚拟滚动方案。
这里的关键点在于,树形数据,是有父子层级关系的,并不是扁平数据。
因而首先要做的,就是把树形数据按顺序遍历平铺展开,即扁平化。
// 树形数据 const tree = [{ node: 1, children: [{ node: 11, children: [] }, { node: 12, children: [] }] }, { node: 2, children: [] }, { node: 3, children: [] }] // 树形数据按顺序平铺展开 const flatten = [{ node: 1 }, { node: 11 }, { node: 12 }], { node: 2 }], { node: 3 }]]
如此一来,我们就可以完全复用讨论过的虚拟滚动方案,达成树形表格虚拟滚动的效果。
其次,树形表格的展现,一般是要根据层级的深度来缩进的,这样才美观。我们可以展开树形数据的时候,将层级深度记录下来,在创建节点的时候,根据层级深度来决定缩进的宽度。
这里,会遇到一些样式上的问题,比如展开图标、缩进的宽度,有可能会受到css规则的影响,使得实际效果与预期不符,这个就需要自己去排查解决了。
三、组件的职责边界
上面已经提到如何实现一个虚拟滚动的树形表格,但没提到树形表格怎么展开、收起子元素,更没提到表格的可编辑功能。
这涉及到组件职责边界的确定,也是现在要讨论的。
一个组件,特别是react组件,它应该有什么样的功能,能提供什么样的API以供扩展,是要考虑清楚的。考虑不清楚的,就像react-tabulator,写个自定义单元格编辑器都得寻找dom节点,跟JQ有什么区别,而且还要按照它们定的规则来写,否则就不起作用。
理想的组件,不应该附加额外的规则,而是利用现有的规则,加以合适的运行机制,来达到方便扩展的目的。
antd-table这点做的还算可以,我们只需要将自己的react组件跟提供的API对接,就能达成想要的效果。
所以,我们来确定一下虚拟滚动的树形表格,应该有怎样的职责边界。
首先,列出这表格该有的基础功能:
1.支持虚拟滚动
2.支持单元格自定义--任何dom节点或者react组件
3.支持左右列固定
没错,跟antd-table相比,只是多出了一个虚拟滚动。除此以外的其他功能,都应该是由表格的使用者来实现,诸如可编辑单元格,树形表格如何展开收起。
这些,可用一句话来总结——数据驱动视图。
如果用过D3,相信非常能理解这个理念。数据千变万化,组件的功能也能千变万化,这是很理想的状态。
这三个基础功能里,第1个可以采用上述的虚拟滚动方案来实现。第3个可以用css的sticky属性配合js计算来实现(具体不赘述,参考阿里大爷的例子)。
第2个,其实倒是最简单的了。
只需要用React编写每个单元格容器,就能做到支持单元格的自定义。因为react天生支持dom节点的嵌套,更是本身就支持react组件之间的互相组合。
到此,基于React手写一个虚拟滚动的表格,已经Over。
行动力强的读者,应该已经可以写出自己的demo了。
我写的表格例子,内部大概长这样:
<Table onScroll={this.onScroll} style={{ maxHeight: this.tableHeight }}> <TableHead data={data} columns={dataColumns} rowWidth={this.rowWidth} rowKey={this.rowKey} onExpand={this.props.onExpand} /> <Placeholder line={viewUpData.length} height={this.cellHeight * viewUpData.length + 'px'} /> <ViewPort data={data} columns={dataColumns} rowWidth={this.rowWidth} rowKey={this.rowKey} onExpand={this.props.onExpand} /> <Placeholder line={viewDownData.length} height={this.cellHeight * viewDownData.length + 'px'} /> </Table>
外部使用虚拟滚动表格,大概是这样:
<VirtualTable bordered expandedRowKeys={expandedKeys} rowKey="id" onExpand={(expanded, record) => { this.onExpand(expanded, record) }} dataSource={dataSource} pagination={false} scroll={{ y: 250 }} columns={columns} viewLine={7} onBeforeScroll={this.onBeforeScroll} />
如果之前使用了antd-table来实现功能,那么,只需要将antd-table换成虚拟滚动表格,再加个视图区的限定于滚动监听,就完全OK了,不用改变任何原有的业务逻辑。
后续
数据驱动视图理念的瓶颈,限于我的有限知识,认为应是在于海量数据频繁快速变化的时候,渲染视图的速度如何能跟上来,怎样做到让人觉得画面流畅,完全不卡。
比如100万条数据的下拉滚动。
学海无涯,苦作舟。这条路,一直是会有苦的...