前端虚拟列表滚动功能实现与核心知识点详解

虚拟列表(Virtual List)是前端解决长列表渲染性能问题 的核心方案,其核心思想是「只渲染可视区域内的列表项,而非全部数据」,可将上万条数据的列表渲染耗时从秒级降至毫秒级。本文从「核心原理→实现步骤→知识点拆解→进阶优化」全维度讲解,附完整可运行代码。

一、虚拟列表核心原理

1. 为什么需要虚拟列表?

普通长列表渲染的问题:

  • DOM 节点过多:10000 条数据会生成 10000 个 DOM 节点,导致首次渲染慢、滚动卡顿(重排 / 重绘成本高);
  • 内存占用大:大量 DOM 节点占用浏览器内存,易引发页面崩溃。

虚拟列表的解决思路:

  1. 固定容器高度 :列表外层容器设置固定高度并开启滚动(overflow: auto);
  2. 计算可视区域:根据容器高度、列表项高度,计算「可视区域能显示的列表项数量」;
  3. 只渲染可视项:从全量数据中截取可视区域内的部分数据,仅渲染这些数据对应的 DOM;
  4. 滚动偏移模拟:通过「空白占位容器」模拟列表的整体滚动高度,通过「定位可视项容器」实现滚动时的位置对齐。

2. 核心术语与公式

术语 说明 核心公式
容器可视高度(viewHeight) 列表外层容器的可见高度(如 500px) -
列表项高度(itemHeight) 单个列表项的固定高度(如 50px,简化版虚拟列表假设高度固定) -
可视项数量(visibleCount) 可视区域能显示的列表项数量(向上取整避免留白) visibleCount = Math.ceil(viewHeight / itemHeight) + 2(+2 为缓冲项)
滚动偏移量(scrollTop) 容器的滚动距离(container.scrollTop -
起始索引(startIndex) 可视区域第一个列表项的索引 startIndex = Math.floor(scrollTop / itemHeight)
结束索引(endIndex) 可视区域最后一个列表项的索引 endIndex = startIndex + visibleCount
占位高度(totalHeight) 模拟整个列表的高度(让滚动条显示正确) totalHeight = 总数据长度 * itemHeight
可视项偏移(offsetTop) 可视项容器的垂直偏移(让可视项显示在正确位置) offsetTop = startIndex * itemHeight

二、基础版虚拟列表实现(固定高度)

1. 完整代码(原生 JS 版)

js 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>基础版虚拟列表</title>
  <style>
    /* 1. 列表容器:固定高度、开启滚动、相对定位 */
    .virtual-list-container {
      width: 300px;
      height: 500px; /* 可视区域高度 */
      border: 1px solid #e6e6e6;
      overflow: auto;
      position: relative;
    }
    /* 2. 占位容器:模拟整个列表的高度,让滚动条正常显示 */
    .virtual-list-placeholder {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      /* 高度由JS动态计算:总数据长度 * 列表项高度 */
    }
    /* 3. 可视项容器:绝对定位,通过top偏移显示在正确位置 */
    .virtual-list-items {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
    }
    /* 4. 列表项:固定高度 */
    .list-item {
      height: 50px; /* 单个列表项高度 */
      line-height: 50px;
      padding: 0 10px;
      border-bottom: 1px solid #f5f5f5;
      box-sizing: border-box;
    }
  </style>
</head>
<body>
  <!-- 外层容器,固定高度 + 滚动 + 相对定位(作为子元素的定位参考) -->
  <div class="virtual-list-container" id="container">
    <!-- 占位容器:模拟总高度 -->
     <!-- 占位容器,高度等于「总数据长度 × 列表项高度」,目的是让滚动条的长度和滚动范围与真实长列表一致 -->
    <div class="virtual-list-placeholder" id="placeholder"></div>
    <!-- 可视项容器:只渲染可视区域的列表项 -->
     <!-- 可视项容器,绝对定位,通过top属性偏移到当前滚动位置对应的区域,只渲染可视数据。 -->
    <div class="virtual-list-items" id="items"></div>
  </div>

  <script>
    // 1. 模拟海量数据(10万条)
    const TOTAL_DATA = Array.from({ length: 100000 }, (_, index) => ({
      id: index,
      content: `列表项 ${index + 1}`
    }));

    // 2. 核心配置
    const config = {
      viewHeight: 500, // 容器可视高度(px)
      itemHeight: 50,  // 单个列表项高度(px)
      buffer: 2        // 缓冲项数量(避免滚动时出现空白)
    };

    // 3. 获取DOM元素
    const container = document.getElementById('container');
    const placeholder = document.getElementById('placeholder');
    const items = document.getElementById('items');

    // 4. 初始化:设置占位容器高度
    placeholder.style.height = `${TOTAL_DATA.length * config.itemHeight}px`;

    // 5. 核心渲染函数:根据滚动位置渲染可视项
    function renderVisibleItems() {
      // 5.1 获取滚动偏移量 container.scrollTop 表示容器向上滚动的距离
      const scrollTop = container.scrollTop;
      
      // 5.2 计算可视项的起始/结束索引
      // 滚动偏移 150px → startIndex = 150/50 = 3(第 4 项);
      // endIndex = 3+12 = 15(第 16 项);
      const visibleCount = Math.ceil(config.viewHeight / config.itemHeight) + config.buffer;
      const startIndex = Math.max(0, Math.floor(scrollTop / config.itemHeight)); // 避免负数
      const endIndex = Math.min(TOTAL_DATA.length - 1, startIndex + visibleCount);
      
      // 5.3 截取可视区域的数据
      // 从 10 万条数据中截取第 3~15 项(仅 13 条);
      const visibleData = TOTAL_DATA.slice(startIndex, endIndex + 1);
      console.log("scrollTop:",scrollTop)
      console.log("visibleCount:",visibleCount)
      console.log("startIndex:",startIndex)
      console.log("endIndex:",endIndex)
      console.log("======")
      
      // 5.4 计算可视项容器的偏移(让可视项显示在正确位置)
      // 设置可视项偏移:items.style.top = 3×50 = 150px,让可视项容器对齐滚动位置
      items.style.top = `${startIndex * config.itemHeight}px`;
      
      // 5.5 渲染可视项(innerHTML简化版,实际可优化为DOM复用)
      items.innerHTML = visibleData.map(item => `
        <div class="list-item" data-id="${item.id}">
          ${item.content}
        </div>
      `).join('');
    }

    // 6. 监听滚动事件:滚动时重新渲染可视项
    container.addEventListener('scroll', debounce(renderVisibleItems));

    // 7. 初始化渲染
    renderVisibleItems();


    // 防抖函数 如果在16ms内多次执行滚动,则会重新计算时间
    function debounce(fn, delay = 16) {
      let timer = null;
      return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
      };
    }
  </script>
