从0到1封装原生table组件,并支持大数量级渲染(十万级)
前言
大家好,我是张添财。最近事情比较多,又断更了好久,恰好上周h5端需要封装一个表格组件,在此继续拾起笔,与各位分享分享感悟。
一、表格布局分析
首先,我们要先设计table组件的基本布局,这里如下图所示:
从上面这张图我们大致可以理清将要设计的表格组件的dom结构,如下面图片所示:
到此,我们就已经把结构大致搭建起来了。但是,我们需要注意:在实际业务中我们肯定不会这么写dom结构,我们往往是用map对数据做一层映射,将表格数据映射成具体表格行、表格单元格等。
二、设计数据格式:
在进行映射之前,我们先设计一下数据格式,这里我们参考的是antd的Table组件传参格式: 表头列用 columns 数据映射,columns 每一项代表每个表头单元格的配置数据,columns 有几个数组元素就代表要渲染几个表头列。columns相关内容配置如下:
key值 | 必填性 | 作用 |
---|---|---|
title | yes | 表头列的标题 |
dataIndex | yes | 表示表头列对应表身单元格的内容数据 |
width | no | 表示表头列宽度 |
id | no(建议填) | 表示表头列的唯一性标识 |
render | no | 表示自定义渲染其对应表身单元格的内容 |
align | no | 表示表头单元格内容对齐方式 |
contentAlign | no | 表示对应表身单元格内容的对齐方式 |
const columns=[
{
title: "用户名称", // 表示表头单元格的标题
dataIndex: "userName", // 表示表头单元格对应表身单元格的内容数据
id: "userName", // 表示表头单元格的唯一性标识
render:(val)=>{return val}, // 表示自定义渲染对应表身单元格的内容
align: "left", // 表示单元格内容对齐位置
},
{
title: "注册时间",
dataIndex: "registerTime",
id: "registerTime",
align: "right",
}
]
表格的内容数据由 dataSource 数据进行映射,dataSource 也是数组类型,数组每一项代表表身的表格行数据;dataSource 有几项就渲染几行数据。这里我们需要注意,dataSource里每一项的key值一定要与columns的dataIndex对应好,否则数据是渲染不出来的!
const dataSource=[ {
userName: "175****0930", // 这里的key值是对应columns的dataIndex值
registerTime: "2012-12-25 22:13",
},
{
userName: "这是一串昵称最长十个一",
registerTime: "2012-12-25 22:13",
},
{
userName: "这是一串昵称最长十个…",
registerTime: "2012-12-25 22:13",
},
{
userName: "这是一串昵称最长十个…",
registerTime: "2012-12-25 22:13",
},
]
三、数据映射
表头列映射
在上一part我们设计了参数格式,容易知道表头列是根据columns来进行映射的,并且内容取的是title值、对齐位置有align决定、自定义渲染有render决定。那么我们就可以将映射设计成如下代码:
...
{/* 表头 */}
<div className="tr tr_header">
{/* 表头单元格 */}
{
columns.map(item => {
return (
<div
className="th"
key={item.id}
style={{
width: item?.width ? item.width : "auto",
flex: item?.width ? "none" : "1",
textAlign: item?.align ? item.align : "left",
}}
>{item.title}
</div>)
})
}
</div>
...
表格行映射
表格行的渲染与表头不同,因为渲染表身的表格行既要结合dataSource进行内容展示,也要根据columns 来决定表身渲染几列。由此我们初步判断要进行两层映射,一层映射遍历dataSource数据决定渲染几行,一层映射遍历columns来渲染出每一行的列数。而我们要自定义渲染的内容则需要写在内层映射中。
...
{/* 表身 */}
<div className="table_body">
{dataSource.map((item) => {
return (
<div className='tr tr_body_line' key={item.id}>
{columns.map((_item) => {
return (
<div
className="td"
style={{
width: _item?.width ? _item.width : "auto",
flex: _item?.width ? "none" : "1",
textAlign: _item?.contentAlign ? _item.contentAlign : "left",
}}
key={_item?.id}
>
{_item?.render
? _item.render(item[_item.dataIndex])
: item[_item.dataIndex]}
</div>
);
})}
</div>
);
})}
</div>
...
到这里我们的表格组件就封装完成了,下面是是完整代码:
CommonTable.jsx 文件
import React ,{memo}from 'react';
import scss from "./index.module.scss";
// 支持自定义render; 支持调节宽度(百分比); 支持文本位置; 支持自定义table样式
const CommonTable = memo((props) => {
let { columns = [], dataSource = [], loading = false ,customTableStyle} = props;
return (
<div className={`${scss.table} ${customTableStyle}`}>
{loading ? (
<div>加载中....</div>
) : (
<div>
<div className={`${scss.tr} ${scss.bg_header}`}>
{columns.map((item) => {
return (
<div
className={scss.th}
style={{
width: item?.width ? item.width : "auto",
flex: item?.width ? "none" : "1",
textAlign: item?.align ? item.align : "left",
}}
key={item?.id}
>
{item.title}
</div>
);
})}
</div>
<div className={scss.table_content}>
{ dataSource.map((item,idx) => {
return (
<div className={`${scss.tr} ${scss.bg_line}`} key={item.id}>
{columns.map((_item) => {
return (
<div
className={scss.td}
style={{
width: _item?.width ? _item.width : "auto",
flex: _item?.width ? "none" : "1",
textAlign: _item?.contentAlign ? _item.contentAlign : "left",
}}
key={_item?.id}
>
{_item?.render
? _item.render(item[_item.dataIndex])
: item[_item.dataIndex]}
</div>
);
})}
</div>
);
})}
</div>
</div>
)}
</div>
);
});
export default CommonTable;
index.module.scss 样式文件:
.table {
position: relative;
border-radius:5px;
overflow-y: hidden;
border: 1px solid #000;
.table_content{
height: 296px;
overflow-y: auto;
}
.tr {
display: flex;
width: 100%;
border-bottom: 1px solid #000;
.th{
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
height: 100%;
&:nth-child(n){
border-right: 1px solid #000;
}
&:last-child{
border-right: 0px;
}
}
.td {
@extend .th;
// 配一些td独有的样式
}
}
.bg_header {
height: 66px;
line-height: 66px;
}
.bg_line{
height: 33px;
line-height: 33px;
}
}
在业务组件中使用此组件:
...
<CommonTable
columns={mockColumns}
dataSource={mockDataSource}/>
...
四、大数量级数据渲染优化
现在我们封装的table组件就可以进行使用了,但是存在一个问题:如果表格的行数据很多时,表格渲染会出现卡顿的现象。这里我们以渲染100000条行数据来进行性能测试。
我们先对Table组件中增加些代码来算一下阻塞时间:
...
const startTime=useRef(new Date().getTime())
useEffect(() => {
const endTime=new Date().getTime()
console.log('endend>>',endTime,'startTime.current>>>',startTime.current,'阻塞时间>>>',endTime-startTime.current);
},[])
return (
...
)
创建dataSource数据,mock十万条行数据:
const mockDataLen=(num)=>{
return Array.from({length:num}).map((it,idx)=>{
return {
userName: idx+"测试数据…",
restNum:"121211",
id: idx
}
})
}
const mockDataSource=[...mockDataLen(100000)]
直接渲染:
我们直接将十万条数据传入,此时查看控制台打印的阻塞时间以及控制面板里的fp如下图所示:
我们可以看出,阻塞时间会长达4-5s并且首次paint在16s后了,页面也很卡顿。
卡顿原因分析: 这里卡顿的原因也很简单:react一次性渲染十万条数据是一笔不小的开销,scripting阶段、render阶段损耗都非常大。
那么我们该如何去优化这种数量级的渲染呢?
主流方法其实就三种: 时间分片、虚拟列表、后端分页。
-
时间分片 就是将大批量数据进行分批渲染,每次的渲染数都是一个小数量级的分片数据;
-
虚拟列表 是每次只渲染可视区域的数据,也就是说渲染的行数是固定的,变的只是位于可视区数据值;
-
后端分页 是后台每次都返回小数量级的数据,前端根据页码去请求相应页码的后台数据并进行渲染。
总的来说,我们在优化大数量级别的渲染遵循的原则就是减少每次要渲染的数据量。我们这里采用时间分片的方式来优化Table组件。
时间分片:
分析:
既然我们已经知道了时间分片就是将大数量级的数据切成一个个小数量级的分片来进行分批次渲染,那么我们可以这样设计: 首先是updateRenderList函数,此函数是用来更改每次要渲染的table数据的,也即每个批次增加多个新渲染的行就是在这里处理的;另外我们还需要一个loop函数,这个函数的作用就是当本次表格数据更新完之后再去进行下一次的更新render。
注意:我这里说的是等本次更新完,也就代表着开启下一次更新的时机一定不能同步执行。这里我们用 setTimeout 定时器来进行开启下一次的更新数据。
逻辑分析完之后,我们就来设计代码:
首先我们先确定需要几个变量值:
-
实际要映射渲染的renderList是必需的;
-
用来被分片的总数据allDataSource也是必需的;
设计updateRenderList函数:
经过上面的分析,我们清楚 updateRenderList 主要就是用来更新实际参与渲染的table数据的,那么我们在此函数中要做的工作就两个: 切割总数据、更新renderList值。
function updateList() {
// 取五十个渲染
setRenderList((pre)=>{
return [...pre,...allDataSource.current.splice(0,50)]
} )
}
设计loop函数:
loop函数是个递归函数,因为此函数中需要不断的进行自调用去更新renderList以及判断是否要进行下一次的loop。递归函数都需要有个出口,此函数的出口就是当总数据allDataSource被分割完,即可跳出递归。
function loop() {
if (allDataSource.current.length === 0) return;
updateList(allDataSource.current)
// 之所以用setTimeout是要等上一个updateList渲染完再进行下一次loop
setTimeout(() => {
// 渲染完成,下一次 loop
loop();
},0)
}
所有优化代码如下所示:
...
let { columns = [], dataSource = [], loading = false ,customTableStyle,tabKey=undefined} = props;
const allDataSource= useRef([])
const [renderList,setRenderList] = useState([])
function updateList() {
// 取五十个渲染
setRenderList((pre)=>{
return [...pre,...allDataSource.current.splice(0,50)]
} )
}
function loop() {
if (allDataSource.current.length === 0) return;
updateList(allDataSource.current)
// 之所以用setTimeout是要等上一个updateList渲染完再进行下一次loop
setTimeout(() => {
// 渲染完成,下一次 loop
loop();
},0)
}
// 数据变化或者有切换tab这种的功能时需要去清除renderList否则会有性能开销
useLayoutEffect(()=>{
setRenderList([])
},[dataSource,tabKey])
useEffect(() => {
allDataSource.current=_.cloneDeep(dataSource)
// 当数据变化开启loop
loop()
},[dataSource])
return (
...
)
...
优化后,控制面板相关数据如下图:
这样的结果我们还是可以接受的。
用RAF进行优化,避免丢帧的情况
我们在滚动时又发现了一个问题:页面滚动过快会出现白屏,也就是我们常说的丢帧现象。如下图示:
这是为什么呢? 原来,我们在开启下一次loop时用的是 setTimeout 来进行开启的。而 setTimeout 又是一个宏任务,它与浏览器的刷新频率是不一致的,这也就造成我们滚动过快出现白屏的原因。
解决方法:
我们知道 requestAnimationFrame 的回调执行频率和屏幕刷新频率一致,都是60HZ也即一秒刷新60次,在下一次重绘前进行调用。所以我们就用 requestAnimationFrame 来替代 setTimeout 来更改一下数据更新的时机从而避免闪屏的出现。
...
function loop() {
if (allDataSource.current.length === 0) return;
updateList(allDataSource.current)
requestAnimationFrame(() => {
// 渲染完成,下一次 loop
loop();
})
}
...
这是requestAnimationFrame优化后面板的数据
以上就是我们整个组件的封装以及优化过程了,文章到此也进入尾声,非常感谢大家的阅读,各位继续奋进呐!
转载自:https://juejin.cn/post/7155064269506084878