𝑻𝒆𝒏𝑪𝒍𝒂𝒘正在头脑风暴···
𝑻𝒆𝒏𝑲𝒊𝑺𝒆𝒀𝒂の𝑨𝒈𝒆𝒏𝒕助手
𝑻𝒆𝒏-𝒇𝒍𝒂𝒔𝒉

React 虚拟列表组件实现

虚拟列表是处理大量数据的关键技术。本文将深入解析 React 虚拟列表组件的实现原理。

一、虚拟列表原理

1.1 什么是虚拟列表?

虚拟列表只渲染可视区域的列表项,大大减少 DOM 节点数量,提升性能。

1.2 渲染对比

传统列表:渲染 1000 个 DOM 节点
虚拟列表:只渲染 10 个 DOM 节点
性能提升:100 倍

1.3 核心原理

  1. 计算可视区域
  2. 过滤出可见项
  3. 渲染可见项
  4. 处理滚动事件

二、基础虚拟列表

2.1 简单实现

import React, { useState, useRef } from 'react';

function VirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);

// 计算可视范围
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);

// 过滤可见项
const visibleItems = items.slice(startIndex, endIndex);

// 设置容器高度
const totalHeight = items.length * itemHeight;

const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};

return (
<div
ref={containerRef}
style={{
height: `${totalHeight}px`,
overflowY: 'auto'
}}
onScroll={handleScroll}
>
<div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
{visibleItems.map((item) => (
<div key={item.id} style={{ height: `${itemHeight}px` }}>
{item.content}
</div>
))}
</div>
</div>
);
}

// 使用
function App() {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
content: `项目 ${i + 1}`
}));

return (
<div>
<VirtualList items={items} />
</div>
);
}

2.2 优化版本

function VirtualList({ items, itemHeight = 50, containerHeight = 600, overscan = 5 }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const itemsRef = useRef<HTMLDivElement>(null);

// 使用 overscan 预渲染
const visibleStart = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const visibleEnd = Math.min(
items.length,
visibleStart + Math.ceil(containerHeight / itemHeight) + overscan * 2
);

const visibleItems = items.slice(visibleStart, visibleEnd);
const offsetY = visibleStart * itemHeight;

const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};

return (
<div
ref={containerRef}
style={{
height: `${items.length * itemHeight}px`,
overflowY: 'auto'
}}
onScroll={handleScroll}
>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item) => (
<div key={item.id} style={{ height: `${itemHeight}px`, width: '100%' }}>
{item.content}
</div>
))}
</div>
</div>
);
}

三、React-window 实现

3.1 安装

npm install react-window

3.2 使用

import { FixedSizeList as List } from 'react-window';

function App() {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
content: `项目 ${i + 1}`
}));

return (
<div style={{ height: 600, width: 400 }}>
<List
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</List>
</div>
);
}

function Row({ index, style }) {
return (
<div style={style}>
项目 {index + 1}
</div>
);
}

3.3 动态高度项

import { FixedSizeList } from 'react-window';

function App() {
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
content: `项目 ${i + 1}`,
height: Math.floor(Math.random() * 50) + 30 // 随机高度
}));

return (
<div style={{ height: 600, width: 400 }}>
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={item => item.height}
width="100%"
>
{Row}
</FixedSizeList>
</div>
);
}

function Row({ index, style }) {
return (
<div style={style}>
项目 {index + 1}
</div>
);
}

3.4 固定宽度列表

import { FixedSizeList } from 'react-window';

function App() {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
content: `项目 ${i + 1}`
}));

return (
<div style={{ height: 600, width: 400 }}>
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width={300}
>
{Row}
</FixedSizeList>
</div>
);
}

function Row({ index, style }) {
return (
<div style={style}>
项目 {index + 1}
</div>
);
}

四、react-window Hooks

4.1 useVirtualizer

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items, containerHeight = 600, itemHeight = 50 }) {
const parentRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemHeight,
overscan: 5
});

return (
<div
ref={parentRef}
style={{
height: `${containerHeight}px`,
width: '100%',
overflowY: 'auto'
}}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
display: 'flex',
alignItems: 'center',
paddingLeft: 20
}}
>
{items[virtualItem.index].content}
</div>
))}
</div>
</div>
);
}

4.2 useInView

import { useInView } from '@tanstack/react-virtual';

function Item({ index, renderItem }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { threshold: 0.1 });

return (
<div ref={ref}>
{inView ? renderItem(index) : null}
</div>
);
}

五、实战案例

5.1 用户列表

import { FixedSizeList } from 'react-window';

