
定高虚拟列表可以直接用 scrollTop / itemSize 定位起始行。但卡片里一旦出现多行文字、延迟加载的图片或可展开内容,行高不再固定,原来的公式就不够用了。
这篇会从已有的虚拟列表结构出发,依次改造高度缓存、可见区查找、缓冲区和分页逻辑。技术思路参考掘金作者 西维,代码按 Vue 3 教学示例重新组织,并修正了原文片段里的几处边界问题。
一、保留两层结构,把固定行高换成位置缓存
虚拟列表仍然需要一个撑起滚动条的占位层,以及一个真正渲染少量节点的内容层:
<div class="viewport" @scroll="onScroll">
<div :style="{ height: total + 'px' }">
<div :style="{
transform: 'translateY(' + offset + 'px)'
}">
<article v-for="row in visible" :key="row.id">
{{ row.text }}
</article>
</div>
</div>
</div>
这是结构片段,变量会在后面的计算中逐步得到。实际容器需要固定可视高度并设置 overflow: auto。

虚拟列表结构示意图
定高时,每行位置都能直接算。不定高时,要改为维护 positions:每项保存 id、index、height、top、bottom。其中 top 是所有前置行高度之和,bottom = top + height。
未渲染的行先给一个预估高度;拿到真实 DOM 后,再写回实测高度。下面的函数可直接运行:
function rebuild(rows, heights, estimate = 100){
let top = 0;
return rows.map((row, index)=>{
const height = heights.get(row.id) ?? estimate;
const item = {
id: row.id, index, top, height,
bottom: top + height
};
top += height;
return item;
});
}
heights 是以稳定 ID 为键的 Map。新数据追加时,旧行的测量结果可以保留,避免把所有行重新变回预估高度。
原文的初始化示例有一处参数笔误:第二个参数传入了位置数组,而函数内部需要的是预估行高。迁移代码时应明确传入数值;空列表的总高度也要回退为 0。
二、用二分查找定位起始行,注意等号的归属
现在不能再除以固定行高。我们要找的是:第一个 bottom 大于滚动位置的行。
例如第一行范围是 [0, 40),当 scrollTop 正好等于 40,第一行已经完全移出视口,起点应该落到第二行。
function firstBottomAfter(positions, y){
let lo = 0;
let hi = positions.length;
while(lo < hi){
const mid = Math.floor((lo + hi)/2);
if(positions[mid].bottom <= y){
lo = mid + 1;
}else{
hi = mid;
}
}
return lo;
}
返回 positions.length 表示没有匹配行;空数组自然返回 0,调用方不再直接读取不存在的 item.index。
原文在右半区收缩处只将右边界减一,没有按中点缩小区间,不能保证标准二分查找的效率。这里使用半开区间,每轮都按中点收缩。
结束行也要根据累计高度计算,不能一直保留“起点加固定可见行数”的做法。若实际行高比预估值小,固定数量可能填不满视口。
三、把缓冲算进去,内容偏移跟着渲染起点走
缓冲区的作用,是在可视区域上下提前渲染一段内容,让快速滚动时不必等到行进入视口才开始创建节点。

