列表渲染优化
问题
React 列表渲染如何优化?key 的作用是什么?什么是虚拟列表?
答案
React 列表渲染优化主要包括:正确使用 key、避免不必要的重渲染、虚拟列表技术。当列表数据量大或项目复杂时,优化尤为重要。
key 的作用与原理
key 的作用
key 帮助 React 识别列表项的身份,用于 Diff 算法优化:
// ✅ 使用稳定的 key
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
为什么不能用 index 作为 key?
// ❌ 使用 index 作为 key
function List({ items }: { items: string[] }) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>
<input defaultValue={item} />
</li>
))}
</ul>
);
}
问题场景:删除或插入元素时
| 操作 | 原列表 | 新列表 | 问题 |
|---|---|---|---|
| 删除第一个 | [A(0), B(1), C(2)] | [B(0), C(1)] | B 复用了 A 的 DOM,输入框内容错误 |
| 头部插入 | [A(0), B(1)] | [X(0), A(1), B(2)] | 所有元素都被"更新"而非移动 |
// ✅ 使用稳定唯一的 id
interface Item {
id: string;
text: string;
}
function List({ items }: { items: Item[] }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
<input defaultValue={item.text} />
</li>
))}
</ul>
);
}
key 的最佳实践
| key 选择 | 推荐度 | 原因 |
|---|---|---|
| 数据库 ID | ✅ 最佳 | 稳定且唯一 |
| UUID | ✅ 推荐 | 稳定且唯一 |
| 业务唯一字段 | ✅ 推荐 | 如用户名、商品编号 |
| 数组索引 | ⚠️ 有条件 | 仅当列表静态、不排序、不增删 |
| 随机数 | ❌ 禁止 | 每次渲染都不同,完全失效 |
避免列表项重渲染
问题:父组件更新导致所有列表项重渲染
// ❌ 父组件状态变化,所有 Item 都重渲染
function List() {
const [filter, setFilter] = useState('');
const [items, setItems] = useState<Item[]>([]);
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<ul>
{items.map(item => (
<ListItem
key={item.id}
item={item}
onClick={() => handleClick(item.id)} // 每次都是新函数
/>
))}
</ul>
</div>
);
}
解决方案 1:React.memo
interface ListItemProps {
item: Item;
onClick: () => void;
}
const ListItem = React.memo(function ListItem({ item, onClick }: ListItemProps) {
console.log('ListItem rendered:', item.id);
return (
<li onClick={onClick}>
{item.text}
</li>
);
});
解决方案 2:useCallback + id 传递
function List() {
const [items, setItems] = useState<Item[]>([]);
// ✅ 稳定的回调函数
const handleItemClick = useCallback((id: string) => {
console.log('Clicked:', id);
}, []);
return (
<ul>
{items.map(item => (
<ListItem
key={item.id}
item={item}
onItemClick={handleItemClick}
/>
))}
</ul>
);
}
interface ListItemProps {
item: Item;
onItemClick: (id: string) => void;
}
const ListItem = React.memo(function ListItem({ item, onItemClick }: ListItemProps) {
// ✅ 在子组件内部处理 id
const handleClick = () => onItemClick(item.id);
return <li onClick={handleClick}>{item.text}</li>;
});
解决方案 3:拆分组件,隔离状态
// ✅ 将搜索逻辑拆分出去
function SearchInput({ onSearch }: { onSearch: (value: string) => void }) {
const [filter, setFilter] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
onSearch(e.target.value);
};
return <input value={filter} onChange={handleChange} />;
}
function List() {
const [items] = useState<Item[]>([]);
// SearchInput 状态变化不会导致 List 重渲染
return (
<div>
<SearchInput onSearch={handleSearch} />
<ItemList items={items} />
</div>
);
}
虚拟列表(Virtual List)
为什么需要虚拟列表?
| 场景 | DOM 数量 | 内存占用 | 性能 |
|---|---|---|---|
| 100 条 | 可接受 | 低 | 无需优化 |
| 1000 条 | 多 | 中 | 建议虚拟化 |
| 10000+ 条 | 极多 | 高 | 必须虚拟化 |
虚拟列表原理
// 虚拟列表核心原理
interface VirtualListProps {
items: any[];
itemHeight: number;
containerHeight: number;
}
function VirtualList({ items, itemHeight, containerHeight }: VirtualListProps) {
const [scrollTop, setScrollTop] = useState(0);
// 1. 计算可见区域的起止索引
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);
// 2. 只取可见区域的数据
const visibleItems = items.slice(startIndex, endIndex);
// 3. 计算偏移量,用于定位
const offsetY = startIndex * itemHeight;
// 4. 总高度(用于滚动条)
const totalHeight = items.length * itemHeight;
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
>
{/* 占位元素,撑开滚动高度 */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* 可见内容容器,通过 transform 定位 */}
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, index) => (
<div key={startIndex + index} style={{ height: itemHeight }}>
{item.text}
</div>
))}
</div>
</div>
</div>
);
}
使用 react-window
import { FixedSizeList, ListChildComponentProps } from 'react-window';
interface Item {
id: string;
text: string;
}
// 列表项组件
const Row = React.memo(function Row({
index,
style,
data
}: ListChildComponentProps<Item[]>) {
const item = data[index];
return (
<div style={style} className="list-item">
{item.text}
</div>
);
});
// 虚拟列表
function VirtualList({ items }: { items: Item[] }) {
return (
<FixedSizeList
height={400} // 容器高度
width="100%" // 容器宽度
itemCount={items.length}
itemSize={50} // 每项高度
itemData={items} // 传递数据
>
{Row}
</FixedSizeList>
);
}
动态高度列表
import { VariableSizeList } from 'react-window';
function DynamicList({ items }: { items: Item[] }) {
const listRef = useRef<VariableSizeList>(null);
// 获取每项高度
const getItemSize = (index: number) => {
// 根据内容计算高度
return items[index].text.length > 100 ? 100 : 50;
};
return (
<VariableSizeList
ref={listRef}
height={400}
width="100%"
itemCount={items.length}
itemSize={getItemSize}
itemData={items}
>
{Row}
</VariableSizeList>
);
}
react-virtuoso
更强大的虚拟列表库,支持动态高度、分组、无限加载等:
import { Virtuoso } from 'react-virtuoso';
function VirtuosoList({ items }: { items: Item[] }) {
return (
<Virtuoso
style={{ height: 400 }}
data={items}
itemContent={(index, item) => (
<div className="list-item">
{item.text}
</div>
)}
// 支持自动高度
overscan={10}
// 支持无限加载
endReached={() => loadMore()}
/>
);
}
其他优化技术
分页加载
function PaginatedList() {
const [page, setPage] = useState(1);
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const loadMore = async () => {
setLoading(true);
const newItems = await fetchItems(page);
setItems(prev => [...prev, ...newItems]);
setPage(p => p + 1);
setLoading(false);
};
return (
<div>
<ul>
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
<button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
</div>
);
}
无限滚动
import { useInView } from 'react-intersection-observer';
function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const { ref, inView } = useInView();
useEffect(() => {
if (inView) {
loadMore();
}
}, [inView]);
const loadMore = async () => {
const newItems = await fetchItems(page);
setItems(prev => [...prev, ...newItems]);
setPage(p => p + 1);
};
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
{/* 哨兵元素 */}
<li ref={ref}>Loading...</li>
</ul>
);
}
骨架屏占位
function ListWithSkeleton() {
const { data: items, isLoading } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
});
if (isLoading) {
return (
<ul>
{Array.from({ length: 10 }).map((_, i) => (
<li key={i} className="skeleton-item">
<div className="skeleton-avatar" />
<div className="skeleton-text" />
</li>
))}
</ul>
);
}
return (
<ul>
{items?.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
}
常见面试问题
Q1: 为什么列表需要 key?
答案:
key 帮助 React 在 Diff 算法中识别元素身份,优化列表更新:
- 没有 key:React 按顺序比较,增删元素时可能导致大量不必要的 DOM 操作
- 有 key:React 通过 key 匹配新旧元素,只操作真正变化的元素
// 删除第一个元素
// 旧: [A, B, C]
// 新: [B, C]
// 没有 key:A→B, B→C, 删除 C(3次操作)
// 有 key:删除 A(1次操作)
Q2: 用 index 作为 key 有什么问题?
答案:
当列表会重新排序、插入、删除时,用 index 作为 key 会导致:
- 性能问题:无法正确复用 DOM,导致大量不必要的更新
- 状态错误:组件内部状态会"错位"
// 原列表:[{id:1, text:'A'}, {id:2, text:'B'}]
// key: [0, 1]
// 删除第一个后:[{id:2, text:'B'}]
// key: [0] // B 复用了 A 的 DOM!
// 如果 A 的输入框有内容,B 会错误地继承
安全使用 index 的条件:
- 列表是静态的,不会变化
- 不会重新排序
- 不会从中间增删元素
Q3: 什么是虚拟列表?如何实现?
答案:
虚拟列表只渲染可见区域的列表项,而不是全部渲染。
核心原理:
// 1. 计算可见范围
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + Math.ceil(containerHeight / itemHeight);
// 2. 只渲染可见项
const visibleItems = items.slice(startIndex, endIndex);
// 3. 用 transform/padding 定位
<div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
{visibleItems.map(item => ...)}
</div>
// 4. 撑开滚动高度
<div style={{ height: items.length * itemHeight }} />
常用库:
react-window:轻量级,性能好react-virtuoso:功能丰富,支持动态高度@tanstack/react-virtual:Headless,灵活性高
Q4: 如何优化长列表的重渲染性能?
答案:
- 使用 React.memo 包裹列表项
const ListItem = React.memo(function ListItem({ item }) {
return <div>{item.text}</div>;
});
- 稳定的 key 和 props
const handleClick = useCallback((id) => {}, []);
const style = useMemo(() => ({ color: 'red' }), []);
- 避免内联函数
// ❌
<Item onClick={() => handleClick(item.id)} />
// ✅
<Item onClick={handleClick} itemId={item.id} />
- 状态下沉
// 将不需要提升的状态放在子组件
function ListItem({ item }) {
const [isHovered, setIsHovered] = useState(false);
// ...
}
- 虚拟列表
<FixedSizeList height={400} itemCount={10000} itemSize={50}>
{Row}
</FixedSizeList>
Q5: 列表渲染时如何避免闪烁?
答案:
- 使用骨架屏
{isLoading ? <SkeletonList /> : <RealList items={items} />}
- 保持列表项高度稳定
.list-item {
min-height: 60px; /* 固定最小高度 */
}
- 图片懒加载 + 占位
<img
src={item.thumbnail}
loading="lazy"
style={{ aspectRatio: '16/9' }} // 预留空间
/>
- 使用 key 保持稳定性
// ✅ 稳定的 key
<Item key={item.id} />
// ❌ 不稳定的 key
<Item key={Math.random()} />
- 启用 React Concurrent 特性
import { useTransition } from 'react';
function List() {
const [isPending, startTransition] = useTransition();
const handleFilter = (value: string) => {
startTransition(() => {
setFilter(value);
});
};
return (
<>
<input onChange={e => handleFilter(e.target.value)} />
{isPending ? <Spinner /> : <Items />}
</>
);
}