translate3d(0, ${offset}px, 0)
使得可视区域平滑的上下移动(同时这个列表区域是 position: ‘absolute’ 脱离文档流的),每当 scrollTop 是 itemHeight 的整数倍时,设置偏移值的同时也会切换 start, 这就刚好可以给用户一个列表在正常滚动的错觉
- 准备一些可分页的数据,虚拟滚动列表,一般用来提升大数据量下的渲染性能,但是对于前端项目来说,数据是通过请求后端获得的,相比于大数据列表的渲染,大数据的请求更加耗时,所以,一般来说,实现虚拟滚动的同时,也要兼顾数据分页请求的考虑(一般是下拉到底后请求下一页数据)
- 准备一个可以动态获得容器宽高的控件,虚拟滚动列表需要获取高度来进行计算,一个“动态获得容器宽高的控件” 可以满足虚拟滚动列表在页面缩放或者其他影响展示高度的场景下拿到准确的高度值
/**
* HOC 获得撑满所在区域的 宽高
* NOTICE:
* 1. 此组件的上层组件如果有 padding 处理,那么需要对传出的 width height 做些计算
* 2. 此组件的下层组件不要设置 height=100% 或者 width=100% 否则可能会导致白屏,建议直接设置 style={width, height}
* */
/**
* ResizeObserver 可以用来监听 DOM 元素内容区域的边界改动
* https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver
* 但是有兼容问题,因此使用了第三方提供的 polyfill
*
*/
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);
/*
* 虚拟滚动列表
* */
import VirtualLess from './Virtual.module.less';
interface IProps {
/** 显示区域高度 */
height: number;
/** 显示区域宽度 */
width?: number;
/** 每一项 Item 的高度 */
itemHeight: number;
/** 总数据 */
records: { [key: string]: any }[];
/** 数据中的那一项作为唯一标识 */
recordKeyName: string;
/** Item 渲染方法 */
renderItem: (record: IProps['records'][number]) => React.ReactNode;
/** 显示到末尾时的回调 */
onScrollToBottom: () => void;
/** 为了显示效果富余显示的 Item 数量上下各加 number, 建议是一个比 分页请求数量小的值 */
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%' }}
>
{/* pillar 负责撑开滚动列表的实际高度 最小值为 height + 1 视为了撑开一个滚动控件,防止一些边界情况导致的 onScrollToBottom 不触发 */}
<div className={VirtualLess.pillar} style={{ height: Math.max(listHeight, height + 1) }} />
{/* realList 通过定位展示在可视区域 */}
<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;
}
import AutoSizer from './AutoSizer';
import VirtualList from './VirtualList';
import { getData } from './MockRequest'; // 一个获得列表数据的mock 接口
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);