虚拟列表(动态高度,性能优化,骨架屏)

虚拟列表
最基本的计算值
  • startIndex 起始索引
  • endIndex 结束索引
  • scrollTop 用户滑动的距离
  • startOffset 准确的偏移量
针对千万级数据需要考虑的因素
  • 数据量过多

    • 直接放在普通数组中会炸开
    • 直接设置响应式,vue3拦截处理也会造成阻塞
    • 仅通过遍历数据和scrollTop比对查找,复杂度为O(n)
  • 动态高度

    • 图片加载完成
    • 文字内容不同
    • Dom的高度瞬间改变会导致用户所看到的图片被压下去(如果在视口上方)
  • 快读移动

    • 计算慢,会导致白屏
解决方式
  • 先实现最基础的虚拟列表,滚动层占位层视页层

  • 引入ResizeObsever 维护heights数组,动态高度问题

  • 将数组换为Float32Array 二分查找定位起点 惰性求值,性能速度优化

  • 跳动补偿,防止突变高度导致视野区受干扰

重要理解

用户看到的内容是 出现在窗口中的渲染视区层 而占位层提供的占位器会导致整个窗口向下移动,而渲染视区层和占位层都使用的是绝对定位,top:0。当窗口下移的时候,渲染视区层不会跟着下移,用户此时看到的是空白内容,因为我们只渲染了需要的DOM节点,并未将所有DOM节点渲染,当窗口下移时,渲染视区层的没有DOM啊,这就是一片空白。可以将整个过程看作"在望远镜中看天空",望远镜就是窗口,天空就是渲染视区层,而占位层形成的滚动条是让望远镜上下移动的转轴。

我们的渲染视区层与天空不同,只有一部分是有星星的(内容),其余皆是黑暗。所以偏移量就是让你的渲染视区层也向下移动,同时也只移动被完全销毁的DOM节点数。

代码实现
javascript 复制代码
<template>
  <div
    class="virtual-list-container"
    ref="containerRef"
    @scroll="onScroll"
    :style="{ height: containerHeight || '100%' }"
    >
    <!-- 占位层:构造全部数据的预期物理总高度,撑开原生滚动条 -->
    <div
      class="virtual-list-phantom"
      :style="{ height: listTotalHeight + 'px' }"
      ></div>

    <!-- 渲染层:采用绝对定位,随滑动动态偏移至视区 -->
    <div
      class="virtual-list-content"
      :style="{ transform: `translate3d(0, ${startOffset}px, 0)` }"
      >
      <template v-for="node in visibleData" :key="node.__index">
        <!-- 单个节点包裹层:绑定 Ref 用于 ResizeObserver 监听真实高度 -->
        <div
          class="virtual-list-item-wrapper"
          :data-index="node.__index"
          :ref="(el) => setItemRef(el, node.__index)"
          >
          <!-- 作用域插槽:对外抛出数据字典,实现 headless API 逻辑解耦 -->
          <!-- 严密的空指针防御 / 极速拖拽处理 -->
          <slot
            :item="node.data"
            :index="node.__index"
            :loading="node.data == null"
            >
            <!-- 默认防跌落骨架屏插槽 -->
            <div v-if="node.data == null" class="default-skeleton">
              Loading chunk {{ node.__index }}...
            </div>
          </slot>
        </div>
      </template>
    </div>
  </div>
</template>

