思路解析
一次只渲染部分可见的数据:根据可滚动元素滚动的距离scrollTop
来计算当前应该渲染哪些数据。
代码实现
原生HTML实现
🪶 index.html
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<link rel="stylesheet" type="text/css" href="./index.css" />
</head>
<body>
<div class="wrapper" onscroll="handleScroll(this)">
<div id="infinite-wrapper"></div>
<ul id="list"></ul>
</div>
<script src="./index.js"></script>
</body>
</html>
- 外层容器
wrapper
作为可视窗口,并监听处理滚动事件 infinite-wrapper
作为影子元素,作为真实可滚动的元素,其高度等同于所有元素高度之和,用于撑开容器形成滚动条list
作为元素渲染区域
🌷 index.css
* {
margin: 0;
padding: 0;
}
.wrapper {
position: relative;
margin: 200px auto;
height: 300px;
width: 500px;
border: 1px solid #eee;
overflow-y: scroll;
}
#infinite-wrapper {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
}
#list {
position: absolute;
left: 0;
list-style: none;
padding: 0;
width: 100%;
}
#list > li {
height: 30px;
width: 100%;
}
#list > li.green-item {
background-color: #c5ffc5;
}
#list > li.red-item {
background-color: #ffd5d5;
}
🍬 index.js
// 模拟数据构造
const arr = [];
const nameArr = ['Alice', 'July', 'Roman', 'David', 'Sara', 'Lisa', 'Mike'];
for (let i = 0; i < 10000; i++) {
arr.push({
number: i + 1,
name: `${nameArr[i % nameArr.length]}`,
});
}
const wrapper = document.getElementById('infinite-wrapper'); //滚动监听容器
const container = document.getElementById('list'); //可视数据容器
const itemSize = 30, //每个元素的高度
itemCount = 10, //可视窗口展示的条数
bufferCount = 3, //缓存元素数量
bufferSize = bufferCount * itemSize; //缓存区大小
// 根据数据量设置容器总高度
wrapper.style.height = `${arr.length * itemSize}px`;
/**
* @method handleScroll
* @description: 滚动事件监听:获取scrollTop,根据它获取数据,偏移可视窗口容器
* @param {HTMLBodyElement} element scrollTop的元素
*/
const handleScroll = (element) => {
const { scrollTop } = element;
// 计算当前数据起止index
const _startIndex = Math.floor(scrollTop / itemSize) - bufferCount;
const _endIndex = _startIndex + itemCount + bufferCount * 2;
// 起止index映射到数据中
const startIndex = Math.max(_startIndex, 0);
const endIndex = Math.min(_endIndex, arr.length);
renderList(arr.slice(startIndex, endIndex));
// 可视窗口容器偏移
container.style.transform = `translateY(${
_startIndex < 0 ? 0 : scrollTop - bufferSize - (scrollTop % itemSize)
}px)`;
};
/**
* @method renderList
* @description: 渲染可视数据
* @param {Array} data 筛选后的数据,可直接加载显示的数据
*/
const renderList = (data) => {
//文档片段
const fragment = document.createDocumentFragment();
data.forEach((item) => {
const li = document.createElement('li');
li.className = item.number % 2 === 0 ? 'green-item' : 'red-item'; //奇偶行元素不同色
const text = document.createTextNode(
`${`${item.number}`.padStart(7, '0')}-${item.name}`
);
li.appendChild(text);
fragment.appendChild(li);
});
// 移除所有节点
Array.from(container.children).forEach((item) => container.removeChild(item));
//重新添加子节点
container.appendChild(fragment);
};
// 可视数据初始化
renderList(arr.slice(0, itemCount + bufferCount));
迁移到React
🌴 store/data.ts
/**
* @description: 模拟数据构造
* @param {number} total
* @return {Array<IDataItem>}
*/
export const getData = (
total: number = 1000
): Array<any> => {
const arr = [];
const nameArr = ['Alice', 'July', 'Roman', 'David', 'Sara', 'Lisa', 'Mike'];
for (let i = 0; i < total; i++) {
arr.push({
number: i + 1,
name: `${nameArr[i % nameArr.length]}`
});
}
return arr;
};
🎄 VirtualizedList.tsx
/*
* @Description: 虚拟滚动列表 - 元素高度固定
* @Author: leewentao
* @Date: 2021-12-07 15:20:58
* @LastEditors: leewentao
* @LastEditTime: 2021-12-13 11:18:44
*/
import React, {
FC,
useCallback,
useEffect,
useReducer,
useRef,
useMemo,
} from 'react';
import { IDataItem, IStyleState } from './interface';
import { getData } from '../store/data';
import styles from './index.module.css';
export type IProps = {
itemSize?: number;
itemCount?: number;
bufferCount?: number;
listData: Array<IDataItem>;
};
const FixedHeightList: FC<IProps> = ({
itemSize = 30,
itemCount = 10,
bufferCount = 3,
listData = [],
}: IProps) => {
const allDataRef: any = useRef([]); // 汇总数据
const scrollRef: any = useRef(null); // 滚动元素:用来获取scrollTop
// 缓存区大小
const bufferSize = useMemo(
() => bufferCount * itemSize,
[bufferCount, itemSize]
);
const [styleState, dispatchStyle] = useReducer(
(state: Object, action: any): IStyleState => {
return { ...state, ...action.payload };
},
{
scrollHeight: 0,
visibleHeight: itemCount * itemSize,
offset: 0,
listData: [],
}
);
/**
* @description: 监听滚动
* @param {*}
* @return {*}
*/
const handleScroll = useCallback(() => {
const { scrollTop } = scrollRef.current;
// 优化:滚动超过一条元素大小时再重新调整数据(bufferSize区域无效)
if (Math.abs(scrollTop - styleState.offset - bufferSize) > itemSize) {
console.log(scrollTop - styleState.offset - bufferSize);
const { current: data } = allDataRef;
// 计算当前数据起止index
const _startIndex = Math.floor(scrollTop / itemSize) - bufferCount;
const _endIndex = _startIndex + itemCount + bufferCount * 2;
// 起止index映射到数据中
const startIndex = Math.max(_startIndex, 0);
const endIndex = Math.min(_endIndex, data.length);
console.log('rendering');
dispatchStyle({
payload: {
listData: data.slice(startIndex, endIndex),
offset:
_startIndex < 0
? 0
: scrollTop - bufferSize - (scrollTop % itemSize), //设置可视窗口偏移量
},
});
}
}, [bufferCount, bufferSize, itemCount, itemSize, styleState.offset]);
useEffect(() => {
allDataRef.current = listData.length ? listData : getData();
const { current: data } = allDataRef;
dispatchStyle({
payload: {
listData: data.slice(0, itemCount + 1),
scrollHeight: data.length * itemSize,
},
});
}, [itemCount, itemSize, listData]);
return (
<div
className={styles['wrapper']}
style={{ height: styleState.visibleHeight }}
onScroll={handleScroll}
ref={scrollRef}
>
<div
className={styles[`list-phantom`]}
style={{ height: styleState.scrollHeight }}
></div>
<ul
style={{
transform: `translateY(${styleState.offset}px)`,
}}
className={styles[`list`]}
>
{styleState.listData.map(({ number, name }) => (
<li
key={number}
style={{ height: itemSize, lineHeight: `${itemSize}px` }}
className={
number % 2 === 0 ? styles[`green-item`] : styles[`red-item`]
}
>
{number} - {name}
</li>
))}
</ul>
</div>
);
};
export default FixedHeightList;
🌾 interface.ts
interface IDataItem {
number: number;
name: string;
}
interface IStyleState {
scrollHeight: number;
visibleHeight: number;
offset: number;
listData: Array<IDataItem>;
}
export type { IDataItem, IStyleState };
🌷 index.module.css
* {
margin: 0;
padding: 0;
}
.wrapper {
position: relative;
margin: 200px auto;
width: 500px;
border: 1px solid #eee;
overflow-y: scroll;
scroll-behavior: smooth;
transition: all ease-in-out 3ms;
}
/* 用来撑开容器,形成滚动条 */
.list-phantom {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
}
.list {
position: absolute;
left: 0;
list-style: none;
padding: 0;
width: 100%;
}
.list > li {
width: 100%;
}
.list > li.green-item {
background-color: #d9fdd9;
}
.list > li.red-item {
background-color: #ffebeb;
}
更优秀的实现方式
React-Window
react-window
是较为优秀的虚拟列表组件,支持 高度固定
高度可变
的列表List
和表格Grid
。还支持懒加载等功能。
参考文档
react-window 参考文档
react-window - github.com
相关链接
https://github.com/bvaughn/react-virtualized-auto-sizer
https://github.com/bvaughn/react-window-infinite-loader