本文深入解析基于原生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虚拟列表的资料请关注本站其它相关文章!