蓝色为可见内容,灰色为额外渲染的缓冲内容
原文按可见行数的比例设置缓冲。这里改成像素缓冲,更容易与动态高度统一计算:
const bufferPx = 240; // 教学起点,可按设备调整
const start = firstBottomAfter(
positions, Math.max(0, scrollTop - bufferPx)
);
const end = Math.min(
positions.length,
firstBottomAfter(
positions, scrollTop + clientHeight + bufferPx
)+1
);
const visible = rows.slice(start, end);
const offset = positions[start]?.top ?? 0;
const total = positions.at(-1)?.bottom ?? 0;
注意最后的 offset:它对应的是含上缓冲区的渲染起点。如果数据切片已经提前了几行,偏移却仍按可见区起点计算,内容就会整体错位。
240 像素只是示例值。缓冲过小可能跟不上快速滚动,过大则增加节点和渲染成本,需要结合真实内容调整。
四、DOM 测量后更新缓存,图片加载后再测一次
原文在视图更新后读取 getBoundingClientRect().height。这个测量思路可以保留,但 Vue 的一次 DOM 更新完成,不代表图片也加载完成。
因此,首次渲染后测量一次,再用 ResizeObserver 观察已挂载的行。核心连接方式如下,需放入组件生命周期:
let frame = 0;
const observer = new ResizeObserver(()=>{
if(frame)return;
frame = requestAnimationFrame(()=>{
frame = 0;
measureRenderedRows();
});
});
// 新节点挂载后调用
observer.observe(rowElement);
// 节点离开渲染区时调用
observer.unobserve(rowElement);
// 组件卸载时调用
observer.disconnect();
cancelAnimationFrame(frame);
测量函数读取当前行元素的实际高度,按 data-id 更新缓存,再重建累计位置。先批量读取、再批量更新,不要每测量一行就重新排一遍整个列表。
行元素统一使用 box-sizing: border-box,把间距放在测量范围内,例如使用内边距或边框。外边距不会被 getBoundingClientRect().height 包含,容易导致累计位置偏差。
如果上方行变高,光重算位置还不够,还要保住正在阅读的位置:更新前记住第一条可见行的 ID 和行内偏移,更新后找到它的新 top,恢复 scrollTop = 新 top + 行内偏移。
对于 Vue,先更新用于渲染的状态,等待 nextTick() 后再设置滚动位置。自行补偿时检查浏览器自带的滚动锚定,示例容器使用 overflow-anchor: none,避免支持该属性的浏览器重复补偿。
五、追加数据时保留旧缓存,请求失败不要推进游标
原文使用触底追加来模拟分页。实际接口里,严格比较滚动距离是否恰好等于总高度,很容易漏掉触发,应改成剩余距离阈值:
const remaining =
scrollHeight - scrollTop - clientHeight;
if(remaining < 300 && !loading && !error){
loadMore();
}
下一页加载至少管理三个状态:loading 防止重复请求,hasMore 表示是否结束,error 用于呈现重试入口。
async function loadMore(){
if(loading || !hasMore)return;
loading = true;
try{
const page = await fetchPage(cursor);
appendUnique(page.items); // 按 ID 去重
cursor = page.nextCursor;
hasMore = cursor != null;
error = null;
}catch(e){
error = e;
}finally{
loading = false;
}
}
这是接口控制片段,fetchPage 和 appendUnique 需要接入业务实现。只有响应成功,才追加数据和推进游标;失败保留原游标,交给用户重试,避免滚动事件不断触发失败请求。
另外两种情况需要单独处理:容器宽度改变后,旧行高可能不再有效,应使高度缓存失效并重新测量;筛选条件改变后,应取消旧请求或校验请求版本,防止旧响应混入新列表。
六、把这些逻辑放到真实页面里验证
下面是配套 Vue 3 教学示例的实际运行截图。示例从 500 条模拟数据开始,支持延迟插入图片、改变容器宽度、模拟分页失败及重试。

本地浏览器运行截图,不是生产性能测试。
一次验收中,页面在列表中部实际挂载了 9 行;给视口上方行增加图片后,原阅读锚点偏移约 0.16 像素。重复触发加载只发起一次模拟请求,失败后重试追加到 600 条;这些结果只对应本示例和这次测试条件。
真正接进业务后,还要继续检查空数据、短列表、筛选重置和快速往返滚动。当前示例采用整表重建位置,更新复杂度是 O(n),用于讲清原理;超大数据集不宜把它当作零成本操作。
如果要继续补浏览器渲染、图片布局和接口优化,可以在 前端性能课程 里按这几个主题查找章节,获取条件以页面为准。
参考与学习入口
原文作者:掘金 ID 西维,文章《深入【虚拟列表】动态高度、缓冲、异步加载… Vue 实现》:juejin.cn/post/7168645862296879117。本文参考原文思路重新实现,引用两张原文图并保留署名;补充了边界修正、Vue 3 示例及本地验收。接入业务时,仍需根据数据源补齐接口适配和组件生命周期。