实现虚拟列表

虚拟列表实现

实现思路

  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) 这部分;
相关推荐
复苏季风14 分钟前
聊聊 ?? 运算符:一个懂得 "分寸" 的默认值高手
前端·javascript
探码科技16 分钟前
AI驱动的知识库:客户支持与文档工作的新时代
前端
朱程42 分钟前
写给自己的 LangChain 开发教程(一):Hello world & 历史记录
前端·人工智能
luckyCover44 分钟前
js基础:手写call、apply、bind函数
前端·javascript
Dragon Wu2 小时前
前端 下载后端返回的二进制excel数据
前端·javascript·html5
北海几经夏2 小时前
React响应式链路
前端·react.js
晴空雨2 小时前
React Media 深度解析:从使用到 window.matchMedia API 详解
前端·react.js
一个有故事的男同学2 小时前
React性能优化全景图:从问题发现到解决方案
前端
探码科技2 小时前
2025年20+超实用技术文档工具清单推荐
前端
Juchecar2 小时前
Vue 3 推荐选择组合式 API 风格(附录与选项式的代码对比)
前端·vue.js