参考
虚拟滚动列表的大致方案
- 虚拟列表的实现,大致上的思路就是:只加载可是区域范围内的列表项,发生滚动时,动态计算哪些列表项需要渲染
- 为实现,我们需要(以下步骤以列表项高度 itemHeight 确定的情况为例,列表项高度不定时会麻烦一些)
- 有一个已知高度 height 的滚动容器 scrollContainer
- 计算所有数据 data 都渲染所需要占用的高度 itemHeight * data.length , 然后用这个高度撑开滚动容器,从而获得滚动条及滚动容器 scrollTop
- 计算当前可视区域所能渲染的列表条数 count = Math.ceil(height / itemHeight)
- 计算可视区域第一个列表项的索引 start
- 计算可视区域最后一个列表项的索引 end = start + count
- 渲染可视区域的数据 data.slice(start, end)
- 监听滚动容器的 scroll 事件,事件内获得当前滚动容器的 scrollTop, 并根据获得的 scrollTop 重新计算需要展示的第一个列表项索引 start = Math.floor(scrollTop / itemHeight)
- 计算当前滚动区域整体的偏移值 offset = scrollTop - (scrollTop % itemHeight) , 借助 css 属性 transform:
translate3d(0, ${offset}px, 0) 使得可视区域平滑的上下移动(同时这个列表区域是 position: ‘absolute’ 脱离文档流的),每当 scrollTop 是 itemHeight 的整数倍时,设置偏移值的同时也会切换 start, 这就刚好可以给用户一个列表在正常滚动的错觉
0. 前期准备
- 准备一些可分页的数据,虚拟滚动列表,一般用来提升大数据量下的渲染性能,但是对于前端项目来说,数据是通过请求后端获得的,相比于大数据列表的渲染,大数据的请求更加耗时,所以,一般来说,实现虚拟滚动的同时,也要兼顾数据分页请求的考虑(一般是下拉到底后请求下一页数据)
- 准备一个可以动态获得容器宽高的控件,虚拟滚动列表需要获取高度来进行计算,一个“动态获得容器宽高的控件” 可以满足虚拟滚动列表在页面缩放或者其他影响展示高度的场景下拿到准确的高度值
import ResizeObserverPolyfill from 'resize-observer-polyfill';
interface ISize {
width: number;
height: number;
}
interface IProps {
children: (size: ISize) => React.ReactNode;
}
const AutoSizer: React.FC<IProps> = (props: IProps) => {
const ref = React.useRef<HTMLDivElement>(null);
const [size, setSize] = React.useState<ISize>({} as any);
React.useEffect(() => {
if (ref.current && ref.current.parentNode && ref.current.parentNode.ownerDocument) {
const resizeObserver: ResizeObserver = new ResizeObserverPolyfill((entries) => {
requestAnimationFrame(() => {
if (!Array.isArray(entries) || !entries.length) {
return;
}
if (ref && ref.current) {
const target = entries[0].target as HTMLElement;
const { offsetWidth, offsetHeight } = target;
setSize({ width: offsetWidth, height: offsetHeight });
}
});
});
resizeObserver.observe(ref.current.parentNode as HTMLElement);
return () => resizeObserver.disconnect();
}
return () => {};
}, [ref]);
const { children } = props;
return (
<div ref={ref} style={{ overflow: 'visible', width: 0, height: 0 }}>
{children(size)}
</div>
);
};
export default React.memo(AutoSizer);
1. 普通的虚拟滚动列表 + 下拉动态加载
虚拟滚动组件封装
import VirtualLess from './Virtual.module.less';
interface IProps {
height: number;
width?: number;
itemHeight: number;
records: { [key: string]: any }[];
recordKeyName: string;
renderItem: (record: IProps['records'][number]) => React.ReactNode;
onScrollToBottom: () => void;
buffer?: number;
}
const VirtualList: React.FC<IProps> = (props: IProps) => {
const scrollRef = React.useRef<HTMLDivElement>(null);
const { height, width, itemHeight, renderItem, recordKeyName, records, buffer = 5,onScrollToBottom } = props;
const [start, setStart] = React.useState(0);
const [offset, setOffset] = React.useState(0);
const visibleCount = Math.ceil(height / itemHeight);
const end = start + visibleCount;
const listNum = records.length;
const listHeight = listNum * itemHeight;
const displayRecords = records.slice(start, Math.min(end, listNum));
const scrollListener = React.useCallback(() => {
if (!scrollRef.current) return;
const scrollTop = scrollRef.current.scrollTop;
const nextStart = Math.floor(scrollTop / itemHeight);
const nextOffset = scrollTop - (scrollTop % itemHeight);
setStart(nextStart);
setOffset(nextOffset);
const nextEnd = nextStart + visibleCount + buffer;
if (nextEnd >= listNum && onScrollToBottom) onScrollToBottom();
}, [visibleCount, listNum, itemHeight]);
React.useEffect(() => {
const dom = scrollRef.current;
scrollListener();
if (dom) dom.addEventListener('scroll', scrollListener);
return () => {
if (dom) dom.removeEventListener('scroll', scrollListener);
};
}, [scrollListener]);
return (
<div
className={VirtualLess.container}
ref={scrollRef}
style={{ height, width: width || '100%' }}
>
{}
<div className={VirtualLess.pillar} style={{ height: Math.max(listHeight, height + 1) }} />
{}
<div className={VirtualLess.realList} style={{ transform: `translate3d(0, ${offset}px, 0)` }}>
{displayRecords.map((record) => {
return (
<div className={VirtualLess.listItem} key={record[recordKeyName]}>
{renderItem(record)}
</div>
);
})}
</div>
</div>
);
};
export default React.memo(VirtualList);
虚拟滚动组件的样式
.container {
overflow-y: auto;
position: relative;
}
.pillar{
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.realList {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}
.listItem{
width: 100%;
border: 1px solid red;
}
虚拟滚动组件的使用 demo
import AutoSizer from './AutoSizer';
import VirtualList from './VirtualList';
import { getData } from './MockRequest';
const Virtual = () => {
const [data, setData] = React.useState<{ id: string; title: string }[]>([]);
const [pageNumber, setPageNumber] = React.useState(1);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
setIsLoading(true);
getData({ pageNumber, pageSize: 50 }).then((d) => {
setIsLoading(false);
setData(d);
});
}, []);
const onScrollToBottom = React.useCallback(() => {
if (isLoading) return;
const nextPageNumber = pageNumber + 1;
getData({ pageNumber: nextPageNumber, pageSize: 50 }).then((d) => {
setPageNumber(nextPageNumber);
setIsLoading(false);
setData(data.concat(d));
});
setIsLoading(true);
}, [data, isLoading, pageNumber]);
const renderItem = React.useCallback((record: { id: string; title: string }) => {
return <div style={{ height: 50 }}>{record.title}</div>;
}, []);
return (
<div style={{ width: '100%', height: '100%' }}>
<AutoSizer>
{({ width, height = 0 }) => (
<VirtualList
width={width}
height={height}
itemHeight={50}
records={data}
recordKeyName="id"
renderItem={renderItem}
onScrollToBottom={onScrollToBottom}
/>
)}
</AutoSizer>
</div>
);
};
export default React.memo(Virtual);
|