<script setup lang="ts">
  import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';

  interface VirtualListProps {
    total: number;                   // 千万级数据总长度
    dataSource: any[];               // 数据源(可含洞,未加载的数据为 undef / null)
    estimatedItemHeight?: number;    // 默认预估高度
    bufferSize?: number;             // 缓冲区数量
    containerHeight?: string;        // 整个列表容器的高度
  }

  // 为props设置默认值
  const props = withDefaults(defineProps<VirtualListProps>(), {
    estimatedItemHeight: 50,
    bufferSize: 10,
    containerHeight: '100%'
  });

  // 解耦流控:滚动彻底停顿或低速时才抛出真实视窗范围,用于发请求
  const emit = defineEmits<{
    (e: 'range-change', start: number, end: number): void;
  }>();

    // =================【 1. 核心状态 Refs 】=================
    const containerRef = ref<HTMLElement | null>(null);
  // 性能黑科技,由于数据过多,定义一个保存所有数据的数组时,vue的响应式会奔溃
  // 需要拦截添加响应式,这样会卡死,所以我们设置为普通变量
  // 当计算属性依赖的数据不是响应式的(也及时我们设置的普通变量)
  // 但这些数据变化后,我需要强制计算属性重新执行
  // 就可以用一个 trigger 响应式变量来 "手动触发" 更新
  const trigger = ref(0); 
  const startIndex = ref(0);
  const endIndex = ref(0);
  const startOffset = ref(0);   
  const itemRefs = new Map<number, HTMLElement>();

  // 状态标志
  let isScrollJumping = false; // 用于标识因计算导致的坐标跳动补偿
  let scrollDebounceTimer: number | null = null; // 请求防抖

  // =================【 2. O(1) 终极性能 Cache 模型 】=================
  // 常规的对象缓存数组在千万级下直接内存爆炸 (10M * 3属性 * 8字节 + 对象头 ≈ 1000MB+)
  // 这里采用 TypedArray 占用连续内存,极大降低堆积与 V8 GC 压力 (约 10M * ~12 Byte = 120MB)

  // 记载每个盒子的高度(可能动态改变)
  let heights: Float32Array;
  // 这个盒子的顶部离最上面的起点的绝对距离
  let tops: Float64Array;
  // 这个盒子的底部离最上面的起点的绝对距离 top+height
  let bottoms: Float64Array;

  // 惰性游标指针
  // 如果初始值为-1  一项都没测过
  // 滚动到20行   lastMeasuredIndex为19
  let lastMeasuredIndex = -1; 

  // 初始化坐标池
  const initCache = () => {
    // Float32Array(length)
    heights = new Float32Array(props.total);  //存储每条高度的数组
    tops = new Float64Array(props.total);     //存储每条数据距离视口最顶部的距离
      bottoms = new Float64Array(props.total);  //存储每条数据距离视口最底部的距离
  
  // 默认预估高度(.fill(value) 所有数组中的元素都将是value默认值)
  heights.fill(props.estimatedItemHeight);
  // 初始状态:没有任何数据被测量过
  lastMeasuredIndex = -1;
};

// 惰性递推计算:用到了哪里,就算到哪里,优化内部循环性能
// 用户滑动到15(index)那我就只算15(index)之前和15条(index)的内容,15之后的内容等用户滑动到16的时候再算
const processPositionsUpToIndex = (index: number) => {
  if (index <= lastMeasuredIndex) return; // O(1) 短路
  
  let lastBottom = lastMeasuredIndex >= 0 ? bottoms[lastMeasuredIndex] : 0;
  // 上一次算完的最后一项+1
  const startIndex = Math.max(0, lastMeasuredIndex + 1);
  
  // 极致优化的紧凑循环,消除原先的大量数组寻址和 if 判断
  for (let i = startIndex; i <= index; i++) {
//   当前项 top = 上一项 bottom
//   当前项 bottom = 当前项 top + 当前项高度
    tops[i] = lastBottom;
    lastBottom += heights[i];
    bottoms[i] = lastBottom;
  }
  
  lastMeasuredIndex = index;
};

// 获取某一项的bottom时
const getBottom = (index: number) => {
  processPositionsUpToIndex(index);
  return bottoms[index];
};

// 获取某一项的top时
const getTop = (index: number) => {
  processPositionsUpToIndex(index);
  return tops[index];
};

// =================【 3. 动态高度 & 坐标补偿机制 】=================
// 动态高度自愈护板,同时offsetCorrection用于修正滚动条位置
// ResizeObserver 浏览器原生自带的"DOM尺寸监听工具"

let ro: ResizeObserver | null = null;

