📚 uniapp版本懒加载 + 不定高虚拟列表实现


💡 核心实现思路与技术要点

虚拟列表的实现涉及几个关键步骤:数据准备、位置预估、滚动计算和动态修正。

1. 数据准备与初始位置预估

数据结构化

为了方便组件内部管理和索引,原始数据需要被封装。

  • 思路: 将原始数据包裹一层,添加一个唯一的内部索引 _index
  • 关键实现: formatVirtualList 函数创建 IVirtualItem 结构,包含 origin(原始数据)和 _index

占位与预估

在组件初次渲染和数据加载时,需要根据预估高度计算每个项目的位置,并撑起整个列表的滚动区域。

  • 思路:

    1. 使用一个隐藏的 空块 (empty-block) 撑起整个列表的总高度,以确保滚动条的正确出现。
    2. 创建 positions 数组,用于存储每个列表项的 heighttopbottom 位置信息。初始化时,所有项的 height 均使用传入的 props.itemHeight(预估高度)。
  • 关键实现:

    • totalHeight computed 属性:计算 positions 数组中最后一项的 bottom 值。
    • setPositions 函数:初始化 positions 数组,利用 props.itemHeight 计算初始的 topbottom

2. 确定渲染区域(窗口计算)

虚拟列表的关键是高效计算当前滚动位置下,需要渲染的列表项的起始和结束索引。

获取容器信息

  • 思路: 在组件挂载后,获取滚动容器的实际高度,并计算出预估的可见列表项数量。
  • 关键实现: onMounted 钩子中,使用 uni.createSelectorQuery() 获取 .virtual-container 的高度 (screenHeight),并计算 visibleCount

高效查找起始索引

  • 思路: 监听滚动事件,根据当前的 scrollTop,快速定位第一个进入可视区域的列表项索引 (startIndex)。
  • 关键实现: getStartIndex(scrollTop) 函数 :采用了二分查找法(Binary Search) 。在有序的 positions 数组中查找第一个 bottom 值大于 scrollTop 的元素,这是性能优化的重要体现。

设置缓冲与渲染

  • 思路: 在可视区域的 startIndex 之前和 endIndex 之后各增加一个缓冲区(Buffer) ,以减少滚动时的白屏现象。

  • 关键实现:

    • aboveCountbelowCount computed 属性:根据 visibleCountprops.buffCount 计算缓冲区的项目数量。
    • renderList computed 属性:使用 slice() 截取包含缓冲区的完整数据。

3. 列表定位与滚动偏移

为了让渲染的内容与滚动条的位置保持一致,需要对渲染区域进行位移。

  • 思路: 使用 CSS transform: translateY() 将实际渲染的列表区域平移到正确的位置。

  • 关键实现: setOffet() 函数

    • 计算 offsetY 的值。它应该等于 startIndex 对应的 top 值减去上方缓冲区的总高度
    • 这样做保证了即使渲染区域只是一部分,它在整个虚拟高度中仍然位于正确的垂直位置。

4. 动态高度测量与修正(不定高核心)

这是实现不定高列表最复杂且最核心的部分。

测量实际高度

  • 思路: 每次滚动后,都需要测量当前渲染的列表项的实际高度,以修正最初的预估值。

  • 关键实现: handleScroll 中,设置 isMeasuring 标志位,并在 nextTick 中调用 updatePositions()

    • updatePositions 函数:使用 uni.createSelectorQuery().selectAll(".virtual-item").boundingClientRect() 获取当前渲染的 DOM 元素的实际尺寸

修正位置数据

  • 思路:

    1. 遍历测量结果,如果任何一项的实际高度与 positions 数组中记录的高度不一致(存在变化),则更新该项的 height
    2. 一旦某一项的高度发生变化,其后续所有列表项topbottom 值都会受到影响,必须全部重新计算(连锁更新)。
  • 关键实现:

    • updatePositions 中,通过循环遍历 positions 数组,使用新的 height 重新计算每个项目的 topbottom
    • 修正后,必须再次调用 setOffet() 来修正渲染列表的垂直偏移量。

