antd 3.x Table组件如何快速实现虚拟列表详析

1. 前言

随着互联网的发展,web展示的内容越来越丰富,也越来越无穷。我们在实际开发中难免会遇到长列表数据渲染,而又不适合分页的业务场景,如果浏览器直接渲染海量数据,会造成页面卡死,严重时导致浏览器资源耗尽,直接崩溃掉。这种情况用户与产品是无法接受的,浏览器性能与业务需求产生了对立,因此虚拟列表技术被提出,为这种尴尬的场面提供了一线生机。

2. 虚拟列表

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。假设有10万条记录需要同时渲染,我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在渲染的时候,我们只需加载可视区的那10条即可,触发页面滚动时,实时替换当前应该展示在页面中的10条数据。

它的名词解释由一张图来诠释,如下:

触发滚动后,可视区域内的数据变化:

  • 首先,定义一个visibleHeight变量来保存我们可见区域的高度,作为内容容器,并设置为500px,visibleHeight = 500
  • 假设每列高度itemHeight固定,则可见区域内的数据条数visibleCount = Math.ceil(visibleHeight / itemHeight)
  • 由于只渲染可视区域内的数据,所以我们需要另一个占位容器,使父盒子出现滚动条,占位容器高度placeholderHeight = totalCount * itemHeight。占位容器替代了内容容器,撑出了滚动条,内容盒子则采用绝对定位脱离文档流。
  • 每当触发滚动时,获取滚动条的滚动距离,计算出应该总共滚动了的数据条数,从而设置数据的开始索引startIdx = Math.floor(scrollTop / itemHeight),而结束索引endIdx = startIdx + visibleCount
  • 如果是原生开发,则根据开始、结束索引去操作dom,替换dom。如果是vue或react都是数据驱动,则更新要渲染的list数据即可,renderList = sourceList.slice(startIdx, endIdx)
  • 最后一点,我们的可视数据列表需要根据每次滚动的距离相应地调整内容容器的位置,以保证在父盒子的滚动下,内容容器始终在可视区域中,offset = startIdx * itemCountoffset则为内容容器相对于占位容器的偏移距离。

首先,准备dom结构:

<div className="wrapper" style={{
 position: "relative",
 overflow: "auto"
}}>
 <div className="placeholder-list" style={{ 
 height: `${visibleHeight}px` 
 }}></div>
 <div className="render-list" style={{ 
 postion: "absolute", 
 top: 0, 
 left: 0
 }}>...</div>
</div>

wrapper添加滚动事件实现逻辑:

scrollEvent(e){
 const startIdx = Math.floor(e.target.scrollTop / itemHeight);
 const endIdx = startIdx + visibleCount;
 setList(source.slice(startIdx, endIdx));
 // 设置偏移距离,保持数据在视图中
 const offset = startIdx * itemHeight;
 listRef.current.style.top = offset + "px";
}

我们发现,快速滚动时最下方会出现空白的现象,因为此时数据还没渲染成功。为了优化此空白,考虑多渲染2条数据作为缓冲区。因此visibelCount=Math.ceil(visibelHeight / itemHeight) + 2

代码完整示例:

import { useCallback, useEffect, useRef, useState } from "react";

const visibleHeight = 360;
const itemHeight = 50;
const visibleCount = Math.ceil(visibleHeight / itemHeight) + 2;
const totalCount = 100;

const source = Array.from(Array(totalCount), (item, index) => index);

