找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖
Claude、GPT 海外模型 API 接入Claude skills 从入门到精通 吴恩达亲授 AI Agent 核心技能2026 瞪哥公务员考试全攻略 行测申论一站式系统备考
Agent 文心智能蒸馏模型实战 90G 课程智泊 AI 大模型训练营 基于 LangChain 的 RAG 与提示工程实战构建企业级 AI 大脑:大模型微调与 RAG / Agent 全栈实战

6169

积分

0

好友

783

主题
发表于 4 天前 | 查看: 1| 回复: 0

虚拟列表数据聚合与结构化渲染示意

定高虚拟列表可以直接用 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 条模拟数据开始,支持延迟插入图片、改变容器宽度、模拟分页失败及重试。

Vue 3 动态高度虚拟列表运行截图

本地浏览器运行截图,不是生产性能测试。

一次验收中,页面在列表中部实际挂载了 9 行;给视口上方行增加图片后,原阅读锚点偏移约 0.16 像素。重复触发加载只发起一次模拟请求,失败后重试追加到 600 条;这些结果只对应本示例和这次测试条件。

真正接进业务后,还要继续检查空数据、短列表、筛选重置和快速往返滚动。当前示例采用整表重建位置,更新复杂度是 O(n),用于讲清原理;超大数据集不宜把它当作零成本操作。

如果要继续补浏览器渲染、图片布局和接口优化,可以在 前端性能课程 里按这几个主题查找章节,获取条件以页面为准。

参考与学习入口

原文作者:掘金 ID 西维,文章《深入【虚拟列表】动态高度、缓冲、异步加载… Vue 实现》:juejin.cn/post/7168645862296879117。本文参考原文思路重新实现,引用两张原文图并保留署名;补充了边界修正、Vue 3 示例及本地验收。接入业务时,仍需根据数据源补齐接口适配和组件生命周期。




上一篇:agent-skills 前端实战:从需求到验收的性能优化六步法
下一篇:ret2shellcode 栈溢出实战:shellcode 注入、ASLR 绕过与远程 getshell
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-25 04:56 , Processed in 0.569784 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表