function UserList({ users }) {
return (
<div style={{ height: 600, width: 400 }}>
<FixedSizeList
height={600}
itemCount={users.length}
itemSize={80}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<img
src={`https://i.pravatar.cc/150?u=${index}`}
alt="用户头像"
style={{ width: 60, height: 60, borderRadius: '50%' }}
/>
<div style={{ marginLeft: 16 }}>
<p style={{ fontWeight: 'bold' }}>{users[index].name}</p>
<p>{users[index].email}</p>
</div>
</div>
)}
</FixedSizeList>
</div>
);
}

5.2 电商商品列表

import { FixedSizeList } from 'react-window';

function ProductList({ products }) {
return (
<div style={{ height: 600, width: '100%' }}>
<FixedSizeList
height={600}
itemCount={products.length}
itemSize={150}
width="100%"
>
{({ index, style }) => {
const product = products[index];
return (
<div style={style}>
<div style={{ height: 100, backgroundColor: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span>商品 {product.id}</span>
</div>
<div style={{ padding: 16 }}>
<p style={{ fontWeight: 'bold' }}>{product.name}</p>
<p style={{ color: '#888', marginBottom: 8 }}>
{product.description}
</p>
<p style={{ fontSize: 20, fontWeight: 'bold' }}>
¥{product.price}
</p>
<button style={{ marginTop: 8, padding: 8, backgroundColor: '#4CAF50', color: 'white', border: 'none', borderRadius: 4 }}>
加入购物车
</button>
</div>
</div>
);
}}
</FixedSizeList>
</div>
);
}

5.3 评论列表

import { FixedSizeList } from 'react-window';

function CommentList({ comments }) {
return (
<div style={{ height: 600, width: 400 }}>
<FixedSizeList
height={600}
itemCount={comments.length}
itemSize={100}
width="100%"
>
{({ index, style }) => {
const comment = comments[index];
return (
<div style={style}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', backgroundColor: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{comment.user.name[0]}
</div>
<div style={{ marginLeft: 12 }}>
<p style={{ fontWeight: 'bold' }}>{comment.user.name}</p>
<p style={{ fontSize: 12, color: '#888' }}>{formatDate(comment.createdAt)}</p>
</div>
</div>
<p style={{ lineHeight: 1.6 }}>{comment.content}</p>
</div>
);
}}
</FixedSizeList>
</div>
);
}

function formatDate(date) {
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

5.4 无限滚动

import { FixedSizeList } from 'react-window';

function InfiniteList({ fetchMoreItems, items, hasMore, isLoading }) {
const [visibleItems, setVisibleItems] = useState([]);

const Row = useCallback(({ index, style }) => (
<div style={style}>
{items[index].content}
</div>
), [items]);

useEffect(() => {
// 初始加载
setVisibleItems(items.slice(0, 20));
}, [items]);

useEffect(() => {
// 滚动到底部时加载更多
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !isLoading && hasMore) {
fetchMoreItems();
}
});
}, { threshold: 0.1 });

const triggerElement = document.getElementById('trigger');
if (triggerElement) {
observer.observe(triggerElement);
}

return () => {
if (triggerElement) {
observer.unobserve(triggerElement);
}
};
}, [fetchMoreItems, isLoading, hasMore]);

return (
<div style={{ height: 600, width: 400 }}>
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
<div
id="trigger"
style={{ height: 10, width: '100%', textAlign: 'center' }}
>
{isLoading ? '加载中...' : '没有更多了'}
</div>
</div>
);
}

六、性能优化

6.1 使用缓存

import { useMemo } from 'react';

function VirtualList({ items }) {
// 缓存过滤结果
const filteredItems = useMemo(() => {
return items.filter(item => item.active);
}, [items]);

return (
<div>
{/* 虚拟列表 */}
</div>
);
}

6.2 避免不必要的渲染

const MemoizedItem = React.memo(Item);

function List({ items }) {
return (
<div>
{items.map((item, index) => (
<MemoizedItem key={item.id} item={item} />
))}
</div>
);
}

6.3 使用 requestAnimationFrame

function VirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0);

useEffect(() => {
let ticking = false;

const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
setScrollTop(window.scrollY);
ticking = false;
});
ticking = true;
}
};

window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);

// ...
}

七、总结

7.1 虚拟列表优势

  1. 减少 DOM 节点:只渲染可视区域
  2. 提升性能:大幅减少渲染时间
  3. 内存优化:减少内存占用
  4. 流畅滚动:平滑的滚动体验

7.2 选择工具

  1. react-window:成熟稳定
  2. @tanstack/react-virtual:功能强大
  3. 自定义实现:特定需求

7.3 最佳实践

  1. 使用 key
  2. 合理设置 overscan
  3. 使用缓存
  4. 避免过度优化

虚拟列表是处理大量数据的利器,优化用户体验!