前端定高和不定高虚拟列表

定高虚拟列表 (Fixed-Height Virtual List)

定高虚拟列表的实现相对简单,因为每个列表项的高度是固定的。这意味着我们可以通过简单的乘法来精确计算任何一个列表项在整个列表中的位置(top 值)以及整个列表的总高度。

实现思路

  1. 确定容器和列表项高度: 设定列表容器的固定高度 (containerHeight) 和每个列表项的固定高度 (itemHeight)。

  2. 计算总高度: totalHeight = data.length * itemHeight。这个高度用于撑开一个占位元素 (phantomscroller),以确保滚动条的正常显示和长度。

  3. 监听滚动事件: 当用户滚动容器时,获取当前的 scrollTop 值。

  4. 计算可见区域:

    • 起始索引 (startIndex): Math.floor(scrollTop / itemHeight)。这是当前可视区域的第一个元素的索引。
    • 结束索引 (endIndex): startIndex + Math.ceil(containerHeight / itemHeight)。这是当前可视区域的最后一个元素的索引。
    • 为了优化用户体验,通常会增加一个缓冲区域 (bufferCount),例如在 startIndex 前和 endIndex 后多渲染几个元素,以避免快速滚动时出现白屏。那么 startIndex 变为 Math.max(0, startIndex - bufferCount)endIndex 变为 Math.min(data.length - 1, endIndex + bufferCount)
  5. 计算偏移量 (offsetY): offsetY = startIndex * itemHeight。这个值将作为 transform: translateY() 应用到实际渲染列表项的容器上,使得渲染的元素在视觉上处于正确的位置。

  6. 动态渲染: 根据 startIndexendIndex 从原始数据中截取需要渲染的数据子集,并渲染到DOM中。

代码实现 (Vanilla JavaScript)

以下是一个简单的定高虚拟列表的实现示例:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>定高虚拟列表</title>
    <style>
        body {
            font-family: sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f4f4f4;
        }
        h2 {
            color: #333;
        }
        .virtual-list-container {
            width: 300px;
            height: 400px; /* 容器固定高度 */
            overflow-y: auto; /* 允许垂直滚动 */
            border: 1px solid #ccc;
            background-color: #fff;
            position: relative; /* 为内部绝对定位元素提供参考 */
            margin: 20px auto;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }
        .virtual-list-phantom {
            position: absolute;
            left: 0;
            top: 0;
            right: 0;
            z-index: -1; /* 确保不遮挡内容 */
        }
        .virtual-list-content {
            position: absolute;
            left: 0;
            top: 0;
            right: 0;
        }
        .list-item {
            height: 50px; /* 列表项固定高度 */
            line-height: 50px;
            padding: 0 15px;
            border-bottom: 1px solid #eee;
            box-sizing: border-box;
            background-color: #f9f9f9;
            color: #555;
        }
        .list-item:nth-child(even) {
            background-color: #eef;
        }
    </style>
</head>
<body>
    <h2>定高虚拟列表示例</h2>
    <div class="virtual-list-container" id="fixedListContainer">
        <div class="virtual-list-phantom" id="fixedListPhantom"></div>
        <div class="virtual-list-content" id="fixedListContent">
            <!-- 列表项将在这里渲染 -->
        </div>
    </div>

    <script>
        const fixedListContainer = document.getElementById('fixedListContainer');
        const fixedListPhantom = document.getElementById('fixedListPhantom');
        const fixedListContent = document.getElementById('fixedListContent');

        // 模拟大量数据
        const totalData = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `列表项 ${i + 1}` }));

        const itemHeight = 50; // 每个列表项的高度 (px)
        const bufferCount = 5; // 上下缓冲区域的列表项数量,用于平滑滚动体验

        let containerHeight = 0; // 容器的实际高度
        let visibleData = []; // 当前可见的数据
        let startIndex = 0; // 当前可见区域的起始索引
        let endIndex = 0; // 当前可见区域的结束索引

        // 更新可见列表项
        function updateVisibleItems() {
            containerHeight = fixedListContainer.clientHeight; // 获取容器当前高度
            const scrollTop = fixedListContainer.scrollTop; // 获取当前滚动位置

            // 计算起始索引
            startIndex = Math.floor(scrollTop / itemHeight);
            // 增加缓冲区域
            startIndex = Math.max(0, startIndex - bufferCount);

            // 计算结束索引
            endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + bufferCount * 2;
            endIndex = Math.min(totalData.length, endIndex);

            // 截取需要渲染的数据
            visibleData = totalData.slice(startIndex, endIndex);

            // 计算内容区域的偏移量
            const offsetY = startIndex * itemHeight;

            // 设置 phantom 元素的高度,撑开滚动条
            fixedListPhantom.style.height = `${totalData.length * itemHeight}px`;

            // 移动内容区域到正确的位置
            fixedListContent.style.transform = `translateY(${offsetY}px)`;

            // 渲染可见数据
            fixedListContent.innerHTML = visibleData.map((item, index) => {
                // 这里的index是visibleData中的索引,需要加上startIndex才是原始数据中的真实索引
                return `<div class="list-item" data-index="${startIndex + index}">${item.text}</div>`;
            }).join('');
        }

        // 监听滚动事件
        fixedListContainer.addEventListener('scroll', () => {
            // 可以添加节流或防抖以优化性能,但对于定高列表,通常直接更新即可
            updateVisibleItems();
        });

        // 页面加载和窗口大小改变时更新
        window.addEventListener('load', updateVisibleItems);
        window.addEventListener('resize', updateVisibleItems);

        // 初始渲染
        updateVisibleItems();
    </script>
