实现虚拟列表

虚拟列表实现

实现思路

  1. 渲染可视区域的数据:根据滚动位置计算出可见的起始索引和结束索引;
  2. 总高度占位:整个容器高度与真实数据等高,让滚动条正常工作;
  3. 滚动定位:通过一个"内层偏移容器"将可视区域的内容垂直偏移到正确位置;
  4. 动态渲染:滚动时实时计算需要渲染的数据子集。

图示概念

js 复制代码
┌────────────────────┐
│ scroll container   │  ← 固定高度、滚动容器
│ ┌────────────────┐ │
│ │ phantom        │ │  ← 实际总高度,占位用
│ │ ┌────────────┐ │ │
│ │ │ visible     │ │ │  ← 只渲染可视区域
│ │ └────────────┘ │ │
│ └────────────────┘ │
└────────────────────┘

关键计算参数

  • 容器高度:可视区域的高度
  • 项目高度:每个列表项的高度(固定或动态)
  • 滚动位置:当前滚动条的位置
  • 缓冲区:预渲染的额外项目数(防止滚动时空白)

实现代码

原生JavaScript

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8" />
  <title>虚拟列表 - 原生 JS</title>
  <style>
    #container {
      height: 300px; /* 设置固定高度,超出部分滚动 */
      overflow-y: auto;
      position: relative;
      border: 1px solid #ccc;
    }
    #phantom {
      height: 0; /* 最终高度将被 JS 设置 */
    }
    #visible {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
    }
  </style>
</head>
<body>
  <div id="container">
    <div id="phantom"></div> <!-- 占位用容器撑起滚动条 -->
    <div id="visible"></div> <!-- 实际渲染可视区域数据 -->
  </div>

  <script>
    const container = document.getElementById('container'); // 获取滚动容器
    const phantom = document.getElementById('phantom');     // 占位容器
    const visible = document.getElementById('visible');     // 渲染内容的容器

    const itemHeight = 30; // 每一项的固定高度
    const total = 10000;   // 总数据条数
    const visibleCount = Math.ceil(container.clientHeight / itemHeight); // 可视区域最多渲染多少项
    const data = Array.from({ length: total }, (_, i) => `Item ${i + 1}`); // 生成 1~10000 的数据项

    phantom.style.height = `${total * itemHeight}px`; // 设置占位高度:总条数 × 每项高度

    function render(startIndex) {
      // 取出可视区域需要渲染的子集数据
      const visibleData = data.slice(startIndex, startIndex + visibleCount);

      // 生成 HTML,每项设置高度
      visible.innerHTML = visibleData.map(item =>
        `<div style="height:${itemHeight}px; border-bottom:1px solid #eee; padding-left: 8px;">${item}</div>`
      ).join('');

      // 设置渲染容器向下偏移位置
      visible.style.transform = `translateY(${startIndex * itemHeight}px)`;
    }

    // 初始化:从第 0 项开始渲染
    render(0);

    // 监听滚动事件
    container.addEventListener('scroll', () => {
      // 计算滚动位置对应的起始索引
      const start = Math.floor(container.scrollTop / itemHeight);
      render(start);
    });
  </script>
</body>
</html>

思路解释

  • #container 设置固定高度和滚动条,是整个滚动容器
  • #phantom 高度撑起整个列表滚动高度,不显示内容,只为撑起滚动条
  • #visible 实际显示数据的容器,显示部分 DOM 项
  • 根据当前滚动起点 startIndex,通过 slice(start, end) 截取当前需要展示的数据。
  • 设置 transform: translateY 偏移,将渲染区域移动到应该出现的位置。
  • container.scrollTop 是滚动条卷去的高度,除以 itemHeight 就得到当前在第几项开头。然后重新渲染当前窗口中要显示的内容。

Vue3

vue 复制代码
<template>
  <div class="viewport" @scroll="handleScroll" ref="viewport">
    <!-- 占位元素 -->
    <div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
    
    <!-- 实际渲染内容 -->
    <div class="content" :style="{ transform: `translateY(${offset}px)` }">
      <div 
        v-for="item in visibleData" 
        :key="item.id"
        class="item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.text }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    data: { type: Array, required: true },  // 列表数据
    itemHeight: { type: Number, default: 50 }, // 每项高度
    buffer: { type: Number, default: 5 }     // 缓冲区大小
  },
  
  data() {
    return {
      startIndex: 0,     // 起始索引
      endIndex: 0,       // 结束索引
      scrollTop: 0       // 滚动位置
    };
  },
  
  computed: {
    // 列表总高度
    totalHeight() {
      return this.data.length * this.itemHeight;
    },
    
    // 可见区域数据
    visibleData() {
      return this.data.slice(this.startIndex, this.endIndex + 1);
    },
    
    // 内容偏移量
    offset() {
      return this.startIndex * this.itemHeight;
    },
    
    // 可见项目数
    visibleCount() {
      return Math.ceil(this.$refs.viewport?.clientHeight / this.itemHeight) || 0;
    }
  },
  
  mounted() {
    this.updateRange();
  },
  
  methods: {
    // 更新可见范围
    updateRange() {
      this.startIndex = Math.floor(this.scrollTop / this.itemHeight);
      this.endIndex = this.startIndex + this.visibleCount + this.buffer;
      this.endIndex = Math.min(this.endIndex, this.data.length - 1);
    },
    
    // 滚动事件处理
    handleScroll() {
      this.scrollTop = this.$refs.viewport.scrollTop;
      this.updateRange();
    }
  }
};
</script>

