前端开发中,“列表渲染” 是家常便饭,但如果遇到一次性渲染 10 万条数据的场景,页面直接 “卡死” 几乎是必然结果。这背后不仅是 “JS 执行耗时” 的问题,更和浏览器的EventLoop(事件循环)、“页面渲染时机” 紧密相关。今天就从问题根源出发,一步步优化,最终用 “虚拟列表” 实现丝滑体验,并附上 React 完整实现~
从 EventLoop 看透卡顿本质
要解决卡顿,得先搞懂 “为什么会卡顿”。这一切的根源,都绕不开前端的 “铁律”——JS 是单线程的,而页面渲染和 JS 执行,还得抢同一个 “时间片”。
1.1 EventLoop 的 “执行顺序”
浏览器的 EventLoop(事件循环),决定了 JS 代码和页面渲染的执行优先级,记住这个流程,就能理解 90% 的前端性能问题:
- 执行同步任务:先把主线程里的同步代码执行完(比如 for 循环、变量声明)。
- 清空微任务队列:同步任务执行完后,先处理微任务(Promise.then、MutationObserver 等)。
- UI 渲染:微任务清空后,浏览器会判断是否需要渲染页面(比如更新 DOM、修改样式)。
- 执行宏任务队列:渲染完成后,再从宏任务队列里拿一个任务执行(setTimeout、setInterval、scroll 事件等)。
- 重复 1-4 步,形成 “事件循环”。
关键问题来了:如果同步任务执行时间太长(比如循环插入十万个 DOM),会直接阻塞后续的 “UI 渲染” 步骤—— 这就是页面 “卡成白板” 的原因!
1.2 实测:同步插入十万条数据有多 “坑”
咱们先看一段代码,就是直接用 for 循环插入十万个 li:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>同步插入十万条数据</title>
</head>
<body>
<ul id="container"></ul>
<script>
let now = Date.now();
const total = 100000; // 十万条数据
let ul = document.getElementById('container');
// 同步循环插入DOM
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerHTML = `数据${Math.random()*total}`;
ul.appendChild(li);
}
// 打印JS执行时间(同步任务耗时)
console.log('JS同步执行时间:', Date.now() - now);
// 用setTimeout(宏任务)打印总耗时(包含渲染)
setTimeout(() => {
console.log('总耗时(含渲染):', Date.now() - now);
}, 0);
</script>
</body>
</html>
运行结果(不同设备略有差异):
为什么会卡顿?
- 同步任务阻塞渲染:for 循环插入十万个 DOM 是 “同步任务”,会霸占主线程 1 秒左右,这段时间浏览器根本没时间渲染页面,所以你会看到页面空白半天。
- DOM 操作开销爆炸:每一次
appendChild都会触发 “重绘” 或 “重排”(浏览器重新计算 DOM 位置和样式并绘制),十万次 DOM 操作,相当于让浏览器 “跑十万米”,不卡才怪。
1.3 小结:卡顿的 2 个核心原因
- JS 同步任务执行时间过长,阻塞了 UI 渲染(EventLoop 流程被打断);
- 一次性插入大量 DOM 节点,导致浏览器重绘 / 重排开销过大。
初步优化:时间分片
既然 “一次性吃太多会撑死”,那咱们就把 “十万条数据插入” 这个大任务,拆成一个个 “每次插 20 条” 的小任务,分批次执行 —— 这就是时间分片(Time Slicing) 的核心思路。
2.1 用 setTimeout 实现时间分片
setTimeout 是宏任务,能让拆分后的小任务 “插队” 到 EventLoop 的宏任务队列,避免阻塞同步执行和渲染。代码如下:
<ul id="container"></ul>
<script>
let ul = document.getElementById("container");
const total = 100000; // 总数据量
const once = 20; // 每次插入20条(分片大小)
let index = 0; // 当前插入的索引
// 递归执行分片插入
function loop(curTotal, curIndex) {
if (curTotal <= 0) return; // 数据插完了,终止递归
// 本次插入的数量(最后一次可能不足20条)
const pageCount = Math.min(curTotal, once);
// 宏任务:把本次插入放到下一轮EventLoop
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>
优化效果:
页面不再空白半天,而是 “逐步加载” 数据,滚动时也不会完全卡死。但有个小问题:偶尔会出现 “掉帧” —— 比如滚动时突然停顿一下。
2.2 用 requestAnimationFrame 替代 setTimeout
为什么 setTimeout 会掉帧?因为 setTimeout 的 “延迟时间” 是预估的(实际可能受主线程忙碌程度影响),比如你设了 0ms,实际可能 16ms 后才执行,而浏览器的屏幕刷新频率通常是 60fps(约 16.6ms 刷新一次),两者不同步就会掉帧。
requestAnimationFrame(rAF) 完美解决了这个问题:它会 “跟紧” 浏览器的刷新节奏,每刷新一次执行一次回调,确保不会掉帧。修改代码如下:
<ul id="container"></ul>
<script>
let ul = document.getElementById("container");
const total = 100000;
const once = 20;
let index = 0;
function loop(curTotal, curIndex) {
if (curTotal <= 0) return;
const pageCount = Math.min(curTotal, once);
// 替换setTimeout为rAF,跟浏览器刷新同步
requestAnimationFrame(() => {
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);
});
}
loop(total, index);
</script>
效果提升:
滚动时的流畅度明显提高,掉帧问题基本消失。但还有个隐患:随着数据加载,DOM 节点会越来越多(最终还是十万个) ,内存占用会越来越大,滚动时浏览器依然要 “扛着” 十万个节点计算,时间长了还是会慢。
2.3 用 DocumentFragment 减少 DOM 操作
每次循环里appendChild20 次,还是会触发 20 次 DOM 更新。有没有办法 “批量插入”?答案是DocumentFragment—— 它是一个 “内存中的 DOM 容器”,可以先把 20 个 li 放到里面,再一次性插入 ul,这样只触发 1 次 DOM 更新。
修改代码,加入 DocumentFragment:
<ul id="container"></ul>
<script>
let ul = document.getElementById("container");
const total = 100000;
const once = 20;
let index = 0;
function loop(curTotal, curIndex) {
if (curTotal <= 0) return;
const pageCount = Math.min(curTotal, once);
requestAnimationFrame(() => {
// 创建DocumentFragment(内存中的容器)
const 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); // 一次性插入DOM,只触发1次更新
loop(curTotal - pageCount, curIndex + pageCount);
});
}
loop(total, index);
</script>
关键优化点:
DocumentFragment 不会被渲染到页面上,只是临时存储 DOM 节点,批量插入后会自动清空,能大幅减少 DOM 更新次数,进一步提升性能。
时间分片的局限:DOM 节点依然 “泛滥”
时间分片解决了 “一次性阻塞渲染” 的问题,但没解决 “DOM 节点过多” 的根本问题 —— 十万条数据最终还是会生成十万个 DOM 节点,内存占用高,滚动时浏览器需要计算大量节点的位置,流畅度还是会受影响。
这时候,就需要 “终极方案”—— 虚拟列表登场了。
终极方案:虚拟列表 —— 只渲染 “看得见的” 内容
虚拟列表的核心思想特别简单:只渲染当前视窗内(用户能看到的)的内容,视窗外的内容一概不渲染。比如视窗高度 500px,每个 item 高度 50px,那视窗内只能放 10 个 item,不管总数据有十万还是一百万,页面上永远只渲染 20 个左右的 DOM 节点(视窗内 10 个 + 预渲染 10 个)。
怎么实现呢?记住 3 个关键步骤:
3.1 虚拟列表的核心原理(3 步走)
- 撑起滚动条:计算所有数据的总高度(
totalHeight = data.length * itemHeight),用一个 “占位元素” 把容器的滚动条撑起来,让用户觉得 “所有数据都在里面”(欺骗滚动条)。 - 计算可视范围:监听容器的滚动事件,根据滚动距离(
scrollTop)计算当前视窗内要渲染的 “起始索引” 和 “结束索引”。 - 定位渲染内容:用
transform: translateY()把要渲染的内容 “挪” 到正确的位置,只渲染视窗内 + 预渲染(overscan)的 item,确保滚动时不空白。
3.2 关键计算公式(必背)
假设:
- 容器高度 =
containerHeight(比如 500px) - 每个 item 高度 =
itemHeight(比如 50px) - 滚动距离 =
scrollTop(比如 100px) - 预渲染数量 =
overscan(比如 3,避免滚动空白)
则:
- 起始索引 =
Math.floor(scrollTop / itemHeight)(滚过了多少个 item,就是起始位置) - 可视 item 数量 =
Math.ceil(containerHeight / itemHeight)(视窗内能放多少个 item) - 结束索引 =
起始索引 + 可视item数量 + overscan(加上预渲染,避免空白) - 偏移量 =
起始索引 * itemHeight(用 transform 把内容挪到正确位置)
实战:手写 React 虚拟列表组件
理解了原理,咱们来补全用户给的 React 虚拟列表组件,实现一个可直接用的版本。
4.1 组件设计思路
先明确组件需要的 props:
| props | 类型 | 作用 |
|---|---|---|
| data | array | 总数据(比如十万条) |
| height | number | 容器高度(固定,比如 500px) |
| itemHeight | number | 每个 item 的高度(固定) |
| renderItem | function | 自定义 item 渲染(插槽) |
| overscan | number | 预渲染数量(默认 3) |
然后需要管理的状态:
startIndex:当前要渲染的起始索引offset:渲染内容的偏移量(用于 transform)
4.2 完整代码实现(逐行解释)
// components/VirtualList.jsx
import { useRef, useState, useEffect } from 'react';
const VirtualList = ({
data = [], // 总数据,默认空数组
height = 500, // 容器高度,默认500px
itemHeight = 50, // 每个item高度,默认50px
renderItem, // 自定义渲染item的函数
overscan = 3 // 预渲染数量,默认3
}) => {
// 1. 用ref获取容器DOM,避免每次render重新获取
const containerRef = useRef(null);
// 2. 管理起始索引和偏移量(状态变了才重新渲染)
const [startIndex, setStartIndex] = useState(0);
const [offset, setOffset] = useState(0);
// 3. 计算总高度(撑起滚动条用)
const totalHeight = data.length * itemHeight;
// 4. 计算可视范围的核心函数
const calculateVisibleRange = () => {
if (!containerRef.current) return;
const { scrollTop } = containerRef.current; // 获取滚动距离
// 计算起始索引:滚过的距离 / 每个item高度,向下取整
const newStartIndex = Math.max(0, Math.floor(scrollTop / itemHeight));
// 计算可视item数量:容器高度 / 每个item高度,向上取整(避免漏一个)
const visibleCount = Math.ceil(height / itemHeight);
// 计算结束索引:起始 + 可视数量 + 预渲染,不超过总数据长度
const newEndIndex = Math.min(
data.length,
newStartIndex + visibleCount + overscan
);
// 计算偏移量:起始索引 * 每个item高度(用于定位)
const newOffset = newStartIndex * itemHeight;
// 只有起始索引变了,才更新状态(避免频繁render)
if (newStartIndex !== startIndex) {
setStartIndex(newStartIndex);
setOffset(newOffset);
}
// 返回当前要渲染的索引范围(用于slice数据)
return { start: newStartIndex, end: newEndIndex };
};
// 5. 监听滚动事件:滚动时重新计算可视范围
const onScroll = () => {
calculateVisibleRange();
};
// 6. 初始化时计算一次(页面加载时就渲染正确的内容)
useEffect(() => {
calculateVisibleRange();
// 依赖:容器高度、item高度、数据长度变化时,重新计算
}, [height, itemHeight, data.length]);
// 7. 获取当前要渲染的数据(切片:只取可视范围+预渲染的数据)
const { start = 0, end = 0 } = calculateVisibleRange() || {};
const visibleData = data.slice(start, end);
return (
<div
ref={containerRef}
onScroll={onScroll}
style={{
height: `${height}px`, // 固定容器高度
overflowY: 'auto', // 垂直滚动
position: 'relative', // 子元素绝对定位用
willChange: 'transform', // 性能优化:告诉浏览器要变transform,提前优化
border: '1px solid #eee' // 加个边框,好看点
}}
>
{/* 1. 占位元素:撑起滚动条,高度=总数据高度 */}
<div
style={{
height: `${totalHeight}px`,
width: '100%',
position: 'absolute',
top: 0,
left: 0,
zIndex: -1 // 放在最下面,不影响内容
}}
/>
{/* 2. 实际渲染的内容:只渲染可视范围+预渲染的数据 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// 关键:用transform把内容挪到正确位置
transform: `translateY(${offset}px)`
}}
>
{/* 调用renderItem渲染每个item,传入item和index */}
{visibleData.map((item, idx) => {
// 注意:这里的真实索引是 start + idx(不是idx)
const realIndex = start + idx;
return renderItem(item, realIndex);
})}
</div>
</div>
);
};
export default VirtualList;
4.3 组件使用示例(App.jsx)
用用户给的 App 组件,传入十万条数据,测试效果:
// App.jsx
import { useState } from 'react';
import './App.css';
import VirtualList from './components/VirtualList';
// 生成十万条测试数据
const generateData = (count) =>
Array.from({ length: count }, (_, i) => ({
id: i,
name: `商品 ${i + 1}`,
price: `¥${(Math.random() * 1000).toFixed(2)}`,
description: `这是第${i + 1}个商品,用虚拟列表渲染,DOM节点只有20个左右`
}));
function App() {
// 生成十万条数据(页面加载时生成)
const data = generateData(100000);
// 自定义item渲染(插槽,灵活适配不同场景)
const renderItem = (item, index) => (
<div
key={item.id} // 必须加key,React优化用
style={{
padding: '12px',
borderBottom: '1px solid #f5f5f5',
backgroundColor: index % 2 === 0 ? '#fff' : '#fafafa',
height: `${80}px`, // 对应VirtualList的itemHeight=80
boxSizing: 'border-box' // 避免padding撑高item
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>[{index + 1}]</strong>
<span style={{ color: '#ff4400' }}>{item.price}</span>
</div>
<h3 style={{ margin: '8px 0' }}>{item.name}</h3>
<p style={{ margin: 0, fontSize: '0.9em', color: '#666' }}>
{item.description}
</p>
</div>
);
return (
<div style={{ padding: '20px', fontFamily: 'Arial', maxWidth: '800px', margin: '0 auto' }}>
<h1>十万条商品数据 · React虚拟列表</h1>
<p style={{ color: '#666' }}>
实测:DOM节点仅20个左右,滚动帧率保持60fps(打开F12→Elements查看DOM数量)
</p>
{/* 使用虚拟列表组件 */}
<VirtualList
data={data}
height={window.innerHeight - 150} // 容器高度=窗口高度-150(避免溢出)
itemHeight={80} // 每个item高度=80px(和renderItem的height一致)
renderItem={renderItem} // 自定义渲染item
overscan={3} // 预渲染上下各3个,避免滚动空白
/>
</div>
);
}
export default App;
4.4 性能测试:DOM 节点数量对比
- 传统渲染:100000 个 DOM 节点
- 虚拟列表:约
Math.ceil((window.innerHeight-150)/80) + 3*2 ≈ 10 + 6 = 16个 DOM 节点
差距一目了然!滚动时浏览器只需要处理 16 个节点,流畅度直接拉满。
虚拟列表的应用场景与面试亮点
5.1 适用场景
虚拟列表不是银弹,但在这些场景下是 “性能救星”:
- 大数据列表:商品列表、订单列表、日志展示
- 长表格:比如后台管理系统的数据分析表格
- 下拉加载:比如无限滚动列表(结合虚拟列表,避免 DOM 爆炸)
5.2 面试时怎么说?(加分话术)
“我在项目中遇到过十万条商品数据渲染卡顿的问题,一开始用时间分片解决了同步阻塞,但 DOM 节点还是太多。后来深入理解了 EventLoop 和浏览器渲染机制,用虚拟列表重构了组件:
- 先通过 EventLoop 分析卡顿原因:同步任务阻塞渲染,大量 DOM 导致重绘重排;
- 用时间分片 + requestAnimationFrame 初步优化,解决了阻塞问题;
- 最后用虚拟列表实现终极优化:只渲染视窗内内容,DOM 节点从十万减到 20 个,滚动帧率保持 60fps;
- 还做了预渲染(overscan)和 GPU 加速(willChange),避免滚动空白和掉帧。”
5.3 扩展:动态 item 高度怎么处理?
上面的例子是 “固定 item 高度”,如果 item 高度不固定(比如内容长短不一),可以用以下方案:
- 预估高度:先给一个预估高度,渲染后再修正真实高度(用 ResizeObserver 监听);
- 用成熟库:推荐
react-window或react-virtualized(处理了动态高度、横向滚动等边缘场景)。
结语:从 “知其然” 到 “知其所以然”
解决十万条数据卡顿的过程,本质是 “理解原理→逐步优化” 的过程:
- 从 EventLoop 看透卡顿根源,避免 “头痛医头”;
- 用时间分片解决 “同步阻塞”,是 “治标”;
- 用虚拟列表解决 “DOM 泛滥”,是 “治本”。