前端无限列表(也叫无限滚动/虚拟列表)是解决长列表渲染性能问题的核心方案,其核心逻辑是只渲染可视区域内的内容,而非全部数据------这也是"一直下滑不卡"的关键原因。
一、先搞懂:为什么普通长列表会卡顿?
如果直接把1万条数据渲染成DOM节点,会触发两个致命问题:
- DOM节点爆炸:浏览器渲染引擎需要维护大量DOM节点,重排(reflow)/重绘(repaint)成本指数级上升;
- 内存占用过高:JS堆内存和DOM树占用大量内存,导致页面卡顿、甚至崩溃。
无限列表的本质就是规避这两个问题------无论数据有多少,始终只渲染"看得见的一小部分"。
二、无限列表的两种核心实现方案
根据场景不同,无限列表主要分「滚动加载(懒加载)」和「虚拟列表(虚拟滚动)」,前者简单、后者极致,常结合使用。
方案1:滚动加载(懒加载)------基础版无限列表
核心逻辑:监听滚动事件,当滚动到页面底部(或接近底部)时,加载下一页数据并追加到列表末尾。
实现步骤:
- 监听滚动事件 :监听
scroll(全局)或scroll事件(容器),计算滚动位置; - 判断加载时机:计算"滚动条距离底部的距离",当小于阈值(如200px)时触发加载;
- 加载并渲染数据:请求下一页数据,将新数据渲染成DOM追加到列表,同时标记"加载中/无更多"。
核心代码示例(原生JS):
javascript
const listContainer = document.getElementById('list-container');
let page = 1;
const pageSize = 20;
let isLoading = false; // 防止重复请求
// 监听滚动
listContainer.addEventListener('scroll', () => {
// 计算滚动到底部的距离:容器高度 + 滚动距离 >= 内容总高度 - 阈值
const { scrollTop, scrollHeight, clientHeight } = listContainer;
if (scrollTop + clientHeight >= scrollHeight - 200 && !isLoading) {
loadMore();
}
});
// 加载更多数据
async function loadMore() {
isLoading = true;
try {
const res = await fetch(`/api/list?page=${page}&size=${pageSize}`);
const data = await res.json();
if (data.length === 0) {
// 无更多数据
listContainer.innerHTML += '<div class="no-more">没有更多了</div>';
return;
}
// 渲染新数据(追加DOM)
const newItems = data.map(item => `<div class="list-item">${item.content}</div>`).join('');
listContainer.innerHTML += newItems;
page++;
} catch (err) {
console.error('加载失败:', err);
} finally {
isLoading = false;
}
}
// 初始化加载第一页
loadMore();
优缺点:
- ✅ 优点:实现简单,适配大部分场景(如商品列表、新闻流);
- ❌ 缺点:数据量累积后,DOM节点仍会越来越多,最终还是会卡顿(适合数据量不极致的场景)。
方案2:虚拟列表(虚拟滚动)------极致性能版
核心逻辑:无论总数据有多少,始终只渲染「可视区域 + 少量缓冲」的DOM节点,通过偏移量模拟列表滚动的视觉效果。
核心概念:
- 可视区域(viewport):用户能看到的列表容器区域;
- 滚动偏移(scrollOffset):列表滚动的距离;
- 缓冲区域:可视区域上下各加少量节点(如5个),避免快速滚动时出现空白;
- 占位容器:用一个空容器设置总高度,模拟列表的"完整滚动条",让用户感知到列表长度。
实现步骤:
- 计算可视区域能显示的条目数 :
可视区域高度 / 单条目高度; - 监听滚动事件:计算滚动偏移对应的"起始条目索引";
- 截取需要渲染的数据:从总数据中截取「起始索引 - 缓冲数」到「起始索引 + 可视条目数 + 缓冲数」的片段;
- 定位渲染的DOM :通过
transform: translateY(偏移量)将渲染的条目定位到可视区域; - 更新占位容器高度 :设置为
总数据长度 * 单条目高度,保证滚动条正常。
核心代码示例(原生JS):
html
<div id="virtual-list" style="height: 500px; overflow: auto; border: 1px solid #ccc;">
<!-- 占位容器:模拟总高度 -->
<div id="placeholder" style="position: relative; height: 0;"></div>
<!-- 渲染区域:只显示可视区域的条目 -->
<div id="render-area" style="position: absolute; top: 0; left: 0; width: 100%;"></div>
</div>
<script>
// 配置项
const ITEM_HEIGHT = 50; // 单条目高度(固定)
const BUFFER = 5; // 缓冲条目数
const listEl = document.getElementById('virtual-list');
const placeholderEl = document.getElementById('placeholder');
const renderAreaEl = document.getElementById('render-area');
// 模拟总数据(10万条)
const totalData = Array.from({ length: 100000 }, (_, i) => `条目 ${i + 1}`);
// 初始化:设置占位容器总高度
placeholderEl.style.height = `${totalData.length * ITEM_HEIGHT}px`;
// 核心渲染函数
function renderVisibleItems() {
// 1. 获取滚动偏移
const scrollTop = listEl.scrollTop;
// 2. 计算起始条目索引(向下取整)
const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);
// 3. 计算可视区域能显示的条目数
const visibleCount = Math.ceil(listEl.clientHeight / ITEM_HEIGHT);
// 4. 计算结束条目索引
const endIndex = Math.min(totalData.length - 1, startIndex + visibleCount + BUFFER);
// 5. 截取需要渲染的数据
const visibleData = totalData.slice(startIndex, endIndex + 1);
// 6. 计算渲染区域的偏移量(让条目定位到正确位置)
const offsetY = startIndex * ITEM_HEIGHT;
renderAreaEl.style.transform = `translateY(${offsetY}px)`;
// 7. 渲染可视区域条目
renderAreaEl.innerHTML = visibleData.map((item, i) =>
`<div style="height: ${ITEM_HEIGHT}px; line-height: ${ITEM_HEIGHT}px; border-bottom: 1px solid #eee;">${item}</div>`
).join('');
}
// 监听滚动事件(防抖优化)
listEl.addEventListener('scroll', () => {
requestAnimationFrame(renderVisibleItems); // 配合RAF减少重绘次数
});
// 初始化渲染
renderVisibleItems();
</script>
优缺点:
- ✅ 优点:无论数据量多大,DOM节点数始终保持在「可视条目数 + 2*缓冲数」(通常几十条),性能极致;
- ❌ 缺点:实现复杂,若条目高度不固定(如动态内容),需要额外计算高度(动态虚拟列表)。
三、为什么"一直下滑"不会出现性能问题?
核心原因总结:
- DOM节点数量恒定 :
- 滚动加载:虽会追加DOM,但可通过"旧数据回收"(如只保留最近10页)控制节点数;
- 虚拟列表:DOM节点数始终是"可视+缓冲"的固定值,与总数据量无关。
- 减少重排/重绘 :
- 用
requestAnimationFrame包裹滚动回调,让渲染与浏览器刷新率同步; - 虚拟列表通过
transform定位DOM(GPU加速,不会触发重排),而非修改top/left。
- 用
- 内存占用可控 :
- 仅保存当前渲染数据的引用,旧数据可通过分页/懒加载按需请求,不常驻内存;
- 虚拟列表甚至可配合"数据分片加载"(如只加载当前可视区域附近的数据),进一步降低内存占用。
四、进阶优化点
- 防抖/节流:滚动事件触发频率极高(每秒几十次),用防抖(debounce)或节流(throttle)减少渲染次数;
- 骨架屏/加载占位:加载数据时显示骨架屏,提升用户体验;
- 动态高度适配 :若条目高度不固定,可通过
getBoundingClientRect计算实际高度,实现"动态虚拟列表"; - 复用DOM节点 :用"对象池"复用已渲染的DOM节点,避免频繁创建/销毁节点(如React的
react-window、Vue的vue-virtual-scroller都做了这点); - 数据预加载:提前加载下一页数据(如滚动到80%时),避免用户等待。
五、成熟库推荐(不用重复造轮子)
- 通用:
react-window(React)、vue-virtual-scroller(Vue2/Vue3)、@tanstack/virtual(跨框架); - 移动端:
better-scroll(带虚拟滚动)、vant的List组件(滚动加载)。
总结
无限列表的核心是「按需渲染」:
- 简单场景(如几百条数据)用「滚动加载」即可;
- 海量数据(如几千/几万条)必须用「虚拟列表」,保证DOM和内存始终可控;
- 性能不崩的关键是:始终只渲染可视区域的内容,避免DOM和内存爆炸。