<style>
.viewport {
  height: 100%;
  overflow: auto;
  position: relative;
}
.phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
}
.content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.item {
  position: absolute;
  width: 100%;
  box-sizing: border-box;
}
</style>

思路解释

  • .viewport 是滚动容器,监听 scroll 事件。
  • ref="viewport" 用于访问 DOM 获取 scrollTopclientHeight
  • .phantom 是"假元素",用于撑出滚动条总高度(类似原生实现中的 phantom
  • .content 是"真实内容区",通过 transform: translateY() 将其移动到正确显示区域
  • buffer:让上下各多渲染几项,避免滚动过快时出现"白屏"现象。
  • .contenttranslateY 偏移值,让渲染内容出现在正确滚动位置。
  • 滚动事件触发时,更新 scrollTop,并重新计算显示范围。

React

js 复制代码
import React, { useState, useEffect, useRef } from 'react';

/**
 * 虚拟列表组件
 * @param {Object} props 
 * @param {Array} props.data 列表数据
 * @param {number} props.itemHeight 列表项高度
 * @param {number} props.buffer 缓冲区大小
 * @param {number} props.height 容器高度
 */
function VirtualList({ data, itemHeight = 50, buffer = 5, height = 400 }) {
  const [startIndex, setStartIndex] = useState(0); // 起始索引
  const [endIndex, setEndIndex] = useState(0);    // 结束索引
  const [scrollTop, setScrollTop] = useState(0);   // 滚动位置
  const viewportRef = useRef(null);                // 容器ref
  
  // 列表总高度
  const totalHeight = data.length * itemHeight;
  
  // 可见项目数
  const visibleCount = Math.ceil(height / itemHeight);
  
  // 更新可见范围
  const updateRange = () => {
    const newStart = Math.floor(scrollTop / itemHeight);
    const newEnd = newStart + visibleCount + buffer;
    setStartIndex(newStart);
    setEndIndex(Math.min(newEnd, data.length - 1));
  };
  
  // 处理滚动事件
  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };
  
  // 滚动位置变化时更新范围
  useEffect(() => {
    updateRange();
  }, [scrollTop]);
  
  // 初始计算可见范围
  useEffect(() => {
    updateRange();
  }, []);
  
  // 可见数据
  const visibleData = data.slice(startIndex, endIndex + 1);
  
  return (
    <div
      ref={viewportRef}
      style={{
        height: `${height}px`,
        overflow: 'auto',
        position: 'relative'
      }}
      onScroll={handleScroll}
    >
      {/* 占位元素 */}
      <div style={{ height: `${totalHeight}px` }} />
      
      {/* 实际内容 */}
      <div
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          transform: `translateY(${startIndex * itemHeight}px)`
        }}
      >
        {visibleData.map((item) => (
          <div
            key={item.id}
            style={{
              height: `${itemHeight}px`,
              position: 'absolute',
              top: `${item.id * itemHeight}px`,
              width: '100%'
            }}
          >
            {item.text}
          </div>
        ))}
      </div>
    </div>
  );
}

// 使用示例
const data = Array.from({length: 10000}, (_, i) => ({id: i, text: `Item ${i}`}));

function App() {
  return (
    <VirtualList 
      data={data} 
      itemHeight={50} 
      height={500} 
    />
  );
}

思路解释

  • 设置容器固定高度,开启垂直滚动。
  • 监听滚动事件 onScroll,通过 ref 获取 DOM 元素。
  • 占位元素高度 = 总项数 × 每项高度。
  • 用于让容器产生滚动条,但并不显示真实内容
  • .content 内容容器被平移(transform)到底部;
  • startIndex * itemHeight 就是当前要显示的首项的位置;
  • 只渲染 visibleData = data.slice(startIndex, endIndex + 1) 这部分;
相关推荐
WeiXiao_Hyy21 分钟前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡37 分钟前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone43 分钟前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09011 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农1 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king2 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳2 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵3 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星3 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_3 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js