</body>
</html>

代码解释:

  1. HTML 结构:

    • virtual-list-container: 外部容器,设置固定高度并 overflow-y: auto 来创建滚动条。它是 position: relative 的,以便内部的绝对定位元素可以相对于它定位。
    • virtual-list-phantom: 占位元素,position: absolutez-index: -1。它的高度会被动态设置为所有数据项的总高度,从而模拟出完整的滚动条。
    • virtual-list-content: 实际渲染列表项的容器,position: absolute。它会根据滚动位置通过 transform: translateY() 进行垂直位移,使其内部渲染的可见列表项始终处于可视区域内。
  2. CSS 样式: 定义了容器、占位元素、内容区域和列表项的基本样式,特别是列表项的固定高度 height: 50px 是关键。

  3. JavaScript 逻辑:

    • totalData: 模拟了包含 10000 条数据的数组。

    • itemHeight: 定义了每个列表项的高度,这是定高虚拟列表的基础。

    • bufferCount: 缓冲区的数量,在可视区域上下各多渲染一些元素,减少快速滚动时的白屏现象,提升用户体验。

    • updateVisibleItems() 函数是核心:

      • 获取容器的 clientHeightscrollTop
      • 根据 scrollTopitemHeight 计算 startIndexendIndex,并考虑 bufferCount
      • 使用 totalData.slice(startIndex, endIndex) 截取需要渲染的数据子集。
      • 设置 fixedListPhantom.style.heighttotalData.length * itemHeight,确保滚动条的正确长度。
      • 设置 fixedListContent.style.transform = translateY(${offsetY}px)`` ,将内容区域整体位移,使得当前 startIndex 对应的元素位于正确的位置。
      • 通过 innerHTML 渲染 visibleData
    • 监听 scroll 事件,当容器滚动时调用 updateVisibleItems

    • 在页面加载和窗口大小改变时也调用 updateVisibleItems,确保初始渲染和布局正确。

不定高虚拟列表 (Variable-Height Virtual List)

不定高虚拟列表的实现要复杂得多,因为每个列表项的高度是可变的。这意味着我们不能简单地通过索引和固定高度来计算元素的精确位置和总高度。

