生成模拟数据
typescript
import { useState } from 'react';
import React from 'react';
import classNames from 'classnames/bind';
import styles from './index.module.less';
const cx = classNames.bind(styles);
export interface VirtualListProps {}
const VirtualList: React.FC<VirtualListProps> = () => {
const generateMockList = (length: number) => {
const getRandomHeight = () => 50 + Math.random() * 100;
const getRandomHex = () => Math.floor(Math.random() * 255).toString(16);
const getRandomColor = () => `#${getRandomHex()}${getRandomHex()}${getRandomHex()}`;
return new Array(length).fill(0).map((_, index) => {
return (
<div key={index} style={{ height: getRandomHeight(), background: getRandomColor(), fontSize: 20 }}>
{index + 1}
</div>
);
});
};
const [mockList] = useState(generateMockList(100000));
return (
<div className={cx('container')}>
{/* 生成10w条数据滚动体验还好,但是插入DOM的时间过长 */}
<div className={cx('virtual-list')}>
{mockList}
</div>
</div>
);
};
export default VirtualList;
大数据量产生的问题
- 当列表包含多媒体资源,直接拉取浪费带宽
- 一次性插入过多 DOM 节点导致页面卡顿
解决方案
在计算机领域,很多问题都可以通过引入中间层解决。那既然一次性插入非常多DOM节点会导致页面卡顿,那就想办法减少实际DOM数量。在这里我会通过代理模式,判断列表元素是否需要实际插入到页面中。
使用Chrome的图层进行调试可以发现有很多DOM节点都是不可见的,这些不可见的DOM就是即将被优化的对象。
计算可视区
首先要解决可视区域的计算问题,其实改变可视区的动作只有两种:
- 滚动容器的高度变化
- 滚动容器内部进行了滚动,scrollTop发生变化
scss
const [ref, size] = useSize(); // size.height为滚动框的高度
const [scrollTop, _setScrollTop] = useState(0);
const { run: setScrollTop } = useDebounceFn(_setScrollTop, 50); // 滚动框scrollTop需要防抖,否则拖拽滚动条会卡
那么可视区的高度范围为[scrollTop, scrollTop + size.height]
计算元素高度
得到可视区高度之后还需要得到元素高度,再根据元素高度得到可视区可展示的列表序号范围。为了拿到元素高度,可以设计一个组件专门用于得到元素高度。
typescript
import { useRef } from 'react';
import React, { useEffect } from 'react';
export interface ScrollItemProps {
setHeight: (h: number) => void;
children: any;
}
const ScrollItem: React.FC<ScrollItemProps> = ({ setHeight, children }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
setHeight(ref.current.scrollHeight);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div ref={ref}>{children}</div>;
};
export default ScrollItem;
ini
const [visibleMinIndex, setVisibleMinIndex] = useState(0);
const [visibleMaxIndex, setVisibleMaxIndex] = useState(10);
const itemsRef = useRef<number[]>([]); // 记录每个item的高度
const items = itemsRef.current;
// 这个items可能会等于
// 假设可视区范围是[300,500]
[
100,
120,
110, // 100 + 120 + 110 = 330 是第一个大于等于300的元素,则visibleMinIndex = 2
80,
90,
60, // 100 + 120 + 110 + 80 + 90 + 60 = 560 是第一个大于等于500的元素,则visibleMaxIndex = 5
40,
200
]
// 由此计算出可视范围内需要展示的序号
const listItemsToRender = listItems.slice(visibleMinIndex, visibleMaxIndex + 1).map((c, i) => {
const index = i + visibleMinIndex;
return (
<ScrollItem index={index} key={index}>
{c}
</ScrollItem>
);
});
填充占位区
不在可视区的DOM元素在清除后需要通过占位区给一个高度,否则会有滚动塌陷的问题。
ini
// 计算占位区的方式也非常简单,对可视区上、下区域的元素高度求和
const topPlaceholderHeight = sum(items.slice(0, visibleMinIndex));
const bottomPlaceholderHeight = sum(items.slice(visibleMaxIndex + 1));
<div {...props} ref={ref} onScroll={e => setScrollTop(e.currentTarget.scrollTop)}>
<div style={{ height: topPlaceholderHeight }}></div>
{listItemsToRender}
<div style={{ height: bottomPlaceholderHeight }}></div>
</div>
设置缓冲区
设置缓冲区有诸多好处:
- 假设用户滚动到底部了,如果有缓冲区则每次都可以多加载几个,可以一直滚动下去。否则可能需要对滚动到底部的操作做判断和处理。
- 用户在上下滚动时可以出现白屏的概率。
ini
useEffect(() => {
let height = 0;
let minIndex = -1;
let maxIndex = items.length - 1;
for (let i = 0; i < items.length; i++) {
const h = items[i];
height += h;
if (height >= scrollTop) {
if (minIndex === -1) minIndex = i;
}
if (height > size.height + scrollTop) {
maxIndex = i;
break;
}
}
setVisibleMinIndex(Math.max(0, minIndex - topBuffer)); // topBuffer上缓冲
setVisibleMaxIndex(maxIndex + bottomBuffer); // bottomBuffer下缓冲
}, [size.height, scrollTop, topBuffer, bottomBuffer, items]);
实现效果
ini
import { useRef } from 'react';
import { useEffect, useState } from 'react';
import React from 'react';
import { useDebounceFn, useSize } from '@byted/hooks';
import { sum } from 'lodash';
import ScrollItem from '../scroll-item';
export interface ScrollProps extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
listItems: any[];
topBuffer?: number;
bottomBuffer?: number;
}
const Scroll: React.FC<ScrollProps> = ({ listItems, topBuffer = 10, bottomBuffer = 10, ...props }) => {
const [ref, size] = useSize();
const [scrollTop, _setScrollTop] = useState(0);
const { run: setScrollTop } = useDebounceFn(_setScrollTop, 50);
const [visibleMinIndex, setVisibleMinIndex] = useState(0);
const [visibleMaxIndex, setVisibleMaxIndex] = useState(10);
const itemsRef = useRef<number[]>([]);
const items = itemsRef.current;
const topPlaceholderHeight = sum(items.slice(0, visibleMinIndex));
const bottomPlaceholderHeight = sum(items.slice(visibleMaxIndex + 1));
useEffect(() => {
let height = 0;
let minIndex = -1;
let maxIndex = items.length - 1;
for (let i = 0; i < items.length; i++) {
const h = items[i];
height += h;
if (height >= scrollTop) {
if (minIndex === -1) minIndex = i;
}
if (height > size.height + scrollTop) {
maxIndex = i;
break;
}
}
setVisibleMinIndex(Math.max(0, minIndex - topBuffer));
setVisibleMaxIndex(maxIndex + bottomBuffer);
}, [size.height, scrollTop, topBuffer, bottomBuffer, items]);
const listItemsToRender = listItems.slice(visibleMinIndex, visibleMaxIndex + 1).map((c, i) => {
const index = i + visibleMinIndex;
return (
<ScrollItem setHeight={h => (itemsRef.current[index] = h)} key={index}>
{c}
</ScrollItem>
);
});
return (
<div {...props} ref={ref} onScroll={e => setScrollTop(e.currentTarget.scrollTop)}>
<div style={{ height: topPlaceholderHeight }}></div>
{listItemsToRender}
<div style={{ height: bottomPlaceholderHeight }}></div>
</div>
);
};
export default Scroll;