React 虚拟列表组件实现
虚拟列表是处理大量数据的关键技术。本文将深入解析 React 虚拟列表组件的实现原理。
一、虚拟列表原理
1.1 什么是虚拟列表?
虚拟列表只渲染可视区域的列表项,大大减少 DOM 节点数量,提升性能。
1.2 渲染对比
传统列表:渲染 1000 个 DOM 节点 虚拟列表:只渲染 10 个 DOM 节点 性能提升:100 倍
|
1.3 核心原理
- 计算可视区域
- 过滤出可见项
- 渲染可见项
- 处理滚动事件
二、基础虚拟列表
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);
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 安装
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 虚拟列表优势
- 减少 DOM 节点:只渲染可视区域
- 提升性能:大幅减少渲染时间
- 内存优化:减少内存占用
- 流畅滚动:平滑的滚动体验
7.2 选择工具
- react-window:成熟稳定
- @tanstack/react-virtual:功能强大
- 自定义实现:特定需求
7.3 最佳实践
- 使用 key
- 合理设置 overscan
- 使用缓存
- 避免过度优化
虚拟列表是处理大量数据的利器,优化用户体验!