export default function VirtualList() {
 const [list, setList] = useState(source);
 const listRef = useRef();

 const scrollEvent = useCallback((e) => {
 const startIdx = Math.floor(e.target.scrollTop / itemHeight);
 const endIdx = startIdx + visibleCount;
 setList(source.slice(startIdx, endIdx));
 const offset = startIdx * itemHeight;
 listRef.current.style.top = offset + "px";
 }, []);
 
 useEffect(() => {
 listRef.current = document.querySelector(".list");
 }, []);
 
 return (
 <div
 style={{
 backgroundColor: "#FFF",
 height: visibleHeight + 'px',
 textAlign: "center",
 overflow: "auto",
 position: "relative",
 overscrollBehavior: 'contain'
 }}
 onScroll={scrollEvent}
 >
 <div style={{ height: totalCount * itemHeight + 'px' }}></div>
 <div
 className="list"
 style={{
 position: "absolute",
 top: 0,
 left: 0,
 width: "100%",
 height: visibleHeight + 'px'
 }}
 >
 {list.map((item) => {
 return (
 <div
 key={item}
 style={{ height: itemHeight + 'px', borderBottom: "1px solid #eee" }}
 >
 {item}
 </div>
 );
 })}
 </div>
 </div>
 );
}

3. 虚拟table

终于来到了标题的内容,如何对antd table3.x进行虚拟表格的封装。其实和上述的代码差不多,只不过对于有的新手同学来讲,可能有点摸不着入口,所以有了本节内容。

目前在antd4.x版本table已经实现了开启虚拟列表的配置,拿来即用。针对3.x的版本自己实现了一个虚拟table,解决了业务上长列表渲染卡顿的问题。

注意:Table每项需要定高,因此columns属性中需要ellipsis:true保证数据只展示一行,溢出展示省略号。

根据上节内容介绍的虚拟列表思路,我们需要准备2个容器,一个内容容器,Table已经提供了,另一个占位容器没有提供,所以需要手动创建一个并放在合适的地方。通过开发者工具审查元素找到Table内提供的那个内容容器.ant-table-body table,获取其dom。父容器.ant-table-body,创建一个占位容器div,追加到父容器内。通过元素审查也知道Table tr高度为54px,即itemHeight=54

知道了类名,就可以获取到Table的dom为所欲为了。

useEffect(() => {
 const parentNode = document.querySelector('.ant-table-body');
 const table = document.querySelector('.ant-table-body table');
 // 用ref保持table方便在滚动事件中使用table dom
 tableRef.current = table;
 // 创建一个占位的div,高度等于所有数据高度,用来撑开容器展示滚动条
 const placeholderWrapper = document.createElement('div');
 placeholderWrapper.style.height = itemHeight * totalCount + 'px'
 parentNode.appendChild(placeholderWrapper);
 // 子绝父相口诀,为table设置定位,脱离文档流,把位置让给占位盒子
 parentNode.style.position = 'relative';
 table.style.position = 'absolute';
 table.style.top = 0;
 table.style.left = 0;
 // 添加滚动事件
 parentNode.addEventListener('scroll', scrollEvent)
 return () => {
 // 清理占位盒子
 parentNode.removeChild(placeholderWrapper);
 parentNode.removeEventListener('scroll', scrollEvent)
 }
 }, [scrollEvent]);

接下来实现滚动事件,和上节内容一致,保存范围索引到state中:

const scrollEvent = useCallback((e) => {
 const startIdx = Math.floor(e.target.scrollTop / itemHeight);
 const endIdx = startIdx + visibleCount;
 // 保存当前的范围索引,用来slice源数据给展示用
 setRange([startIdx, endIdx]);
 const offset = startIdx * itemHeight;
 tableRef.current.style.top = offset + "px";
 }, []);

根据范围索引,截取当前要展示的数据项

const [range, setRange] = useState([]);
// 这个renderList就是需要给Table组件的
const renderList = useMemo(() => {
 const [start, end] = range;
 return dataSource.slice(start, end)
 }, [range])

return <Table dataSource={renderList} />

全文示例代码Github地址

4.总结

本文只是实现了在固定每项列表高度的情况下的虚拟列表,现实很多情况是不定高的。这个比定高的复杂,不过原理也是一样的,多了一步需要计算渲染后的实际高度的步骤。后续会完善不定高的虚拟列表的实现。

本文的内容也是我在工作中遇到的情况,应该很多其他小伙伴也会遇到antd 3.x table的虚拟化的问题,希望能给小伙伴们一点思路。因此有了本文,也是自己一次关于输入与输出的记录与沉淀。

作者:Yue栎廷

%s 个评论

要回复文章请先登录注册