跳到主要内容

列表渲染优化

问题

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 算法中识别元素身份,优化列表更新:

  1. 没有 key:React 按顺序比较,增删元素时可能导致大量不必要的 DOM 操作
  2. 有 key:React 通过 key 匹配新旧元素,只操作真正变化的元素
// 删除第一个元素
// 旧: [A, B, C]
// 新: [B, C]

// 没有 key:A→B, B→C, 删除 C(3次操作)
// 有 key:删除 A(1次操作)

Q2: 用 index 作为 key 有什么问题?

答案

列表会重新排序、插入、删除时,用 index 作为 key 会导致:

  1. 性能问题:无法正确复用 DOM,导致大量不必要的更新
  2. 状态错误:组件内部状态会"错位"
// 原列表:[{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: 如何优化长列表的重渲染性能?

答案

  1. 使用 React.memo 包裹列表项
const ListItem = React.memo(function ListItem({ item }) {
return <div>{item.text}</div>;
});
  1. 稳定的 key 和 props
const handleClick = useCallback((id) => {}, []);
const style = useMemo(() => ({ color: 'red' }), []);
  1. 避免内联函数
// ❌
<Item onClick={() => handleClick(item.id)} />

// ✅
<Item onClick={handleClick} itemId={item.id} />
  1. 状态下沉
// 将不需要提升的状态放在子组件
function ListItem({ item }) {
const [isHovered, setIsHovered] = useState(false);
// ...
}
  1. 虚拟列表
<FixedSizeList height={400} itemCount={10000} itemSize={50}>
{Row}
</FixedSizeList>

Q5: 列表渲染时如何避免闪烁?

答案

  1. 使用骨架屏
{isLoading ? <SkeletonList /> : <RealList items={items} />}
  1. 保持列表项高度稳定
.list-item {
min-height: 60px; /* 固定最小高度 */
}
  1. 图片懒加载 + 占位
<img 
src={item.thumbnail}
loading="lazy"
style={{ aspectRatio: '16/9' }} // 预留空间
/>
  1. 使用 key 保持稳定性
// ✅ 稳定的 key
<Item key={item.id} />

// ❌ 不稳定的 key
<Item key={Math.random()} />
  1. 启用 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 />}
</>
);
}

相关链接