大家好,我是你们的老朋友掘金创作者FogLetter。今天我们来聊聊一个既常见又让人头疼的问题------海量数据渲染。
想象一下这个场景:产品经理兴冲冲跑过来:"我们要做个消息中心,用户可能有几万条消息,要保证流畅滚动哦!" 你内心OS:"一次性渲染10万条数据?这不是要浏览器老命吗!"
别急,虚拟列表就是为此而生的性能救星!
从一次惨痛经历说起
小明刚入行时,接到一个需求:在后台管理系统展示用户操作日志。测试时数据不多,一切正常。上线后,随着数据积累,有一天运营同事反馈:"页面卡得动不了啦!"
打开开发者工具一看,好家伙,10万条日志记录,页面元素多达10万个DOM节点。浏览器内存占用直奔2GB,不卡才怪!
html
<!-- 这就是我们曾经的噩梦 -->
<ul id="container">
<li>日志记录1</li>
<li>日志记录2</li>
<!-- ... 省略99997个li ... -->
<li>日志记录100000</li>
</ul>
<script>
// 创建10万条数据
let now = Date.now();
const total = 100000;
let ul = document.getElementById('container');
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerText = `操作日志 ${i}: 用户于${new Date()}执行了操作`;
ul.appendChild(li);
}
console.log('JS运行时间', Date.now() - now); // 输出:JS运行时间 3250ms
</script>
这种暴力渲染的方式,JS执行就要3秒多,这还不算后续的样式计算、布局、绘制时间。用户在这期间只能面对白屏干瞪眼。
为什么海量数据会卡?
要理解虚拟列表,先得明白瓶颈在哪:
1. JS执行时间过长
创建10万个DOM元素,JS需要同步执行很久,阻塞了事件循环。
2. 内存占用巨大
每个DOM元素都要占用内存,10万个LI元素轻松吃掉几百MB内存。
3. 渲染性能瓶颈
浏览器需要计算10万个元素的样式、布局,每次重排重绘都是巨大开销。
4. 事件监听器负担
如果每个列表项都有交互,事件委托还好,要是每个都绑事件...恭喜你,卡顿大礼包已送达!
解决方案演进史
第一代方案:时间分片
既然一次性渲染太卡,那我们分批渲染不就行了?
html
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
let total = 100000;
let once = 20; // 每次渲染20条
let page = total / once;
let index = 0;
function loop(curTotal, curIndex) {
if (curTotal <= 0) return;
let pageCount = Math.min(curTotal, once);
// 使用setTimeout分批次执行
setTimeout(() => {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ':' + (Math.random() * total);
ul.appendChild(li);
}
loop(curTotal - pageCount, curIndex + pageCount);
}, 0);
}
loop(total, index);
</script>
进步: 确实不卡死浏览器了,但会有明显的"白屏-出现-白屏-出现"的闪烁现象。
第二代方案:requestAnimationFrame + DocumentFragment
html
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
let total = 100000;
let once = 20;
let page = total / once;
let index = 0;
function loop(curTotal, curIndex) {
if (curTotal <= 0) return;
let pageCount = Math.min(curTotal, once);
// 使用requestAnimationFrame在浏览器重绘前执行
requestAnimationFrame(function() {
// 使用DocumentFragment减少重排次数
let fragment = document.createDocumentFragment();
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ':' + (Math.random() * total);
fragment.appendChild(li);
}
ul.appendChild(fragment);
loop(curTotal - pageCount, curIndex + pageCount);
});
}
loop(total, index);
</script>
进步: 动画更流畅,减少了布局抖动,但本质上还是在渲染所有数据,内存问题没解决。
终极方案:虚拟列表
虚拟列表的核心思想很简单:只渲染可视区域的内容。
好比你家有个100层的书架,但你只能看到眼前的5层。虚拟列表就像个聪明的图书管理员,你往下滑动时,他把下面的书拿上来,把上面的书收起来,始终保持你眼前只有那几本书。
虚拟列表三要素
- 容器高度 - 你能看到的区域高度
- 滚动位置 - 你现在看到哪了
- 项目高度 - 每本书的厚度(可以是固定或动态)
React虚拟列表实现
让我们手写一个精简版虚拟列表:
jsx
import { useState, useRef, useMemo } from 'react';
const VirtualList = ({
data,
height,
itemHeight,
renderItem,
overscan = 3
}) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
// 计算可见区域
const visibleRange = useMemo(() => {
// 开始索引:滚动距离 / 项目高度
const startIndex = Math.floor(scrollTop / itemHeight);
// 结束索引:开始索引 + 可见项目数
const visibleItemsCount = Math.ceil(height / itemHeight);
const endIndex = startIndex + visibleItemsCount;
// 考虑预渲染(overscan)避免滚动时白屏
const overscanStart = Math.max(0, startIndex - overscan);
const overscanEnd = Math.min(data.length, endIndex + overscan);
return {
startIndex: overscanStart,
endIndex: overscanEnd,
offset: overscanStart * itemHeight
};
}, [scrollTop, height, itemHeight, data.length, overscan]);
const onScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
const totalHeight = data.length * itemHeight;
return (
<div
ref={containerRef}
onScroll={onScroll}
style={{
height,
overflowY: 'auto',
position: 'relative',
willChange: 'transform', // 性能优化提示
}}
>
{/* 撑开容器高度的占位元素 */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* 实际渲染的内容 */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${visibleRange.offset}px)`,
}}>
{data
.slice(visibleRange.startIndex, visibleRange.endIndex)
.map((item, index) =>
renderItem(item, visibleRange.startIndex + index)
)
}
</div>
</div>
</div>
);
};
export default VirtualList;
使用示例
jsx
// 生成测试数据
const generateData = (count) =>
Array.from({ length: count }, (_, index) => ({
id: index,
name: `消息 ${index}`,
content: `这是第${index}条消息的内容,可能很长很长...`,
time: new Date(Date.now() - index * 60000).toLocaleTimeString()
}));
function App() {
const data = generateData(100000);
const renderItem = (item, index) => (
<div
key={item.id}
style={{
padding: '12px 16px',
borderBottom: '1px solid #e8e8e8',
backgroundColor: index % 2 === 0 ? '#fafafa' : '#fff',
height: '80px', // 固定高度,简化计算
boxSizing: 'border-box',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>{item.name}</strong>
<span style={{ fontSize: '0.8em', color: '#666' }}>{item.time}</span>
</div>
<p style={{
margin: '8px 0 0 0',
fontSize: '0.9em',
color: '#666',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{item.content}
</p>
</div>
);
return (
<div style={{ padding: '20px' }}>
<h1>消息中心</h1>
<p>共 {data.length} 条消息,滚动流畅无压力</p>
<VirtualList
data={data}
height={600}
itemHeight={80}
renderItem={renderItem}
overscan={5} // 上下预渲染5个额外项
/>
</div>
);
}
性能对比
让我们看看虚拟列表的威力:
方案 | DOM数量 | 内存占用 | 首次加载 | 滚动流畅度 |
---|---|---|---|---|
暴力渲染 | 100000 | ~500MB | 3s+ | 卡顿严重 |
时间分片 | 100000 | ~500MB | 2s | 有明显闪烁 |
虚拟列表 | ~30 | ~10MB | 50ms | 极度流畅 |
进阶优化技巧
1. 动态高度支持
上面的实现假设所有项目高度固定,现实中往往需要支持动态高度:
jsx
// 思路:维护一个位置索引,记录每个项目的累计高度
const useDynamicHeightVirtualList = (data, estimateHeight) => {
const [positions, setPositions] = useState(() =>
data.map((_, index) => ({
index,
height: estimateHeight,
top: index * estimateHeight,
bottom: (index + 1) * estimateHeight
}))
);
// 项目渲染后更新实际高度
const updatePosition = (index, height) => {
if (Math.abs(positions[index].height - height) > 1) {
// 重新计算后续所有项目的位置
const newPositions = [...positions];
newPositions[index].height = height;
newPositions[index].bottom = newPositions[index].top + height;
for (let i = index + 1; i < newPositions.length; i++) {
newPositions[i].top = newPositions[i-1].bottom;
newPositions[i].bottom = newPositions[i].top + newPositions[i].height;
}
setPositions(newPositions);
}
};
return { positions, updatePosition };
};
2. 滚动节流
避免scroll事件触发太频繁:
jsx
const useThrottledScroll = (callback, delay = 16) => {
const lastExec = useRef(0);
const timeoutId = useRef(null);
return useCallback((e) => {
const elapsed = Date.now() - lastExec.current;
const execute = () => {
callback(e);
lastExec.current = Date.now();
};
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
if (elapsed > delay) {
execute();
} else {
timeoutId.current = setTimeout(execute, delay - elapsed);
}
}, [callback, delay]);
};
生产环境建议
在实际项目中,我推荐使用成熟的虚拟列表库:
- react-window: Facebook官方出品,API简洁
- react-virtualized: 功能丰富,社区成熟
- @tanstack/react-virtual: TanStack出品,现代且高性能
bash
npm install react-window
jsx
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>
第 {index} 行
</div>
);
const App = () => (
<List
height={600}
itemCount={100000}
itemSize={35}
>
{Row}
</List>
);
总结
虚拟列表不是什么黑科技,它的核心思想就是"按需渲染"。通过只渲染可视区域的内容,我们实现了:
✅ 极致的性能 - 无论多少数据,只渲染几十个元素
✅ 流畅的体验 - 滚动如丝般顺滑
✅ 低内存占用 - 告别内存泄漏烦恼
✅ 快速首屏 - 用户无需等待
下次遇到海量数据渲染需求,别再暴力for循环了,试试虚拟列表,让你的应用飞起来!
希望这篇笔记对你有帮助!如果你有更好的虚拟列表实践,欢迎在评论区分享交流~
思考题: 虚拟列表适合所有列表场景吗?什么情况下不适合使用虚拟列表?