实现思路

  1. 预估高度与真实高度:

    • 由于无法提前知道所有列表项的真实高度,我们首先给每个列表项一个预估高度 (estimatedItemHeight)。
    • 当列表项被渲染到DOM中后,我们需要测量它们的真实高度,并缓存起来。
    • 对于尚未渲染的列表项,继续使用预估高度。
  2. 维护位置信息:

    • 我们需要一个数据结构(例如一个数组 positions)来存储每个列表项的详细位置信息,包括 indexheight(真实高度或预估高度)、top(距离列表顶部的距离)和 bottom(距离列表顶部的距离 + 高度)。
    • positions 数组会随着列表项的渲染和真实高度的测量而不断更新。
  3. 动态计算总高度:

    • 总高度不再是 data.length * itemHeight。它将是 positions 数组中所有已知真实高度的总和,加上未渲染项的预估高度总和。
    • totalHeight = sum(positions[i].height)
  4. 查找可见区域的起始索引:

    • 由于高度不固定,不能直接 scrollTop / itemHeight
    • 需要通过遍历 positions 数组或使用二分查找 来找到第一个 bottom 值大于 scrollTop 的列表项的索引,即 startIndex
  5. 处理高度差异导致的跳动:

    • 当一个列表项首次渲染并测量到真实高度与预估高度不同时,其后续所有列表项的 topbottom 值都会受到影响,导致整个列表的总高度发生变化。

    • 这可能导致滚动条跳动或内容位置不匹配。为了解决这个问题,当某个列表项的真实高度被测量后,需要:

      • 更新该列表项在 positions 数组中的 height
      • 重新计算该列表项及其之后所有列表项的 topbottom 值。
      • 如果 scrollTop 发生了变化(因为内容高度变化导致滚动条位置相对变化),可能需要调整 scrollTop 来保持用户视角的稳定。一种常见的方法是计算高度差异 (diff = realHeight - estimatedHeight),然后将 scrollTop 加上这个 diff,以抵消内容高度变化带来的视觉跳动。
  6. 渲染和测量:

    • 根据 startIndexendIndex 渲染可见数据。
    • 使用 IntersectionObserver 或在渲染后立即通过 getBoundingClientRect() 测量每个渲染项的真实高度。
    • 将测量到的真实高度更新到 positions 数组中。

代码实现 (Vanilla JavaScript)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>不定高虚拟列表</title>
    <style>
        body {
            font-family: sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f4f4f4;
        }
        h2 {
            color: #333;
        }
        .virtual-list-container {
            width: 300px;
            height: 400px; /* 容器固定高度 */
            overflow-y: auto; /* 允许垂直滚动 */
            border: 1px solid #ccc;
            background-color: #fff;
            position: relative; /* 为内部绝对定位元素提供参考 */
            margin: 20px auto;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }
        .virtual-list-phantom {
            position: absolute;
            left: 0;
            top: 0;
            right: 0;
            z-index: -1;
        }
        .virtual-list-content {
            position: absolute;
            left: 0;
            top: 0;
            right: 0;
        }
        .list-item {
            padding: 10px 15px; /* 不定高,所以只有padding */
            border-bottom: 1px solid #eee;
            box-sizing: border-box;
            background-color: #f9f9f9;
            color: #555;
            word-wrap: break-word; /* 确保文本换行 */
            min-height: 30px; /* 最小高度,防止内容过少时高度为0 */
        }
        .list-item:nth-child(even) {
            background-color: #eef;
        }
    </style>