onMounted(() => {
  initCache();
  
  ro = new ResizeObserver((entries) => {
    
    let heightChanged = false; //高度是否变化
    let offsetCorrection = 0;  //作用:记录所需修正画面的滚动距离

//  entries = 数组(里面装着所有刚刚发生了尺寸变化的DOM信息)
    entries.forEach((entry) => {
      const el = entry.target as HTMLElement; //拿到dom元素
      const index = Number(el.dataset.index); //这个DOM是列表的第几条,index是模版中绑定的
      if (Number.isNaN(index)) return;

      const newHeight = entry.borderBoxSize?.[0]?.blockSize ?? el.getBoundingClientRect().height;
      const oldHeight = heights[index];

      // 精度容差防抖(abs取绝对值,只有大于0.1px我再进行修改)
      if (newHeight && Math.abs(newHeight - oldHeight) > 0.1) {
        // [动态鲁棒性]: 如果发生改变的 DOM 在当前可视区上方,会打破视角的偏移对立
        // 我们必须动态回补差值拉伸,绝对消除 Visual Jumping (画面跳窗) 现象!
        if (index < startIndex.value) {
          offsetCorrection += (newHeight - oldHeight);
        }

        heights[index] = newHeight;
        // 从这个index开始,后面已经计算过的所有位置全部作废,下次重新计算
        lastMeasuredIndex = Math.min(lastMeasuredIndex, index - 1);
        // 标记高度发生变化
        heightChanged = true;
      }
    });

    if (heightChanged) {
      if (offsetCorrection !== 0 && containerRef.value) {
        isScrollJumping = true; // 上锁:拦截由于补偿拉伸而虚假触发的原生 Scroll 侦听
        //视区上方盒子高度变化(高了50),那么我们手动让滚动条往下走50
        containerRef.value.scrollTop += offsetCorrection;
      }
      trigger.value++; // 强制视图刷新
      updateView();    // 重新计算可视区域
    }
  });

  // 初始算一次
  updateView();
});

// 管理 Refs 生命周期
const setItemRef = (el: any, index: number) => {
  if (el) {
    if (!itemRefs.has(index)) {
      itemRefs.set(index, el);
      ro?.observe(el);
    }
  }
};

// =================【 4. 滚动事件 & 核心算子 】=================

// 二分查找求起始点
const getStartIndexByBinarySearch = (scrollTop: number) => {
  // 【白屏修复核心】:千万级数据暴力下发拉条时,如果使用 while (getBottom() < scrollTop) i++
  // 会导致产生几百万次函数挂起的开销,直接卡顿白屏!
  // 解决方案:使用预估高度直接猜测 index(指数级靠近),并使用纯二分。
  
  // 1. 根据估算高度,直接定位一个高概率的起始探测点(缩小二分查找范围)
  let guessIndex = Math.floor(scrollTop / props.estimatedItemHeight);
  guessIndex = Math.max(0, Math.min(props.total - 1, guessIndex));
  
  // 2. 将探针高速推进到 guessIndex,这在底层 V8 只是一瞬间的 TypedArray 遍历,无函数栈开销
  processPositionsUpToIndex(guessIndex);

  let left = 0;
  let right = props.total - 1;
  let result = 0;

  // 纯二分查找 O(log N)
  // 找的是第一个bottom大于scrollTop的项,这个就是最顶部的项
  while (left <= right) {
    const mid = (left + right) >> 1; // 极速向下取整
    const bottom = getBottom(mid); // 按需计算
    if (bottom === scrollTop) {
      return mid + 1;
    } else if (bottom < scrollTop) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }
  return result;
};

// 视区更新机
const updateView = () => {
  if (!containerRef.value) return;
  // 获取滚动信息
  const scrollTop = containerRef.value.scrollTop;
  const clientHeight = containerRef.value.clientHeight;

  if (clientHeight === 0 || props.total === 0) return; // 防御:容器 0 高度或空数据

  // 计算屏幕第一项
  startIndex.value = getStartIndexByBinarySearch(scrollTop);

  // 计算屏幕最后一项
  let currentEnd = startIndex.value;
  const visibleEnd = scrollTop + clientHeight; //屏幕可视区域最底部像素位置
  //只要当前项没超过屏幕底部或者不是最后一个,那我就继续往下找
  while (currentEnd < props.total && getBottom(currentEnd) <= visibleEnd) {
    currentEnd++;
  }
  // 可视区域最后一项的下一项
  endIndex.value = currentEnd;

  // 计算带 Buffer 的补偿偏移, translate 需要回退到 buffer 头部的坐标!
  const realStart = Math.max(0, startIndex.value - props.bufferSize);
  startOffset.value = realStart >= 1 ? getBottom(realStart - 1) : 0;
  
  trigger.value++; // 驱动 Buffer Computed 更新
};