5. 滚动加载(Load More)

组件还集成了触底加载更多数据的能力。

  • 思路: 监听滚动事件,判断当前滚动位置是否已接近列表底部预设的阈值。

  • 关键实现: handleScroll 中,判断逻辑为:totalHeight.value - (scrollTop + screenHeight.value) <= bottomThreshold

    • 如果触底,则触发 loadMore 事件,同时将 loading 状态设为 true
    • 通过向父组件传递一个 done() 回调函数,允许父组件在数据加载完成后通知子组件,关闭 loading 状态。

源代码:

javascript 复制代码
<template>
  <scroll-view scroll-y="true" class="virtual-container" @scroll="handleScroll">
    <view class="empty-block" :style="{ height: totalHeight + 'px' }"></view>
    <view class="virtual-list" :style="{ transform: 'translateY(' + offsetY + 'px)' }">
      <view
          class="virtual-item"
          v-for="item in renderList"
          :id="item._index"
          :key="item._index"
          :data-index="item._index"
      >
        <slot name="default" :item="item.origin">
          <view>{{item.origin}}</view>
        </slot>
      </view>
    </view>
    <view v-show="loading">
      <slot name="loading">
        <view class="loading-wrap">
          <Loading />
        </view>
      </slot>

    </view>
  </scroll-view>
</template>

<script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick } from "vue";
import Loading from "@/components/loading/index.vue";
const props = defineProps({
  dataSource: {
    type: Array,
    default: () => [],
  },
  // 预估高度对于不定高虚拟列表至关重要
  itemHeight: {
    type: Number,
    default: 80,
  },
  buffCount: {
    type: Number,
    default: 1
  }
});

const emits = defineEmits(['loadMore'])

interface IPositionItem {
  height: number;
  top: number;
  bottom: number;
}

interface IVirtualItem {
  origin: any;
  _index: number;
}

const virtualList = ref<IVirtualItem[]>([]);
const positions = ref<IPositionItem[]>([]);
const screenHeight = ref(0);

const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);

const totalHeight = computed(() => {
  return positions.value[positions.value.length - 1]?.bottom || 0;
});

const renderList = computed(() => {
  return (virtualList.value || []).slice(startIndex.value - aboveCount.value, endIndex.value + belowCount.value);
});

const aboveCount = computed(() => {
  return Math.min(startIndex.value, visibleCount.value * props.buffCount)
})

const belowCount = computed(() => {
  return Math.min((virtualList.value || []).length - endIndex.value, visibleCount.value * props.buffCount)
})

// 计算所有项的预估位置信息
function setPositions(arr: any[]) {
  if (!arr || !arr.length || !Array.isArray(arr)) return;
  if (positions.value.length > 0) {
    let currentTop = 0;
    positions.value = arr.map((item, index) => {
      const existingPos = positions.value[index];
      const itemHeight = existingPos ? existingPos.height : props.itemHeight;
      const newItem = {
        height: itemHeight,
        top: currentTop,
        bottom: currentTop + itemHeight,
      };
      currentTop = newItem.bottom;
      return newItem;
    });
  } else {
    positions.value = arr.reduce((acc, item, index) => {
      const itemHeight = props.itemHeight;
      const newItem = {
        height: itemHeight,
        top: index * itemHeight,
        bottom: (index + 1) * itemHeight,
      };
      acc.push(newItem);
      return acc;
    }, []);
  }
}

function formatVirtualList(arr: any) {
  if (!arr || !arr.length || !Array.isArray(arr)) return;
  return arr.map((item, index) => {
    return {
      origin: item,
      _index: index,
    };
  });
}

// 二分法查找startIndex
function getStartIndex(scrollTop: number) {
 let left = 0;
  let right = positions.value.length - 1;
  let result = null;
 while (left <= right) {
    const middle = Math.floor(left + (right - left) / 2);
   const middleValue = positions.value[middle].bottom;
   if (middleValue === scrollTop) {
    return middle + 1;
   } else if (middleValue < scrollTop) {
     left = middle + 1;
   } else {
      if (result == null || result > middle) {
       result = middle;
     }
     right = middle - 1;
    }
 }
 return result;
}