</body>
</html>

2. 核心逻辑拆解

(1)DOM 结构设计

  • virtual-list-container:外层容器,固定高度 + 滚动 + 相对定位(作为子元素的定位参考);
  • virtual-list-placeholder:占位容器,高度等于「总数据长度 × 列表项高度」,目的是让滚动条的长度和滚动范围与真实长列表一致;
  • virtual-list-items:可视项容器,绝对定位,通过top属性偏移到当前滚动位置对应的区域,只渲染可视数据。

(2)渲染函数核心步骤

  1. 获取滚动偏移量container.scrollTop 表示容器向上滚动的距离;

  2. 计算可视项数量Math.ceil(500/50)+2 = 12,即可视区域能显示 10 项,额外加 2 项缓冲(避免快速滚动时出现空白);

  3. 计算起始 / 结束索引

    • 滚动偏移 150px → startIndex = 150/50 = 3(第 4 项);
    • endIndex = 3+12 = 15(第 16 项);
  4. 截取可视数据:从 10 万条数据中截取第 3~15 项(仅 13 条);

  5. 设置可视项偏移items.style.top = 3×50 = 150px,让可视项容器对齐滚动位置;

  6. 渲染可视项:仅渲染 13 项 DOM,而非 10 万项。

三、虚拟列表核心知识点拆解

1. 滚动事件与性能优化

  • scroll 事件的特性:滚动时会高频触发(每秒数十次),直接在事件回调中操作 DOM 会导致性能问题;
  • 优化方案:防抖(Debounce),减少渲染频率(如每 16ms 渲染一次,对应 60fps):

2. 定位方式与偏移计算

  • 绝对定位的作用 :可视项容器(virtual-list-items)脱离文档流,仅在可视区域渲染,不影响占位容器的高度;
  • top 偏移的意义startIndex × itemHeight 保证可视项容器始终对齐滚动位置,例如滚动到第 100 项时,top = 100×50 = 5000px,可视项容器会定位到 5000px 位置,显示第 100~112 项。

3. 固定高度 vs 动态高度

基础版假设列表项高度固定,实际场景中列表项高度可能动态(如内容换行、不同类型项高度不同),解决方案:

(1)预估高度 + 修正

  1. 先按「预估高度」计算占位高度和偏移;
  2. 渲染后获取真实高度(getBoundingClientRect());
  3. 记录每个项的真实高度,滚动时用真实高度修正偏移。

(2)核心代码示例(动态高度)

js 复制代码
// 存储每个项的真实高度(初始为预估高度)
const itemHeights = new Array(TOTAL_DATA.length).fill(config.itemHeight);

function renderVisibleItems() {
  const scrollTop = container.scrollTop;
  
  // 计算累计高度(替代固定高度的startIndex计算)
  let cumulativeHeight = 0;
  let startIndex = 0;
  // 找到滚动位置对应的起始索引
  for (; startIndex < TOTAL_DATA.length; startIndex++) {
    if (cumulativeHeight >= scrollTop) break;
    cumulativeHeight += itemHeights[startIndex];
  }
  
  // 计算可视项结束索引(累计高度超过可视高度)
  let endIndex = startIndex;
  let visibleHeight = 0;
  while (endIndex < TOTAL_DATA.length && visibleHeight < config.viewHeight + config.buffer * config.itemHeight) {
    visibleHeight += itemHeights[endIndex];
    endIndex++;
  }
  
  // 截取可视数据
  const visibleData = TOTAL_DATA.slice(startIndex, endIndex);
  
  // 计算可视项容器的偏移(累计到startIndex的高度)
  const offsetTop = cumulativeHeight - itemHeights[startIndex];
  items.style.top = `${offsetTop}px`;
  
  // 渲染后修正真实高度
  items.innerHTML = visibleData.map((item, idx) => `
    <div class="list-item" data-id="${item.id}">${item.content}</div>
  `).join('');
  
  // 渲染完成后获取真实高度并更新
  Array.from(items.children).forEach((el, idx) => {
    const realHeight = el.getBoundingClientRect().height;
    const realIndex = startIndex + idx;
    itemHeights[realIndex] = realHeight;
  });
  
  // 更新占位容器的总高度(动态累加)
  const totalHeight = itemHeights.reduce((sum, height) => sum + height, 0);
  placeholder.style.height = `${totalHeight}px`;
}

4. 缓冲项(Buffer)的作用

  • 问题:快速滚动时,可视项还未渲染完成,会出现短暂空白;
  • 解决方案:在可视项前后各加 N 个缓冲项(通常 2~5 项),提前渲染部分数据,覆盖滚动的 "视觉延迟";
  • 注意:缓冲项过多会增加 DOM 数量,过少则无法解决空白问题,需根据业务调整(一般 2~5 项)。

5. DOM 复用(进阶优化)

基础版每次渲染都用innerHTML重新生成 DOM,会销毁旧 DOM 并创建新 DOM,存在性能损耗。DOM 复用是更优方案:

  1. 初始化时创建「可视项数量 + 缓冲项」的空 DOM 节点池;
  2. 滚动时仅更新节点的内容,而非销毁 / 创建节点;
js 复制代码
// 初始化DOM池
const domPool = [];
const visibleCount = Math.ceil(config.viewHeight / config.itemHeight) + config.buffer;
// 创建空节点池
for (let i = 0; i < visibleCount; i++) {
  const el = document.createElement('div');
  el.className = 'list-item';
  domPool.push(el);
  items.appendChild(el);
}

function renderVisibleItems() {
  // ... 计算startIndex/endIndex/visibleData ...
  
  // 复用DOM节点:仅更新内容,不创建新节点
  visibleData.forEach((item, idx) => {
    const el = domPool[idx];
    el.dataset.id = item.id;
    el.textContent = item.content;
  });
  
  // 隐藏超出可视数据的节点(可选)
  for (let i = visibleData.length; i < domPool.length; i++) {
    domPool[i].style.display = 'none';
  }
}

四、框架版虚拟列表(Vue3 示例)

js 复制代码
<template>
  <div 
    class="virtual-list-container"
    ref="containerRef"
    @scroll="handleScroll"
  >
    <div class="virtual-list-placeholder" :style="{ height: totalHeight + 'px' }"></div>
    <div class="virtual-list-items" :style="{ top: offsetTop + 'px' }">
      <div 
        v-for="item in visibleData" 
        :key="item.id"
        class="list-item"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

// 配置
const config = {
  viewHeight: 500,
  itemHeight: 50,
  buffer: 2
};

