• 放弃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万条数据的下拉滚动。

    学海无涯,苦作舟。这条路,一直是会有苦的...

  • 相关阅读:
    Knockoutjs 实践入门 (2) 绑定事件
    HTTP If-Modified-Since引发的浏览器缓存汇总
    net.sf.json和 com.fasterxml.jackson中对象转json的区别
    JPA EntityManager详解
    Spring JPA中OneToOne和OneToMany用法
    Spring Data JPA中CrudRepository与JpaRepository的不同
    Git提交撤销
    Tomcat启动报StackOverflowError
    Git分支合并冲突解决(续)
    Git分支合并冲突解决
  • 原文地址:https://www.cnblogs.com/miyosan/p/10283402.html
Copyright © 2020-2023  润新知