</head>
<body>
    <h2>不定高虚拟列表示例</h2>
    <div class="virtual-list-container" id="variableListContainer">
        <div class="virtual-list-phantom" id="variableListPhantom"></div>
        <div class="virtual-list-content" id="variableListContent">
            <!-- 列表项将在这里渲染 -->
        </div>
    </div>

    <script>
        const variableListContainer = document.getElementById('variableListContainer');
        const variableListPhantom = document.getElementById('variableListPhantom');
        const variableListContent = document.getElementById('variableListContent');

        // 模拟大量数据,内容长度随机
        const totalData = Array.from({ length: 1000 }, (_, i) => {
            const randomLength = Math.floor(Math.random() * 100) + 20; // 20到120个字符
            const text = `列表项 ${i + 1}: ${'这是一个很长很长的文本,用于测试不定高虚拟列表的渲染效果。'.repeat(Math.ceil(randomLength / 20)).substring(0, randomLength)}`;
            return { id: i, text: text };
        });

        const estimatedItemHeight = 60; // 预估每个列表项的高度 (px)
        const bufferCount = 5; // 上下缓冲区域的列表项数量

        let positions = []; // 存储每个列表项的位置信息 { index, height, top, bottom }
        let containerHeight = 0;

        // 初始化 positions 数组
        function initPositions() {
            positions = totalData.map((_, i) => ({
                index: i,
                height: estimatedItemHeight,
                top: i * estimatedItemHeight,
                bottom: (i + 1) * estimatedItemHeight
            }));
        }

        // 获取某个索引的列表项的 top 值
        function getItemTop(index) {
            if (index < 0) return 0;
            if (index >= positions.length) return positions[positions.length - 1].bottom;
            return positions[index].top;
        }

        // 根据 scrollTop 查找起始索引 (二分查找优化)
        function getStartIndex(scrollTop) {
            let low = 0;
            let high = positions.length - 1;
            let startIndex = 0;

            while (low <= high) {
                const mid = Math.floor((low + high) / 2);
                const item = positions[mid];
                if (item.bottom > scrollTop) {
                    startIndex = mid;
                    high = mid - 1;
                } else {
                    low = mid + 1;
                }
            }
            return startIndex;
        }

        // 更新可见列表项
        function updateVisibleItems() {
            containerHeight = variableListContainer.clientHeight;
            const scrollTop = variableListContainer.scrollTop;

            // 计算起始索引
            startIndex = getStartIndex(scrollTop);
            startIndex = Math.max(0, startIndex - bufferCount); // 加上缓冲

            // 计算结束索引
            // 简单估算需要渲染的项数,然后加上缓冲
            const visibleCount = Math.ceil(containerHeight / estimatedItemHeight);
            endIndex = startIndex + visibleCount + bufferCount * 2;
            endIndex = Math.min(totalData.length, endIndex);

            // 截取需要渲染的数据
            const visibleData = totalData.slice(startIndex, endIndex);

            // 计算内容区域的偏移量
            const offsetY = getItemTop(startIndex);

            // 计算总高度
            const totalHeight = positions[positions.length - 1].bottom;
            variableListPhantom.style.height = `${totalHeight}px`;

            // 移动内容区域到正确的位置
            variableListContent.style.transform = `translateY(${offsetY}px)`;

            // 渲染可见数据
            variableListContent.innerHTML = visibleData.map((item, index) => {
                // 这里的index是visibleData中的索引,需要加上startIndex才是原始数据中的真实索引
                return `<div class="list-item" data-index="${startIndex + index}">${item.text}</div>`;
            }).join('');

            // 测量真实高度并更新 positions
            measureAndCorrectPositions();
        }

        // 测量真实高度并修正 positions
        function measureAndCorrectPositions() {
            const items = variableListContent.children;
            let hasHeightChanged = false;
            let currentOffset = getItemTop(startIndex); // 当前渲染区域的起始top

            for (let i = 0; i < items.length; i++) {
                const itemDom = items[i];
                const index = parseInt(itemDom.dataset.index);
                const realHeight = itemDom.offsetHeight; // 获取真实高度

                if (positions[index].height !== realHeight) {
                    // 如果真实高度与缓存高度不一致,更新并标记需要重新计算
                    const oldHeight = positions[index].height;
                    positions[index].height = realHeight;
                    const diff = realHeight - oldHeight;

                    // 更新后续所有项的 top/bottom
                    for (let j = index + 1; j < positions.length; j++) {
                        positions[j].top += diff;
                        positions[j].bottom += diff;
                    }
                    hasHeightChanged = true;
                }
            }

            // 如果有高度变化,可能需要重新渲染或调整滚动位置
            if (hasHeightChanged) {
                // 重新计算总高度
                const newTotalHeight = positions[positions.length - 1].bottom;
                variableListPhantom.style.height = `${newTotalHeight}px`;

                // 重新调用 updateVisibleItems 确保内容和滚动条同步
                // 或者更精细地处理:如果当前scrollTop在变化项之后,需要调整scrollTop
                // 这里简单粗暴地重新更新一次,实际项目中可能需要更复杂的逻辑来避免跳动
                // 例如:如果用户正在滚动,且变化发生在可视区域之外,可以延迟更新
                // 或者计算当前scrollTop应有的新值并设置
                const currentScrollTop = variableListContainer.scrollTop;
                const newOffset = getItemTop(startIndex);
                const offsetDiff = newOffset - currentOffset; // 渲染区域起始位置的变化

                if (offsetDiff !== 0) {
                    // 调整滚动条位置以保持视觉稳定
                    variableListContainer.scrollTop = currentScrollTop + offsetDiff;
                }
                updateVisibleItems(); // 再次调用以确保渲染内容正确
            }
        }

        // 监听滚动事件
        variableListContainer.addEventListener('scroll', () => {
            // 可以添加节流或防抖以优化性能
            updateVisibleItems();
        });

        // 页面加载和窗口大小改变时更新
        window.addEventListener('load', () => {
            initPositions(); // 初始化位置数据
            updateVisibleItems();
        });
        window.addEventListener('resize', () => {
            // 窗口大小改变时,容器高度可能变化,需要重新计算
            updateVisibleItems();
        });

        // 初始渲染
        initPositions();
        updateVisibleItems();
    </script>
