像素vs条数级虚拟列表

目录

[面试(像素):定/估高 + 缓冲区](#面试(像素):定/估高 + 缓冲区)

DOM

数学公式(缓冲区)

完整代码

[动态高度:估高 + 高度缓存+二分查找](#动态高度:估高 + 高度缓存+二分查找)

避坑指南

[找起始索引 start:改用"二分查找"](#找起始索引 start:改用“二分查找”)

[关于 endIdx 的查找(用二分找还是用预估算?)](#关于 endIdx 的查找(用二分找还是用预估算?))

[纠正位置:updated 生命周期更新真实高度](#纠正位置:updated 生命周期更新真实高度)

完整代码

开发(条数):动态高度+双向时间轴+强实时更新

相关

滚动锚定

数据缓存

流程

[1. 进会话(初始模式)](#1. 进会话(初始模式))

[2. 日常收发消息与视口状态判断](#2. 日常收发消息与视口状态判断)

[3. 向上滑动:加载历史(Prepend)与顶部高度补偿](#3. 向上滑动:加载历史(Prepend)与顶部高度补偿)

[3.5 向下滑动:裁尾回收与窗口复位](#3.5 向下滑动:裁尾回收与窗口复位)

[4. 跳转定位(预加载目标前后10条):搜索 / 首条未读](#4. 跳转定位(预加载目标前后10条):搜索 / 首条未读)

[5. 切换会话 / 离开](#5. 切换会话 / 离开)


目标都一样:少画 DOM。差别在「少」怎么算。

像素级虚拟列表 条数级虚拟列表
总高度 用估算/缓存高度撑出来 就是真实 DOM 总高
滚动条 可对应「全部消息」的假高度 只对应「当前窗口」真实高度
不定高 必须测高/估高 直接渲染,省事
代价 实现难、和气泡耦合重 DOM 仍可能几十~一两百,不是极致

常说的/面试:(像素级)虚拟列表 = 按 scrollTop + viewport 算 index 区间 + spacer/transform。

实际开发:条数级虚拟列表 = 按条数 slice,不维护总虚拟高度。

面试(像素):定/估高 + 缓冲区

「前端进阶」高性能渲染十万条数据(虚拟列表)

DOM

  • 外层容器(Container) :固定高度(如 400px),overflow-y: auto被滚动的

  • 幽灵背景(Phantom) :无内容,纯粹靠 height = totalHeight 把滚动条撑开,z-index: -1。

  • 渲染区域(Content) :绝对定位或 transform 偏移,里面只渲染 visibleData

数学公式(缓冲区)

(边说边写出来)

totalHeight: listData.length * itemSize // 撑开滚动条的高度

javascript 复制代码
// 可视区域高度固定,称之为screenHeight
const visibleCount = Math.ceil(screenHeight / itemSize);
  
const rawStart = Math.floor(scrollTop / itemHeight);
const start = Math.max(0, rawStart - buffer);
const end = Math.min(listData.length , start + visibleCount + buffer * 2);

const visibleData = listData.slice(start,end);
const offset = start * itemHeight;

完整代码

javascript 复制代码
<!-- 1. 外层容器:固定高度,负责滚动 -->
<div class="container" ref="container" @scroll="onScroll" style="height: 400px; overflow-y: auto;">
  <!-- 2. 幽灵背景:纯粹靠高度撑开真实滚动条 -->
  <div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
  
  <!-- 3. 渲染区域:绝对定位/Transform 偏移拉回视口,通过引入 Z 轴参数触发‌合成层(Compositing Layer)‌,在部分旧机型或复杂场景下能减少重排(Reflow),提升滚动流畅度 。 -->
  <div class="content" :style="{ transform: `translate3d(0, ${startOffset}px, 0)` }">
    <div v-for="item in visibleData" :key="item.id" class="item">
      {{ item.value }}
    </div>
  </div>
</div>

export default {
	data() {
	  return {
		listData: [],      // 10万条原始数据
		itemSize: 50,      // 每项固定高 50px
		screenHeight: 400, // 视口高度
		bufferCount: 5,    // 上下缓冲区条数
		
		start: 0,
		end: 0,
		startOffset: 0
	  };
	},
	computed: {
	  // 1. 总高度(撑开滚动条)
	  totalHeight() {
		return this.listData.length * this.itemSize;
	  },
	  // 2. 视口能容纳的条数
	  visibleCount() {
		return Math.ceil(this.screenHeight / this.itemSize);
	  },
	  // 3. 真实渲染的数据切片
	  visibleData() {
		return this.listData.slice(this.start, this.end);
	  }
	},
	methods: {
	  // 4. 滚动核心逻辑
	  onScroll() {
		const scrollTop = this.$refs.container.scrollTop;
		
		// 算视口内的绝对起点
		const rawStart = Math.floor(scrollTop / this.itemSize);
		
		// 加缓冲区算实际 start 和 end
		this.start = Math.max(0, rawStart - this.bufferCount);
		this.end = Math.min(this.listData.length, rawStart + this.visibleCount + this.bufferCount * 2);
		
		// 关键:把渲染框拉回当前可视位置(跟着 start 走)
		this.startOffset = this.start * this.itemSize;
	  }
	}
  };


//以下可忽略

.infinite-list-phantom {
  position: absolute; /* 绝对定位脱离文档流 */
  left: 0;
  top: 0;
  right: 0;
  z-index: -1; /* 1. 关键:把幽灵背景送到最底层 */
}

.infinite-list {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  /* 2. 内容层默认 z-index 比 -1 高,且里面没有背景色或者背景透明,绝对不会被遮挡 */
}
  • infinite-list-container可视区域的容器
  • infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
  • infinite-list 为列表项的渲染区域

动态高度:估高 + 高度缓存+二分查找

滚动事件触发

  1. 拿到 scrollTop

  1. 通过【二分查找】在 positions 中找出 start 索引

  1. 加上缓冲区,计算 end 索引,更新 visibleData 驱动视图渲染

  1. startOffset = positionsstart.top (把渲染框拉到对应位置)

  1. 【DOM 渲染完成 (updated)】

  1. 测量 DOM 真实高度 -> 修正 positions 映射表 -> 累加更新后续所有节点的 top/bottom
  • 测量(Measured) :当 DOM 实际渲染出来后,通过 getBoundingClientRect() 拿到它的真实高度,更新缓存。

  • 维护位置映射表(Positions) :在内存中维护一个数组,记录每一个 Item 的 topbottomheight

javascript 复制代码
// 假设列表有 3 条数据,预估高度 100px
this.positions = [
  { index: 0, height: 100, top: 0,   bottom: 100 },
  { index: 1, height: 100, top: 100, bottom: 200 },
  { index: 2, height: 100, top: 200, bottom: 300 },
]
  • 列表总高度 listHeight :就是映射表中最后一个元素的 bottom 值(this.positions[last].bottom)。

  • 偏移量 startOffset :直接等于 start 这一项的 top 值(即 this.positions[start] ? this.positions[start].top : 0)。

避坑指南

  1. 锚点丢失(抖动问题)

    • 现象:当向上滚动时,上面原本预估高度为 100px 的 Item 被渲染出来后,真实高度变成了 300px。这会导致当前视口内的内容瞬间被"顶"下去,造成极其严重的画面抖动。

    • 解决 :如果向上滚动时修正了上面的元素高度,需要同步调整 scrollTop ,把差值补回到滚动条上(即 scrollTop += 真实高度 - 预估高度),保持视口内容不动。

  2. updated 里的性能隐患

    • 每次更新都用 for 循环修补后续所有 positions 项,如果数据有 10 万条,耗时会增加。

    • 优化 :使用 树状数组(Binary Indexed Tree)线段树 ,或者只做"懒修正"(用到哪一项才计算那项的 top),可以把修正时间复杂度从 O(N) 降到 O(\\log N)

  3. 终极懒人方案(ResizeObserver)

    • 如果 Item 内部的图片加载、展开折叠导致高度动态改变 ,需要用 ResizeObserver 监听 DOM 尺寸变化,随时触发高度更新逻辑。

找起始索引 start:改用"二分查找"

因为每项高度不同,positions 数组里的 topbottom单调递增的。

当滚动条滚动到 scrollTop 时,我们需要在 positions 数组中找到第一个 bottom > scrollTop 的元素索引

使用二分查找,时间复杂度只有 O(\\log N),即使上万条数据也是微秒级响应:

javascript 复制代码
// 二分查找:找到第一个 bottom 大于 scrollTop 的项
binarySearch(scrollTop) {
  let start = 0;
  let end = this.positions.length - 1;
  let tempIndex = null;

  while (start <= end) {
    let midIndex = Math.floor((start + end) / 2);
    let midValue = this.positions[midIndex].bottom;

    if (midValue === scrollTop) {
      return midIndex + 1;
    } else if (midValue > scrollTop) {
      tempIndex = midIndex;
      end = midIndex - 1; // 往左边继续找更靠前的
    } else {
      start = midIndex + 1; // 往右边找
    }
  }
  return tempIndex;
}
关于 endIdx 的查找(用二分找还是用预估算?)
  • 你的疑问"endIdx 不应该是找到第一个 bottom 大于 scrollTop + screenHeight 的吗?"

  • 解答理论上是的! 最完美的逻辑确实是用二分查找去精准定位 scrollTop + screenHeight 所在的 endIdx

  • 但是 :在尚未滚到那些节点时,那些节点的真实 DOM 根本还没渲染positions 里存的仅仅是"预估高度"。用预估高度去二分查 scrollTop + screenHeight,和你直接用 startIdx + Math.ceil(screenHeight / itemHeight) 算出来的结果一模一样 !所以大部分实现为了省去一次二分查找的开销,直接用 startIdx + 预估可视数量

纠正位置:updated 生命周期更新真实高度

  • v-for="(item, index) in visibleList" 中:

    • 这里的局部 index切片后数组的索引(比如 0, 1, 2...)。

    • 但我们在 positions 数组里记录的,是全局源数据列表的索引

    • 所以模板必须写 :data-index="startIdx + index"(即:当前切片起点 startIdx + 局部偏移 index )。 这样渲染出来的 DOM 上带的就是全局唯一索引(比如 <div data-index="105">),在 updated 钩子函数里通过 Number(node.dataset.index) 读出来的就是全局第 105 项,直接对应 this.positions[105]无论怎么滚动,全局索引绝对不会乱!

DOM 渲染完成后,在 Vue 的 updated(或 React 的 useLayoutEffect)中测量真实 DOM 高度,并修正映射表

javascript 复制代码
updated() {
  this.$nextTick(() => {
    if (!this.$refs.items || !this.$refs.items.length) return;

    // 1. 获取已渲染 DOM 节点的真实高度
    this.$refs.items.forEach((node) => {
      let rect = node.getBoundingClientRect();
      let realHeight = rect.height;
      let index = +node.dataset.index; // HTML 属性 data-index,类型转换+"5" -> 5

      let oldHeight = this.positions[index].height;
      let dValue = oldHeight - realHeight; // 高度差值

      // 2. 如果真实高度和预估高度不一致,修正当前项及后续所有项的 top/bottom
      if (dValue !== 0) {
        this.positions[index].height = realHeight;
        this.positions[index].bottom = this.positions[index].bottom - dValue;

        // 核心:当前项高度变了,后面所有项的 top 和 bottom 都要随之调整(顺延)
        for (let k = index + 1; k < this.positions.length; k++) {
          this.positions[k].top = this.positions[k - 1].bottom;
          this.positions[k].bottom = this.positions[k].bottom - dValue;
        }
      }
    });
  });
}

完整代码

虚拟列表测量列表项高度 的场景下,唯一推荐的首选是 item.getBoundingClientRect().height

绝对不要用 clientHeight 测列表项,而 offsetHeight 只能作为取整备选。

属性 精度(小数) 包含 Border? 虚拟列表测项高 选用原因
getBoundingClientRect().height ✅ 支持 (如 50.45px) ✅ 包含 ⭐⭐⭐⭐⭐ 首选 亚像素级精准 ,累加 top/bottom 不会产生累计偏差或滚动微抖
offsetHeight ❌ 强制四舍五入取整 ✅ 包含 ⭐⭐⭐ 备选 仅支持整数,若 CSS 渲染出小数(如 0.5px 边框),滚动多了会有 1-2px 错位
clientHeight ❌ 强制四舍五入取整 ❌ 不包含 ❌ 禁用 会直接导致布局卡顿崩塌 !一旦项有 border,少算高度会导致 startOffset 越来越偏
  • 测【列表项 (Item)】 :无脑选 item.getBoundingClientRect().height(带边框、带小数、最准确)。

  • 测【外层容器 (Container)】 :用 container.clientHeight(计算可视视口区域,自动剔除容器自身的边框和滚动条)。

javascript 复制代码
<template>
  <div class="container" ref="containerRef" @scroll="onScrollTop" :style="{ height: screenHeight + 'px', overflowY: 'auto' }">
    <div class="c_phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div class="c_visible-list" :style="{ transform: `translate3d(0, ${startOffset}px, 0)` }">
      <div v-for="(item, idx) in visibleList" :key="item.id" ref="items" :data-index="start + idx">
        {{ item.val }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    list: { type: Array, default: () => [] },
    itemHeight: { type: Number, default: 50 },
    screenHeight: { type: Number, default: 500 },
    bufferCnt: { type: Number, default: 5 }
  },
  data() {
    return {
      positions: [],
      scrollTop: 0
    };
  },
  computed: {
    binaryStart() {
      let i = 0, j = this.positions.length - 1;
      while (i <= j) {
        let mid = Math.floor((i + j) / 2);
        if (this.positions[mid].bottom < this.scrollTop) {
          i = mid + 1;
        } else {
          j = mid - 1;
        }
      }
      return i;
    },
    start() {
      return Math.max(0, this.binaryStart - this.bufferCnt);
    },
    end() {
      return Math.min(this.list.length, this.start + Math.ceil(this.screenHeight / this.itemHeight) + 2 * this.bufferCnt);
    },
    startOffset() {
      return this.positions[this.start] ? this.positions[this.start].top : 0;
    },
    totalHeight() {
      return this.positions.length ? this.positions[this.positions.length - 1].bottom : 0;
    },
    visibleList() {
      return this.list.slice(this.start, this.end);
    }
  },
  methods: {
    onScrollTop() {
      this.scrollTop = this.$refs.containerRef.scrollTop;
    },
    initPositions() {
      this.positions = this.list.map((item, idx) => ({
        height: this.itemHeight,
        top: idx * this.itemHeight,
        bottom: (idx + 1) * this.itemHeight
      }));
    }
  },
  created() {
    this.initPositions();
  },
  updated() {
    const items = this.$refs.items || [];
    items.forEach((item) => {
      let idx = Number(item.dataset.index);
      const realHeight = item.getBoundingClientRect().height。
      const del = realHeight - this.positions[idx].height;

      if (del !== 0) {
        this.positions[idx].height = realHeight;
        this.positions[idx].bottom += del;

        while (++idx < this.positions.length) {
          this.positions[idx].top += del;
          this.positions[idx].bottom += del;
          k++;
        }
      }
    });
  }
};
</script>

开发(条数):动态高度+双向时间轴+强实时更新

  • 首屏只拉最近 N 条(initialListNum )

  • 上翻距离快到顶再拉 N 条,达到DOM 数量上限(裁远端)

  • 下翻超过一定距离,首尾同时减

  • 跳转: ele.scrollIntoView

  • key = msg_id,发送中用 temp_msg_id,成功后替换,

msg_id:Vue 在列表增删、窗口滑动时正确复用/更新对应 DOM

替换:「不闪」多半是同位置替换 + 内容已渲染 + 只去掉 loading 的体感

展示区间: slice( length - step , length - cutstep )

  • step:顶部裁掉的

  • cutstep:底部裁掉的

  • initialListNum = 20:每次加减的步长

  • cutbase = 4:窗口大约超过 20 * 4 条量级才开始裁尾

相关

滚动锚定

  • 浏览器原生(overflow-anchor):声明式 CSS 属性,交给浏览器 Layout 引擎自动纠偏,默认值 auto 自动纠偏。

  • DOM 精准定位(scrollIntoView):基于指定 DOM 坐标的 "相机对焦" 机制,API:element.scrollIntoView ()。

  • 高度补偿 :元素插入前总高 H 旧,插入后总高 H 新,算出高度变化量△,scrollTop+△校准滚动位置。

开发中

overflow-anchor:none 顶部有数据增删且 JS 手动改 scrollTop 时(如 当前IM虚拟列表)。

element.scrollIntoView:跳转

高度补偿:虚拟列表,顶部插入(变高)要加上 △,顶部/尾部裁剪(变矮)要减去△,核心都是维持"人眼看到的 DOM 相对视口位置不变"。

数据缓存

  • messageListMap (Vuex Store)主缓存 。按 conversationId 隔离。

    • 写入 :首屏/进会话 (SET)、上翻 (UNSHIFT)、收发新消息 (PUSH)。

    • 清理:非当前会话超 20 条即裁剪;非活跃会话整条删除。

  • history_message_list (组件 Component Data)临时历史模式缓存

    • 写入 :搜索跳转/跳未读定位时拉取,同步写 localStorage (set_history_message_list) 供预览。

    • 清理 :换会话或退出历史模式时立即清空为 []

  • 飞书/微信/QQ 客户端多是 SQLite 等原生库 + 消息先落本地再同步,打开会话常能 先出本地再补网

流程

1. 进会话(初始模式)

  • 数据准备 :从 Vuex 的 messageListMap[id] 拉取最近消息,若无则请求 API,触发 SET_MESSAGE_LIST

  • 双游标初始化

    • step \\approx \\min(20, \\text{list.length})

    • cutstep = 0

    • 只渲染最近的约 20 条 DOM(key = msg_id,发送中的暂用 temp_msg_id,成功后替换)。

  • 位置对齐:直接置底或对准最新消息。

2. 日常收发消息与视口状态判断

当底部收到新消息(PUSH_MESSAGE_LIST)时,视口逻辑分支如下:

复制代码
               底部来了新消息
                     │
       ┌─────────────┴─────────────┐
       ▼                           ▼
[ 处于贴底状态 (isBackBottom) ]    [ 处于向上看历史状态 ]
       │                           │
       ├─> 直接滚到底部 / 保持贴底   ├─> 不动 scrollTop(防打扰)
       └─> 不需要高度差补偿        └─> 弹出「haveNewMsg 有新消息」气泡

3. 向上滑动:加载历史(Prepend)与顶部高度补偿

当用户向上滚动快触顶时:

  1. 触发条件 :触发加载,loading = true

  2. 扩窗与拉取

    • 若 Vuex 内存有更早消息:step += 20

    • 若内存不足:发请求拉取更早历史写入 Store,再扩充 step

  3. 高度差补偿(关键)

    • 新 DOM 在顶部长出,导致 scrollHeight 变大。

    • 补偿公式scrollTop = \\Delta scrollHeight + 970

    • 注:970 是为了防止用户连续快速手势直接撞顶触发连刷的"预留缓冲距离",并非消息真实高度。

  4. 触发下边缘裁剪(若过长)

    • step - cutstep \\ge 80(即渲染 DOM 超过 80 条)时,触发 cutstep += 20

    • 效果:卸载靠近最新侧(底部)的 20 条 DOM,人在上面看历史时,底下那截先不画。

3.5 向下滑动:裁尾回收与窗口复位

当用户看完历史向下滑回最新时:

  1. 触发条件:向下滑动触底附近,且 cutstep \> 0

  2. 缩小窗口stepcutstep 同步减少约 20(窗口整体向最新消息方向滑移,把底部 DOM 重新放回)。

  3. 高度差补偿

    • 尾部 DOM 放回导致整体高度变矮(或顶部旧 DOM 卸载导致变矮)。

    • 补偿公式scrollTop = scrollTop_{old} - (scrollHeight_{old} - scrollHeight_{new})

  4. 归位处理 :当 cutstep 减为 0 且滑动回最底部时,清理未读 Point、跟底并重置 haveNewMsg 状态。

4. 跳转定位(预加载目标前后10条):搜索 / 首条未读

搜索跳转:悬停/选中某条、或点击后先出 loading 且并行拉(你们跳转时已经在拉)。

跳转场景不走日常的"双游标滑动"逻辑,而是切换为 历史模式 (history === true)

复制代码
用户点击跳转 / 看未读
       │
       ▼
1. 拉取该消息上下文 (get_message_history)
       │
       ▼
2. 存入 history_message_list(全量渲染该上下文片段,不走 step/cutstep 切片)
       │
       ▼
3. Vue 完成真实 DOM 挂载
       │
       ▼
4. 通过 getElementById找到对应 msg_id 的 DOM
       │
       ▼
5. 执行 dom.scrollIntoView({ block: 'center' }) 精准对准

5. 切换会话 / 离开

  • Vuex 清理 :非活跃会话清理多余 messageListMap,仅保留活跃会话约 30~40 条内存数据。

  • 组件清理 :清空 history_message_list = [],重置 step, cutstep, history 状态位,避免影响下一个会话的游标计算。

相关推荐
paopaokaka_luck1 小时前
基于springboot3+vue3的云南本土影视文旅推荐平台(协同过滤算法、Echarts图形化分析)
前端·spring boot·学习·算法·echarts·mybatis
狂师1 小时前
AI 测试提效 | 别搞万能 Skill,推荐用 5 个 Agent Skill 串起 UI 自动化全流程
前端·人工智能·测试
breeze jiang2 小时前
React useRef 实战:从 input 聚焦到 Web Worker 引用
前端·javascript·react.js
90后的晨仔2 小时前
uni-app 生命周期深度解析(iOS / Android / 鸿蒙 / Vue3 四端对照)
前端
钛态4 小时前
AI 辅助:前端框架反模式:过度封装、状态滥用与副作用失控
前端·vue·react·web
by__csdn4 小时前
Vue vs React vs Angular:前端框架终极对决
前端·vue.js·react.js·前端框架·vue·react·angular
钛态4 小时前
前端框架选型决策框架:React、Vue、Svelte、Solid 的 2026 年横评
前端·vue·react·web
2501_926102864 小时前
深入浅出Vue.js前端框架设计:核心原理与实践指南
前端·vue.js·前端框架