function setOffet() {

  if(startIndex.value >= 1) {
    const size = positions.value[startIndex.value - 1].bottom - positions.value[startIndex.value - aboveCount.value].top;
    offsetY.value = positions.value[startIndex.value].top - size;
  } else {
    offsetY.value = 0;
  }
}

let isMeasuring = false;
const bottomThreshold = 50; // 触底阈值
const loading = ref(false)
function handleScroll(e: any) {
  const scrollTop = e.detail.scrollTop;
  const newStart = getStartIndex(scrollTop) as number;

  if (startIndex.value !== newStart) {
    startIndex.value = newStart;
    endIndex.value = startIndex.value + visibleCount.value;
    setOffet();
  }

  if (!isMeasuring) {
    isMeasuring = true;
    nextTick(() => {
      updatePositions();

      setTimeout(() => {
        isMeasuring = false;
      }, 100);
    });
  }
  if(totalHeight.value - (scrollTop + screenHeight.value) <= bottomThreshold && !loading.value && virtualList.value.length) {
    loading.value = true;
    emits('loadMore', () => {
      loading.value = false;
    })
  }
}

function updatePositions() {
  uni
      .createSelectorQuery()
      .selectAll(".virtual-item")
      .boundingClientRect((res: any) => {

        if (!res || res.length === 0) return;

        let hasPositionChanged = false;


        for (let i = 0; i < res.length; i++) {
          const item = res[i];
          const index = Number(item.id);

          if (!positions.value[index]) continue;

          const oldHeight = positions.value[index].height;
          const newHeight = item.height;

          const diff = newHeight - oldHeight;
          if (Math.abs(diff) > 1) {
            positions.value[index].height = newHeight;
            hasPositionChanged = true;
          }
        }

        if (hasPositionChanged) {
          let currentTop = 0;
          for (let j = 0; j < positions.value.length; j++) {
            const item = positions.value[j];
            item.top = currentTop;
            item.bottom = currentTop + item.height;
            currentTop = item.bottom;
          }
          setOffet();
        }
      })
      .exec();
}

watch(
    () => props.dataSource,
    (nVal) => {
      virtualList.value = formatVirtualList(nVal) as IVirtualItem[];
      setPositions(nVal);
    },
    {
      deep: true,
      immediate: true,
    }
);

onMounted(() => {
  uni
      .createSelectorQuery()
      .select('.virtual-container')
      .boundingClientRect((res:any) => {
        screenHeight.value = res.height;
        visibleCount.value = Math.ceil(screenHeight.value / props.itemHeight);
        endIndex.value = startIndex.value + visibleCount.value;
      })
      .exec();
});
</script>

<style scoped lang="less">
.virtual-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
}

.virtual-list {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  will-change: transform;
}

.loading-wrap {
  display: flex;
  justify-content: center;
  padding: 20rpx 0;
}
</style>
相关推荐
90后的晨仔3 小时前
Vue 插槽(Slots)全面解析与实战指南
前端·vue.js
golang学习记3 小时前
从0死磕全栈之Next.js API 路由实战:不用后端,前端也能写接口!
前端
Nathan202406163 小时前
Kotlin-Sealed与Open的使用
android·前端·面试
MQliferecord3 小时前
前端性能优化实践经验总结
前端
RoyLin3 小时前
SurrealDB - 统一数据基础设施
前端·后端·typescript
longlongago~~3 小时前
富文本编辑器Tinymce的使用、行内富文本编辑器工具栏自定义class、katex渲染数学公式
前端·javascript·vue.js
2501_915921433 小时前
前端用什么开发工具?常用前端开发工具推荐与不同阶段的选择指南
android·前端·ios·小程序·uni-app·iphone·webview
aixfe3 小时前
BiomeJS 2.0 忽略目录配置方法
前端
Mintopia3 小时前
Cesium-kit 又发新玩意儿了:CameraControl 相机控制组件全解析
前端·three.js·cesium