- 本文已参与「新人创作礼」活动,一起开启掘金创作之路
hi,小伙伴们好,今天给大家分享虚拟列表实战。工作中我们常常会遇到一些重磅问题,比如这里的列表超大数据渲染问题。很明显,几十万的数据不可能一次请求,后端必定分页,而前端就要做专门的虚拟滚动来加载对应页数的数据。今天,就让我们来看看如何利用虚拟列表做虚拟滚动。技术栈(react+react-window)
1.要实现什么样的效果?
-
先看效果图:
-
样式丑了点,但不影响我们做功能。我们要实现的效果是:
- 右上角显示数据总条数。
- 滚动条滚动时,不会发送请求,数据为空的地方有一个骨架屏效果。停止滚动的时候,根据虚拟列表的scrollTop计算出要请求第几页数据并发起请求,请求过程中,仍然保持骨架屏效果,直到数据请求完成,然后渲染。这就是虚拟滚动效果。
- 请求回来的数据本地做个缓存,下次再滑动到相同位置,就不用发起请求。
- 现在知道了要实现什么样的效果,我们就来着手做吧。
2.接口参数和返回值分析,以及技术选型
- 后端参数分析。首先要明白一点,后端给我们的数据类型是什么样的,根据页面渲染的数据,至少包含:
{
data:[],
totalCount:300000
}
- data就是列表数据,totalCount是总共的数据条数。
- 前端参数分析。前端参数至少有:
{
pageSize:20,
currentPage:1,
}
-
pageSize就是每一页的数据条数,currentPage就是当前的页数(总数据有30万,后端不可能一次性返回,肯定做了分页,我们传这两个参数就可以获取某个分页的pageSize条数据。)
-
技术选型。虚拟列表的实现我选择react-window这个库,github上有11k的stars,是个很不错的项目。同时,这个作者也封装了react-virtualized-auto-sizer,用来做虚拟列表宽高自适应的,这个库我们也要使用。
3.react-window,react-virtualized-auto-sizer初体验。
-
工欲善其事必先利其器,我们还是先对这两个库做个初步了解。
-
react-window内部封装了四个react组件,分别是:VariableSizeGrid ,FixedSizeGrid ,VariableSizeList ,FixedSizeList。
-
其中带有list关键字的是虚拟列表,带有Grid关键字的是虚拟网格。
-
其中带有Variable关键字的,格子高度的获取通过一个函数控制,可以实现每一行不一样高。带有fixed关键字的每一行一样高。
-
接下来上图说明:
-
FixedSizeList
-
VariableSizeList
-
FixedSizeGrid
-
VariableSizeGrid
-
自行体验网址:react-window在线体验
-
react-virtualized-auto-sizer是一个react组件,直接上代码,一看就知道它的功能,十分受用:
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
<AutoSizer>
{({ height, width }) => (
<List
className="List"
height={height}
itemCount={1000}
itemSize={35}
width={width}
>
{这是渲染的内容}
</List>
)}
</AutoSizer>
- 很明显,它的功能就是监听外部wrap宽高变化,生成实时的height和width来更新内部虚拟列表的宽高,可以说十分完美。
4.功能实现
- 封装fetch方法,模拟后端接口,这里实际项目肯定是有接口的,我这里不方便做接口,所以写个方法模拟下后端接口
const fetch = (pageSize, currentPage) => {
return new Promise((re, rj) => {
setTimeout(() => {
const data = Array(pageSize).fill("新数据");
re({
data,
totalCount: 1000,
});
}, 1000);
});
};
-
通过Promise模拟请求时间为一秒的接口,参数pageSize代表要请求多少条数据,参数currentPage代表请求第几页的数据。可以看到currentPage参数我没有使用,在实际的后端接口中,这两个参数要配合使用。fetch的返回值为data和totalCount,这里我设置totalCount为1000是为了好调试,设置成几十万,上百万也没问题。
-
封装虚拟列表,代码很简单,如下:
import React, { useEffect, useState } from "react";
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import { debounce } from "lodash";
const ITEM_SIZE=30;//定义虚拟列表行的高度
const VisualList=()=> {
const [list, setList] = useState([]);//虚拟列表数据
const [totalCount, setTotalCount] = useState("");//数据总条数
return (
<div
className="autoSizeWrap"
style={{ display: "flex", flexDirection: "column" }}
>
<h3 style={{ textAlign: "right" }}>数据总条数{totalCount}</h3>
<div style={{ flex: 1, minHeight: 0 }} className="autosizeInner">
<AutoSizer>
{({ width, height }) => (
<List
className="List"
height={height}
itemCount={totalCount}
itemSize={ITEM_SIZE}
width={width}
onScroll={(obj) => handleScroll(obj, height)}
data={list}
>
{Row}
</List>
)}
</AutoSizer>
</div>
</div>
);
}
- 处理页面加载后第一次请求:
useEffect(() => {
fetchList(1, 20, "first");//第一次请求
}, []);
const fetchList = async (page1, pageSize, first, page2) => {//封装请求方法
if (first === "first") { //处理第一次请求
const res = await fetch(pageSize, page1);
setList(
res.data.concat(Array(res.totalCount - res.data.length).fill(null))
);
setTotalCount(res.totalCount);
} else if (page2) {//处理请求两页数据的情况
const { data } = await fetch(20, page1);
const { data: data1 } = await fetch(20, page2);
setListData(page1, 40, [...data, ...data1]);
} else {//处理普通的一页数据请求
const { data } = await fetch(20, page1);
setListData(page1, 20, data);
}
};
const setListData = (page, size, data) => {
list.splice((page - 1) * 20, size, ...data);//数组的splice方法保留了已有的数据,起到了缓存数据的作用
setList([...list]);
};
-
上面这段代码唯一要注意的就第一次请求的setList那里,我们只请求到20条数据,但要生成的是长度为1000的数组。
-
接下来,我们要封装Row组件,用于定义虚拟列表內每一行怎么渲染。代码如下:
const Row = (props) => {
const { index, style } = props;//index代表当前数据所处位置
return (
<div className="list-item" style={style}>
{list[index] ? ( //这个判断很重要,当list当前位置是否为null?如果为null就渲染骨架屏
`Row ${index} ${list[index]}`
) : (
<>
<div className={"loading-span span1"} />
<div className={"loading-span span2"} /> //这里是骨架屏
</>
)}
</div>
);
};
- 最后处理比较复杂的滚动条滚动过程,看代码:
const handleScroll = debounce(async ({ scrollOffset }, height) => {
if (scrollOffset === 0) return;
const startIndex = Math.ceil(scrollOffset / ITEM_SIZE);
const endIndex = Math.ceil((scrollOffset + height) / ITEM_SIZE);
const startPage = Math.ceil(startIndex / 20); //虚拟列表內第一行数据所在数据页
const endPage = Math.ceil(endIndex / 20); //虚拟列表內最后一行数据所在数据页,20是默认的pageSize,这个由自己定
const startIsNull = !list[startIndex]; //虚拟列表內第一行数据为空
const endIsNull = !list[endIndex]; //虚拟列表內最后一行数据为空
const fetchType = {
start:
(startIsNull && endIsNull && startPage === endPage) ||
(startIsNull && !endIsNull), //这种情况,只用请求第一行所在页数据
end: !startIsNull && endIsNull, //这种情况,只用请求最后一行所在页数据
all: startIsNull && endIsNull && startPage !== endPage, //这种情况,最后一行所在页,第一行数据所在页数据都要请求
};
const fetchMethod = {
start: fetchList.bind(this, startPage, 20),
end: fetchList.bind(this, endPage, 20),
all: fetchList.bind(this, startPage, 20, "", endPage),
};
for (let key in fetchType) {
if (fetchType[key]) {
fetchMethod[key]();
}
}//判断满足哪一个条件,如果满足就调用对应的请求请求数据
}, 400);
-
debounce 是防抖处理,作用就是滚动条滚完之后再发起请求。
-
函数的 scrollOffset 就是虚拟列表的 scrollTop,就是滚动条滚动的距离,height就是虚拟列表的高度。
-
startIndex 代表滚动条停止时,虚拟列表第一条数据的index;
-
endIndex 代表滚动条停止时,虚拟列表最后一条数据的index;
-
startPage 代表滚动条停止时,虚拟列表第一条数据所处的分页;
-
endPage 代表滚动条停止时,虚拟列表最后一条数据所处的分页;
-
当list[startIndex]为空,或者list[endIndex]为空,代表骨架屏进入页面,页面就要发起请求。
-
fetchType里面的判断处理了多种情况。就是滚动条滚动时,startPage和endPage的处理,向上或者向下滚动,都可能出现四种情况。
-
endPage没有数据,只需请求endPage的数据,看下图分析:
-
startPage,endPage数据都没有,分两种情况,看下图分析:
-
startPage没有数据,只用请求startPage的数据。看下图分析:
5.题后话
虚拟列表的实战到这里就结束了,难点就在于最后的滚动条处理,如果不清楚多看几遍最后三点就知道了。