// DOM引用
const containerRef = ref(null);
// 响应式数据
const totalData = ref(Array.from({ length: 100000 }, (_, i) => ({ id: i, content: `列表项 ${i+1}` })));
const scrollTop = ref(0);
// 计算属性:总高度
const totalHeight = computed(() => totalData.value.length * config.itemHeight);
// 计算属性:可视项相关
const visibleData = computed(() => {
  const visibleCount = Math.ceil(config.viewHeight / config.itemHeight) + config.buffer;
  const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight));
  const endIndex = Math.min(totalData.value.length - 1, startIndex + visibleCount);
  return totalData.value.slice(startIndex, endIndex + 1);
});
// 计算属性:可视项偏移
const offsetTop = computed(() => {
  const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight));
  return startIndex * config.itemHeight;
});

// 滚动事件处理
const handleScroll = () => {
  if (containerRef.value) {
    scrollTop.value = containerRef.value.scrollTop;
  }
};

// 初始化
onMounted(() => {
  // 防抖优化(可选)
  const debouncedHandleScroll = debounce(handleScroll, 16);
  containerRef.value?.addEventListener('scroll', debouncedHandleScroll);
});

// 防抖函数
function debounce(fn, delay = 16) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}
</script>

<style scoped>
.virtual-list-container {
  width: 300px;
  height: 500px;
  border: 1px solid #e6e6e6;
  overflow: auto;
  position: relative;
}
.virtual-list-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.virtual-list-items {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.list-item {
  height: 50px;
  line-height: 50px;
  padding: 0 10px;
  border-bottom: 1px solid #f5f5f5;
  box-sizing: border-box;
}
</style>

五、常见问题与避坑指南

1. 滚动时出现空白

  • 原因:缓冲项不足、滚动防抖延迟过高、动态高度预估偏差;

  • 解决方案

    • 增加缓冲项数量(如从 2 增至 5);
    • 降低防抖延迟(如 16ms,对应 60fps);
    • 动态高度场景下提前渲染更多缓冲项。

2. 滚动条抖动

  • 原因:动态高度场景下,真实高度与预估高度偏差导致总高度频繁变化;

  • 解决方案

    • 初始化时尽可能精准预估高度;
    • 批量更新总高度(而非每次渲染都更新);
    • 限制总高度的更新频率。

3. 大数据初始化卡顿

  • 原因:初始渲染时计算累计高度遍历全量数据;

  • 解决方案

    • 分段初始化(先渲染前 100 项,后续滚动时再计算);
    • 使用 Web Worker 计算累计高度(避免阻塞主线程)。

4. 移动端滚动不流畅

  • 原因:移动端滚动有惯性,scroll 事件触发更频繁;

  • 解决方案

    • 使用passive: true优化滚动事件(避免浏览器阻塞默认行为):
    • 开启硬件加速(transform: translateZ(0)):
js 复制代码
container.addEventListener('scroll', debouncedHandleScroll, { passive: true });
js 复制代码
.virtual-list-items { transform: translateZ(0); }

六、总结

虚拟列表的核心是「空间换时间」:用「占位容器 + 可视项容器」的 DOM 结构,换取「仅渲染可视区域 DOM」的性能提升。核心知识点可归纳为:

  1. 核心原理:可视区域计算 + 数据截取 + 偏移定位;
  2. 性能优化:防抖、DOM 复用、缓冲项、硬件加速;
  3. 适配场景:固定高度(简单)、动态高度(复杂)、加载更多(业务常用);
  4. 框架适配:利用响应式数据封装计算属性,简化滚动状态管理。
相关推荐
辰风沐阳1 天前
ES6 新特性: 解构赋值
前端·javascript·es6
猫头鹰源码(同名B站)1 天前
基于django+vue的时尚穿搭社区(商城)(前后端分离)
前端·javascript·vue.js·后端·python·django
weixin_427771611 天前
npm 绕过2FA验证
前端·npm·node.js
零基础的修炼1 天前
算法---常见位运算总结
java·开发语言·前端
wuhen_n1 天前
@types 包的工作原理与最佳实践
前端·javascript·typescript
我是伪码农1 天前
Vue 1.27
前端·javascript·vue.js
秋名山大前端1 天前
前端大规模 3D 轨迹数据可视化系统的性能优化实践
前端·3d·性能优化
H7998742421 天前
2026动态捕捉推荐:8款专业产品全方位测评
大数据·前端·人工智能
ct9781 天前
Cesium 矩阵系统详解
前端·线性代数·矩阵·gis·webgl
小陈phd1 天前
langGraph从入门到精通(十一)——基于langgraph构建复杂工具应用的ReAct自治代理
前端·人工智能·react.js·自然语言处理