JS动态高度虚拟列表实现原理解析

作者:沉舟

时间:2026-10-31

浏览:385

本文深入解析基于原生JavaScript实现的动态高度虚拟列表原理。通过摒弃传统累计高度表,采用起始索引与偏移量组合的方式,实现了对任意起始位置和动态高度元素的天然支持。文章详细推导了上下滚动的索引计算逻辑、边界处理及缓冲区策略,并提供了核心算法代码,旨在为开发者提供高效、低内存占用的长列表渲染方案。

文章目录

前言

本文面向已具备虚拟列表基础知识的开发者,提供一种理解与实现动态虚拟列表的思路。请注意,本文主要探讨技术原理,非CV(计算机视觉)领域内容,请谨慎参考。

开发环境为浏览器原生JavaScript,不涉及任何第三方框架。

思考与设计方案

常见的虚拟列表实现通常采用累计高度的方式,利用一张非递减数组记录每个元素的起始位置,并通过二分查找确定当前可视区域需渲染的元素。这种实现方式直观易懂,但存在以下局限性:

  • 随着列表长度增加,记录高度的数组体积会不断膨胀。以双精度浮点数(number)为例,每个数值占用8字节。当数组长度达到131072时,内存占用至少为1MB。虽然绝对值不大,但对于追求极致性能的开发者而言,这并非最优雅的方案。
  • 若列表初始位置非零,在确定当前位置之前所有元素的高度之前,列表无法渲染任何内容(针对动态虚拟列表)。常见解决方案是预先估算元素高度,但这往往需要后端返回内容数据。后端传输大量数据,前端仅用于估算高度后便丢弃,造成资源浪费。前端只需调用API,而后端需处理更多逻辑,这种交互模式并不理想。
  • 当列表中某个元素的高度发生变化时,从该元素开始,后续所有元素的起始位置均需重新计算。尽管只需传导变化量,但这始终不是最优解。

上述问题的核心在于依赖一张记录每个元素起始位置的表,表中每一项均基于前一项计算得出,导致整张表具有前向依赖性,修改任意一项都会对后续项产生副作用。因此,解决方案是摒弃这张表,重新设计渲染逻辑。

丢弃该表后,如何确定渲染范围?实际上,虚拟列表仅需确定起始元素索引 startIndex 以及起始元素到可视区顶部的距离 offset。结束位置可通过从起始元素开始累加高度,直到无更多元素或列表高度超过渲染范围来确定。这种设计带来了以下优势:

  • 虚拟列表天然支持任意起始位置!若希望列表初始展示第500个元素,只需提供 startIndex = 500 和 offset = 0 即可实现。
  • 虚拟列表天然支持动态高度元素!渲染区域外元素高度变化不影响列表逻辑;渲染区域内元素高度变化时,保持 startIndex 和 offset 不变,重新执行渲染即可。

虽然设计理想,但需解决滚动问题。原虚拟列表只需修改当前位置并重新渲染,现改为 startIndex 和 offset 的组合后,滚动时不仅需重新计算 startIndex,还需基于滚动距离 delta 和新的 startIndex 更新 offset:

约定索引为 i 的元素高度为 height[i]

获取索引为 i 的元素高度的方法为 getHeight(i: number) => number,若元素不存在则返回 -1

基础滚动逻辑

向下滚动处理

前言

由于仅关注 startIndex 和 offset,仅当 offset >= height[startIndex],即起始元素已处于渲染范围外时,才需重新计算 startIndex。计算方法为:让 offset 循环减去 height[startIndex],同时 startIndex 加一,直到满足 offset < height[startIndex]:

let newOffset = offset + delta;
// 向后移动,直到offset >= height
let height = getHeight(startIndex);
while (height >= 0 && newOffset >= height) {
    newOffset -= height;
    height = getHeight(++startIndex);
}
if (height < 0 && startIndex > 0) startIndex--;

向上滚动处理

前言

逻辑类似,让 offset 循环加上 height[startIndex-1],同时 startIndex 减一,直到满足 offset >= 0:

let newOffset = offset + delta;
let height = getHeight(--startIndex);
while (newOffset < 0 && height >= 0) {
    newOffset += height;
    height = getHeight(--startIndex);
}
startIndex++;

边界条件处理

约定已渲染列表高度为 listHeight,可视区域高度为 viewHeight

获取渲染结果的方法为 getRenderRange(startPosition: [number, number], viewHeight, getHeight) => [number, number]

针对向下滚动,从 delta 入手,通过计算剩余可用高度 restHeight 来限制 delta 的最大值。考虑到后续元素,需让 restHeight 循环加上 height[++endIndex],直到满足 restHeight >= delta 或无可渲染元素:

let restHeight = listHeight - offset - viewHeight;
if (restHeight < delta) {
    // 计算剩余高度,限制移动距离
    let [_, endIndex] = getRenderRange(
        [startIndex, offset],
        viewHeight,
        getHeight
    );
    let nextElementHeight = getHeight(endIndex);
    while (restHeight < delta && nextElementHeight >= 0) {
        restHeight += nextElementHeight;
        nextElementHeight = getHeight(++endIndex);
    }
    delta = Math.min(delta, restHeight);
}

针对向上滚动,只需保证 offset 的值大于等于0即可:

newOffset = Math.max(0, newOffset);

缓冲区策略

缓冲区是可视区与不可视区的过渡地带,旨在元素进入可视区前完成渲染,尤其在高度不确定时。虽然虚拟列表支持可视区内高度变化,但提前加载元素可减少未加载页面的展示概率,并减轻因元素位置突变导致的视觉晃动。

实现方式简单,仅需修改基础滚动循环的退出条件:

约定缓冲区长度为 paddingHeight,取值范围为 [0, Infinity]

向下滚动:offset 循环减去 height[startIndex],直到 offset - height[startIndex] < paddingHeight;向上滚动:offset 循环加上 height[startIndex-1],直到 offset >= paddingHeight

代码实现

整合上述滚动逻辑:

/**
 * 根据起始位置和移动距离计算新的起始位置
 * @param {[number, number]} startPosition 起始位置 [起始元素索引,起始元素offset]
 * @param {number} delta 移动距离
 * @param {[number, number, number]} renderInfo 渲染信息 [视口高度,预渲染高度,列表高度]
 * @param {(index: number) => number} getHeight 高度计算函数
 * @returns {[number, number]} 新的起始位置
 */
function move(startPosition, delta, renderInfo, getHeight) {
  let [startIndex, offset] = startPosition;
  const [viewHeight, paddingHeight, listHeight] = renderInfo;

  let newOffset = offset;

  if (delta > 0) {
    // 向下滚动
    let restHeight = listHeight - offset - viewHeight;
    if (restHeight < delta) {
      // 计算剩余高度,限制移动距离
      let [_, endIndex] = getRenderRange(startPosition, renderInfo, getHeight);
      let nextElementHeight = getHeight(endIndex);
      while (restHeight < delta && nextElementHeight >= 0) {
        restHeight += nextElementHeight;
        nextElementHeight = getHeight(++endIndex);
      }
      delta = Math.min(delta, restHeight);
    }
    newOffset = offset + delta;

    // 向后移动,直到offset >= paddingHeight
    let height = getHeight(startIndex);
    while (height >= 0 && newOffset - height >= paddingHeight) {
      newOffset -= height;
      height = getHeight(++startIndex);
    }
    if (height < 0 && startIndex > 0) startIndex--;
  } else if (delta < 0) {
    // 向上滚动
    newOffset = offset + delta;
    if (newOffset < paddingHeight) {
      // 向前移动,直到offset >= paddingHeight
      let height = getHeight(--startIndex);
      while (newOffset < paddingHeight && height >= 0) {
        newOffset += height;
        height = getHeight(--startIndex);
      }
      startIndex++;
      newOffset = Math.max(0, newOffset);
    }
  }

  return [startIndex, newOffset];
}

实现计算渲染范围的函数 getRenderRange,需注意返回的取值范围为 [startIndex, endIndex):

/**
 * 根据起始位置计算渲染范围
 * @param {[number, number]} startPosition 起始位置 [起始元素索引,起始元素offset]
 * @param {[number, number]} renderInfo 渲染信息 [视口高度,预渲染高度]
 * @param {(index: number) => number} getHeight 高度计算函数
 * @returns {[number, number, number]} 计算结果 [起始索引,结束索引,列表长度]
 */
function getRenderRange(startPosition, renderInfo, getHeight) {
  const [startIndex, offset] = startPosition;
  const [viewHeight, paddingHeight] = renderInfo;
  const renderHeight = offset + viewHeight + paddingHeight;
  let endIndex = startIndex;
  let height = getHeight(endIndex);
  let currentPosition = 0;
  while (height >= 0 && currentPosition < renderHeight) {
    currentPosition += height;
    height = getHeight(++endIndex);
  }
  return [startIndex, endIndex, currentPosition];
}

动态虚拟列表的核心代码至此结束。但要构建完整的虚拟列表,还需实现以下功能:

  • 用于获取元素高度的函数 getHeight(index:number) => number
  • 根据 getRenderRange 返回值进行渲染的函数 render() => void
  • 监听已渲染元素高度变化并重新执行 render
  • 监听 wheel 和 touchmove 事件,并按顺序执行 move 和 render
  • 为滚动添加动画效果

此外,为避免频繁调用 getHeight,可基于 LRUCache 或 LFUCache 等缓存技术对高度进行缓存。

考虑到 getHeight 和 render 在不同环境中可能与浏览器原生JS实现存在差异,且篇幅较长,此处省略具体实现。

以上为JS动态高度虚拟列表实现原理解析的详细内容,更多关于JS虚拟列表的资料请关注本站其它相关文章!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章