Appearance
虚拟列表
介绍
虚拟列表,其实就是将一个原本需要全部列表项的渲染的长列表,改为只渲染可视区域内的列表项,但滚动效果还是要和渲染所有列表项的长列表一样。
虚拟列表解决的长列表渲染大量节点导致的性能问题
一次性渲染大量节点,会占用大量 GPU 资源,导致卡顿;
即使渲染好了,大量的节点也持续占用内存。列表项下的节点越多,就越耗费性能。 我们会将计算出来的高度做成 style 对象以及一个索引值 index传入到这个组件里进行实例化。所以记得在列表项组件内接收它们并使用上它们,尤其是 style。
虚拟列表的实现分两种,一种是列表项高度固定的情况,另一种是列表项高度动态的情况。
列表项高度固定
思路讲解
- 列表项高度固定的情况会简单很多,因为我们可以在渲染前就能知道任何一个列表项的位置。
- 因为涉及到的变量很多,实现起来还是有点繁琐。
- 我们需要的必要信息有:
- 容器高度(即可视区域高度) containerHeight
- 列表长度(即列表项总数) itemCount
- 列表项尺寸 itemHeight
- 滚动位置 scrollTop
代码实现 一个将 items 往下推到正确位置的空元素
- 接收一个上面提到的几个数量和高度参数外,还接收一个列表项组件。
- 我们会将计算出来的高度做成 style 对象以及一个索引值 index传入到这个组件里进行实例化。所以记得在列表项组件内接收它们并使用上它们,尤其是 style。
jsx
/**
* 一个将 items 往下推到正确位置的空元素
*/
import { useState } from 'react';
import { flushSync } from 'react-dom';
function FixedSizeList({ containerHeight, itemHeight, itemCount, children }) {
// children 语义不好,赋值给 Component
const Component = children;
const contentHeight = itemHeight * itemCount; // 内容高度
const [scrollTop, setScrollTop] = useState(0); // 滚动高度
// 继续需要渲染的 item 索引有哪些
let startIdx = Math.floor(scrollTop / itemHeight);
let endIdx = Math.floor((scrollTop + containerHeight) / itemHeight);
// 上下额外多渲染几个 item,解决滚动时来不及加载元素出现短暂的空白区域的问题
const paddingCount = 2;
startIdx = Math.max(startIdx - paddingCount, 0); // 处理越界情况
endIdx = Math.min(endIdx + paddingCount, itemCount - 1);
const top = itemHeight * startIdx; // 第一个渲染 item 到顶部距离
// 需要渲染的 items
const items = [];
for (let i = startIdx; i <= endIdx; i++) {
items.push(<Component key={i} index={i} style={{ height: itemHeight }} />);
}
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => {
// 处理渲染异步导致的白屏现象
// 改为同步更新,但可能会有性能问题,可以做 节流 + RAF 优化
flushSync(() => {
setScrollTop(e.target.scrollTop);
});
}}
>
<div style={{ height: contentHeight }}>
{/* 一个将 items 往下推到正确位置的空元素 */}
<div style={{ height: top }}></div>
{items}
</div>
</div>
);
}
export default FixedSizeList;
代码实现 transform
jsx
/**
* transform 方案
*/
import { useState } from 'react';
import { flushSync } from 'react-dom';
function FixedSizeList({ containerHeight, itemHeight, itemCount, children }) {
// children 语义不好,赋值给 Component
const Component = children;
const contentHeight = itemHeight * itemCount; // 内容高度
const [scrollTop, setScrollTop] = useState(0); // 滚动高度
// 继续需要渲染的 item 索引有哪些
let startIdx = Math.floor(scrollTop / itemHeight);
let endIdx = Math.floor((scrollTop + containerHeight) / itemHeight);
// 上下额外多渲染几个 item,解决滚动时来不及加载元素出现短暂的空白区域的问题
const paddingCount = 2;
startIdx = Math.max(startIdx - paddingCount, 0); // 处理越界情况
endIdx = Math.min(endIdx + paddingCount, itemCount - 1);
const top = itemHeight * startIdx; // 第一个渲染 item 到顶部距离
// 需要渲染的 items
const items = [];
for (let i = startIdx; i <= endIdx; i++) {
items.push(<Component key={i} index={i} style={{ height: itemHeight }} />);
}
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => {
flushSync(() => {
setScrollTop(e.target.scrollTop);
});
}}
>
<div style={{ height: contentHeight }}>
<div style={{ transform: `translate3d(0px, ${top}px, 0` }}>{items}</div>
</div>
</div>
);
}
export default FixedSizeList;
代码实现 绝对定位方案
jsx
/**
* 绝对定位方案
*/
import { useState } from 'react';
import { flushSync } from 'react-dom';
function FixedSizeList({ containerHeight, itemHeight, itemCount, children }) {
// children 语义不好,赋值给 Component
const Component = children;
const contentHeight = itemHeight * itemCount; // 内容高度
const [scrollTop, setScrollTop] = useState(0); // 滚动高度
// 继续需要渲染的 item 索引有哪些
let startIdx = Math.floor(scrollTop / itemHeight);
let endIdx = Math.floor((scrollTop + containerHeight) / itemHeight);
// 上下额外多渲染几个 item,解决滚动时来不及加载元素出现短暂的空白区域的问题
const paddingCount = 2;
startIdx = Math.max(startIdx - paddingCount, 0); // 处理越界情况
endIdx = Math.min(endIdx + paddingCount, itemCount - 1);
const top = itemHeight * startIdx; // 第一个渲染 item 到顶部距离
// 需要渲染的 items
const items = [];
for (let i = startIdx; i <= endIdx; i++) {
items.push(
<Component
key={i}
index={i}
style={{
position: 'absolute',
left: 0,
top: i * itemHeight,
width: '100%',
height: itemHeight
}}
/>
);
}
return (
<div
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative'
}}
onScroll={(e) => {
flushSync(() => {
setScrollTop(e.target.scrollTop);
});
}}
>
<div style={{ height: contentHeight }}>{items}</div>
</div>
);
}
export default FixedSizeList;
使用示例
jsx
import FixedSizeList from './FixedSizeList';
import './styles.css';
/**
* 三种让 items 定位到正确位置的方案
* 可自行切换,感受 style 的不同
*
* FixedSizeList:一个将 items 往下推到正确位置的空元素
* FixedSizeList2:transform 方案
* FixedSizeList3:绝对定位方案
*
*/
function Item({ style, index }) {
return (
<div
className="item"
style={{
...style,
backgroundColor: index % 2 === 0 ? 'burlywood' : 'cadetblue'
}}
>
{index}
</div>
);
}
export default function App() {
const list = new Array(10000).fill(0).map((item, i) => i);
return (
<>
列表项高度固定 - 虚拟列表实现
<FixedSizeList
containerHeight={300}
itemCount={list.length}
itemHeight={50}
>
{Item}
</FixedSizeList>
</>
);
}
列表项高度动态
思路讲解
- 和列表项等高的实现不同,这里不能传一个固定值 itemHeight,改为传入一个根据 index 获取列表项宽度函数 getItemHeight(index)。
- 组件会通过这个函数,来拿到不同列表项的高度,来计算出 offsets 数组。offsets 是每个列表项的底边到顶部的距离。offsets 的作用是在滚动到特定位置时,计算出需要渲染的列表项有哪些。
- 当然你也可以用高度数组,但查找起来并没有优势,你需要累加。offsets 是 heights 的累加缓存结果(其实也就是前缀和)。
- 假设几个列表项的高度数组 heights 为 [10, 20, 40, 100],那么 offsets 就是 [10, 30, 70, 170]。一推导公式为:offsets[i] = offsets[i-1] + heights[i]
代码实现
jsx
import { forwardRef, useState } from 'react';
import { flushSync } from 'react-dom';
// 动态列表组件
const VariableSizeList = forwardRef(
({ containerHeight, getItemHeight, itemCount, itemData, children }, ref) => {
ref.current = {
resetHeight: () => {
setOffsets(genOffsets());
}
};
// children 语义不好,赋值给 Component
const Component = children;
const [scrollTop, setScrollTop] = useState(0); // 滚动高度
const genOffsets = () => {
const a = [];
a[0] = getItemHeight(0);
for (let i = 1; i < itemCount; i++) {
a[i] = getItemHeight(i) + a[i - 1];
}
return a;
};
// 所有 items 的位置
const [offsets, setOffsets] = useState(() => {
return genOffsets();
});
// 找 startIdx 和 endIdx
// 这里用了普通的查找,更好的方式是二分查找
let startIdx = offsets.findIndex((pos) => pos > scrollTop);
let endIdx = offsets.findIndex((pos) => pos > scrollTop + containerHeight);
if (endIdx === -1) endIdx = itemCount;
// 上下扩展补充几个 item
const paddingCount = 2;
startIdx = Math.max(startIdx - paddingCount, 0); // 处理越界情况
endIdx = Math.min(endIdx + paddingCount, itemCount - 1);
// 计算高度
const contentHeight = offsets[offsets.length - 1];
// 需要渲染的 items
const items = [];
for (let i = startIdx; i <= endIdx; i++) {
const top = i === 0 ? 0 : offsets[i - 1];
const height = i === 0 ? offsets[0] : offsets[i] - offsets[i - 1];
items.push(
<Component
key={i}
index={i}
style={{
position: 'absolute',
left: 0,
top,
width: '100%',
height
}}
data={itemData}
/>
);
}
return (
<div
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative'
}}
onScroll={(e) => {
flushSync(() => {
setScrollTop(e.target.scrollTop);
});
}}
>
<div style={{ height: contentHeight }}>{items}</div>
</div>
);
}
);
export default VariableSizeList;
使用示例
jsx
import { useEffect, useRef, useState } from 'react';
import VariableSizeList from './VariableSizeList';
// 列表项组件
function Item({ index, data, setHeight }) {
const itemRef = useRef();
useEffect(() => {
setHeight(index, itemRef.current.getBoundingClientRect().height);
}, [setHeight, index]);
return (
<div
ref={itemRef}
style={{
backgroundColor: index % 2 === 0 ? 'burlywood' : 'cadetblue'
}}
>
{data[index]}
</div>
);
}
export default function App() {
const [list, setList] = useState(
new Array(1000).fill(0).map(() => 1)
);
const listRef = useRef();
const heightsRef = useRef(new Array(100));
// 预估高度
const estimatedItemHeight = 40;
const getHeight = (index) => {
return heightsRef.current[index] ?? estimatedItemHeight;
};
const setHeight = (index, height) => {
if (heightsRef.current[index] !== height) {
heightsRef.current[index] = height;
// 让 VariableSizeList 组件更新高度
listRef.current.resetHeight();
}
};
return (
<>
列表项高度动态 - 虚拟列表实现
<VariableSizeList
ref={listRef}
containerHeight={300}
itemCount={list.length}
getItemHeight={getHeight}
itemData={list}
>
{({ index, style, data }) => {
return (
<div style={style}>
<Item {...{ index, data }} setHeight={setHeight} />
</div>
);
}}
</VariableSizeList>
</>
);
}