// 滚动时触发视图更新
const onScroll = () => {
  if (isScrollJumping) {
    isScrollJumping = false; // 放行修复期幽灵事件
    return;
  }
  updateView();

  // Headless 请求防抖 - 数据流动只在合理的用户滞留期触发
  if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer);
  scrollDebounceTimer = window.setTimeout(() => {
    emit('range-change', startIndex.value, endIndex.value);
  }, 120) as unknown as number;
};

// =================【 5. 视图数据绑定/计算 】=================

// 估算的沙盘虚拟总高
const listTotalHeight = computed(() => {
  trigger.value; // trigger变了就重新计算
  if (props.total === 0) return 0;
  if (lastMeasuredIndex === -1) {
    return props.total * props.estimatedItemHeight;
  }
  
  const measuredHeight = bottoms[lastMeasuredIndex];
  const unmeasuredCount = props.total - 1 - lastMeasuredIndex;
  return measuredHeight + unmeasuredCount * props.estimatedItemHeight;
});

// 解构保护+节点重用 Buffer 的安全输出集
const visibleData = computed(() => {
  trigger.value; // subscribe map
  
  if (props.total === 0) return [];
  
  const start = Math.max(0, startIndex.value - props.bufferSize);
  const end = Math.min(props.total, endIndex.value + props.bufferSize);

  const viewData = [];
  for (let i = start; i < end; i++) {
    viewData.push({
      __index: i,
      data: props.dataSource[i] // 防御态:若是请求未返回的数据,它是 undefined,由插槽 fallback
    });
  }
  return viewData;
});

// 改变整体 Total 级变化清空
watch(() => props.total, () => {
  initCache();
  updateView();
});

onUnmounted(() => {
  ro?.disconnect();
  itemRefs.clear();
  if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer);
});
</script>

<style scoped>
.virtual-list-container {
  overflow-y: auto;
  position: relative;
  -webkit-overflow-scrolling: touch; /* 滑动玄学流畅护航 */
}

.virtual-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.virtual-list-content {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}

.virtual-list-item-wrapper {
  /* 规避闪烁必须的 Layout box 隔离 */
  contain: layout;
}

.default-skeleton {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 50px;
  background-color: #f5f5f5;
  border-bottom: 1px solid #e0e0e0;
  color: #999;
}
</style>
相关推荐
吴声子夜歌2 小时前
JavaScript——对象
开发语言·javascript·ecmascript
不会写DN3 小时前
Js常用的字符串处理
开发语言·前端·javascript
晓13133 小时前
第三章 TypeScript 高级类型
前端·javascript·typescript
Sylus_sui3 小时前
鸿蒙音乐项目懒加载优化实战
javascript
黑白两客3 小时前
Vue 缓存机制
前端·vue.js·缓存
Luna-player3 小时前
Vue 组件,用来实现一个响应式图标网格布局,核心是用 CSS 实现固定宽高比的正方形容器,并在里面放置图片和文字。
前端·css·vue.js
SuperEugene3 小时前
Vue keep-alive 实战避坑:include/exclude + 路由 meta 标记,中后台路由缓存精准可控|状态管理与路由规范篇
开发语言·前端·javascript·vue.js·缓存·前端框架
Gauss松鼠会3 小时前
【GaussDB】GaussDB 重要内存参数设置
数据库·oracle·性能优化·database·gaussdb
用户9714171814273 小时前
JavaScript 模块化详解:CommonJS、ES Module 到底有什么区别?
javascript