</body>
</html>

代码解释:

  1. HTML 结构和 CSS 样式:

    • 与定高列表类似,但 list-item 不再有固定 height,而是使用 paddingmin-height 来适应内容。
  2. JavaScript 逻辑:

    • totalData: 模拟数据,每个列表项的文本长度随机,从而产生不同的高度。

    • estimatedItemHeight: 预估高度,用于初始化 positions 数组和初步计算。

    • positions: 核心数据结构,存储每个列表项的 indexheight(真实或预估)、topbottom

    • initPositions(): 在加载时初始化 positions 数组,所有项都使用 estimatedItemHeight

    • getItemTop(index): 根据 positions 数组获取指定索引项的 top 值。

    • getStartIndex(scrollTop): 使用二分查找在 positions 数组中快速定位 scrollTop 对应的 startIndex。这是不定高列表的关键优化点,避免了线性遍历。

    • updateVisibleItems():

      • 与定高列表类似,获取 scrollTopcontainerHeight
      • 使用 getStartIndex 确定 startIndex
      • 计算 endIndex,同样考虑缓冲。
      • 根据 startIndex 获取 offsetY(当前渲染区域的 top 值)。
      • 设置 variableListPhantom 的高度为 positions 数组中最后一项的 bottom 值,这是当前计算出的总高度。
      • 通过 transform: translateY() 移动 variableListContent
      • 渲染可见数据。
      • 最重要的一步: 调用 measureAndCorrectPositions() 来测量刚渲染的列表项的真实高度并更新 positions
    • measureAndCorrectPositions():

      • 遍历 variableListContent 中所有已渲染的DOM元素。

      • 通过 itemDom.offsetHeight 获取每个元素的真实高度。

      • 如果真实高度与 positions 中缓存的高度不一致,则更新 positions[index].height

      • 计算高度差异 diff

      • 关键: 遍历 index 之后的所有列表项,更新它们的 topbottom 值,因为它们的位置都因 diff 而发生了变化。

      • 如果发生高度变化 (hasHeightChangedtrue),需要:

        • 重新设置 variableListPhantom 的高度,以反映新的总高度。
        • 调整 scrollTop 如果渲染区域的起始 top 值因高度变化而改变 (offsetDiff !== 0),则需要将 variableListContainer.scrollTop 加上 offsetDiff,以抵消内容位移,保持用户视角的稳定。
        • 再次调用 updateVisibleItems() 确保渲染内容和滚动条的同步。
    • 滚动事件监听、加载和窗口大小改变的事件监听与定高列表类似。

不定高虚拟列表的挑战在于如何高效且平滑地处理高度变化。上述代码提供了一个基础的实现,通过预估高度、测量真实高度、更新位置数组和调整滚动条来解决核心问题。在实际生产环境中,可能还需要进一步的优化,例如使用 requestAnimationFrame 进行滚动节流,更复杂的滚动位置调整策略,以及对数据更新的响应等。

相关推荐
计蒙不吃鱼6 分钟前
一篇文章实现Android图片拼接并保存至相册
android·java·前端
全职计算机毕业设计28 分钟前
基于Java Web的校园失物招领平台设计与实现
java·开发语言·前端
啊~哈1 小时前
vue3+elementplus表格表头加图标及文字提示
前端·javascript·vue.js
小小小小宇1 小时前
前端小tips
前端
小小小小宇1 小时前
二维数组按顺时针螺旋顺序
前端
安木夕2 小时前
C#-Visual Studio宇宙第一IDE使用实践
前端·c#·.net
努力敲代码呀~2 小时前
前端高频面试题2:浏览器/计算机网络
前端·计算机网络·html
高山我梦口香糖2 小时前
[electron]预脚本不显示内联script
前端·javascript·electron
神探小白牙2 小时前
vue-video-player视频保活成功确无法推送问题
前端·vue.js·音视频
Angel_girl3193 小时前
vue项目使用svg图标
前端·vue.js