二十.vue3实现虚拟列表滚动

8,457 阅读9分钟

本文正在参加「金石计划」

前言

最近由于列表需要渲染一万+数据,导致渲染起来很卡,本来是准备直接加一个分页或者下拉滚动的,但是和产品batter了好久,始终不通过这个方案,所以就决定使用虚拟列表滚动进行渲染。关于这个虚拟滚动,我也是之前听说,但没有具体了解过,正好趁着这次机会好好体验下,希望该文章能对你有帮助。

虚拟滚动原理

了解虚拟滚动之前,需要先知道几个概念性的知识:可视区域列表区域非可视区域

  • 可视区域:当前固定高度所能看见的区域,决定列表每次展示的数量。
  • 列表区域:列表数据区域,决定总的高度。
  • 非可视区域:列表数据区域减去可视区域后不可见的区域。

虚拟滚动实现原理,其实就是根据当前可视区域的高度,去计算显示的数量,只渲染可视区域的数据,对不可视区域的数据不渲染,从而节省性能。每次滚动时,根据滚动的距离去计算当前可视区域应该展示的数据。

举个例子:可以想象一下这个场景,有一扇门,门上有一个小孔,你每次看到门后的东西是取决于这个孔所在的位置,例如孔在头部时,你可能只能看到天花板,当这个孔慢慢向下时,你可能就会依次看到窗户,床,地板。虚拟滚动就是每次只渲染你能看到的区域,你看不到的区域它就不渲染。

image.png

准备

在真正开始使用虚拟滚动之前,我们可以先了解一下el-scrollbar滚动条,这个滚动条操作其实和虚拟滚动差不多,都需要外部可视区域盒子固定高度overflow-y:auto,里面的列表区域盒子有自己的高度,el-scrollbar可以作为我们了解虚拟滚动的开胃菜。

el-scrollbar其实就是自己写了一个盒子,然后隐藏掉浏览器默认的滚动条,通过定位到固定位置。滚动页面的时候,计算当前滚动的距离和可视固定高度的比例,就等于虚拟滚动条占可视区域的百分比。

image.png

这里贴一下el-scrollbar的部分关键代码:


# 这里定义水平滚动条和垂直滚动条组件,传入移动的距离百分比和滚动条宽度(高度)。 

 const wrap = (
      <div
        ref="wrap"
        style={ style }
        onScroll={ this.handleScroll }
        class={ [this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
        { [view] }
      </div>
    );

if (!this.native) {
  nodes = ([
    wrap,
    <Bar
      move={ this.moveX }
      size={ this.sizeWidth }></Bar>,
    <Bar
      vertical
      move={ this.moveY }
      size={ this.sizeHeight }></Bar>
  ]);
} 
return h('div', { class: 'el-scrollbar' }, nodes);

这里的 handleScroll方法就是盒子滚动时,获取当前滚动的距离和可视固定高度的比例,然后更新Bar组件的位置。

handleScroll() {
  const wrap = this.wrap;

  this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
  this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
}

这种是鼠标滚轮的情况下,如果是按住滚动条滚动的话,就需要另外的三个方法。

没错,又是咱哥三,mousemove,mousedown,mouseup。

原理和拖拽一样,鼠标按下时计算当前位置,然后move的时候通过当前位置减去初始位置,计算移动的距离,然后再计算scrollTop的位置。

startDrag(e) {
  e.stopImmediatePropagation();
  this.cursorDown = true;

  on(document, 'mousemove', this.mouseMoveDocumentHandler);
  on(document, 'mouseup', this.mouseUpDocumentHandler);
  document.onselectstart = () => false;
},
    
clickTrackHandler(e) {
  const offset = Math.abs(e.target.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]);
  const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
  const thumbPositionPercentage = ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);

  this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

mouseMoveDocumentHandler(e) {
    if (this.cursorDown === false) return;
    const prevPage = this[this.bar.axis];
    if (!prevPage) return;

    const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * -1);
    const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
    const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / this.$el[this.bar.offset]);

    this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
 },

实现一个简单的虚拟滚动

通过上面的案例,应该已经对虚拟滚动的流程有了一个大概的了解,接下来咱们来实现一个简单的虚拟滚动。

假设:固定可视区域高度为500px,列表每一项都等高50px,有一万 条数据,列表总高度为500000px,列表可视区域显示数量为:500/50=10,所以列表初始显示值为下标 0~10

先定义template结构,和el-scrollbar一样,虚拟滚动需要外面可视区域盒子固定,并设置overflow-y:auto,里面列表区域盒子根据高度和数量生成。

所以大概template代码如下:

 <div ref="wrapperRef" style="height:500px"  @scroll="onScroll">
        <div class="inner" ref="innerRef" style="height:500000px">
            <div class="list" ref="virtualListRef" :style="{ willChange: "transform",transform: `translateY(${state.scrollOffset}px)`}">
                <div v-for="(item, index) in clientData" :key="index + state.start">
                    {{item}}
                </div>
            </div>
        </div>
  </div>

定义初始方法:

const state = reactive<any>({
    start: 0,
    end: 10,
    scrollOffset: 0,
});

//当前可视的数据。
const clientData = computed(() => {
    return props.data.slice(state.start, state.end);
});

接下来主要处理scroll事件,根据当前滚动的scrollTop去计算当前的startIndexendIndex,注意,当页面滚动后,有一个偏移量,因为列表已经向下了,所以数据区域位置也需要同步向下,才能和数据保持一致。

const onScroll = (e: any) => {
    const { scrollTop } = e.target;
    if (state.scrollOffset === scrollTop) return;

    const startIndex = Math.floor(scrollTop / 50);

    const endIndex = startIndex+10;

    // 偏移量
    const offset = scrollTop - (scrollTop % 50);

    Object.assign(state, {
        start: startIndex,
        end: endIndex,
        scrollOffset: offset
    });
};

实现效果:

5.gif

进一步优化

虽然上面实现了简单的虚拟滚动,但是如果列表滚动过快,数据还没渲染,可能会导致空白的情况,效果不太好,关于这点,我们可以和无缝滚动一样,在当前可视区域前后各插入一屏,这样即可优化滚动效果。

定义字段cacheCount,假设为5,对上面onScroll进行小改造:

const onScroll = (e: any) => {
    const { scrollTop } = e.target;
    if (state.scrollOffset === scrollTop) return;

    const startIndex = Math.floor(scrollTop / 50);

    const endIndex = startIndex + 10 + cacheCount)
    
     if (startIndex > cacheCount) {
        startIndex = startIndex - cacheCount;
    }

    // 偏移量
    const offset = scrollTop - (scrollTop % 50);

    Object.assign(state, {
        start: startIndex,
        end: endIndex,
        scrollOffset: offset
    });
};

复杂的虚拟滚动

上面我们已经实现了一个简单的虚拟滚动,看上去是不是很简单?但是实际场景中可能列表项不是等高的,那这样处理就比等高的复杂了。

主要实现思路是:定义预估高度字段:itemHeight,假设预估列表高度为50px,这里我们要用到onUpdate钩子,当页面渲染完成后,获取当前列表的每一项的高度,然后存储在cacheData里面,滚动的时候通过cacheData快速找到当前的startIndex

假设:固定可视区域高度为500px,列表预估高度为50px,有一万 条数据,列表预估总高度为500000px,列表可视区域显示数量为:500/50=10,所以列表初始显示值为下标 0~10

每次可视区域值变化的时候,初始化一下cacheData的值,将当前index的预估高度,topbottom存储起来,方便后续读取。

watchEffect(() => {
    clientData.value.forEach((_, index) => {
        const currentIndex = state.start + index;
        if (Object.hasOwn(state.cacheData, currentIndex)) return;
        state.cacheData[currentIndex] = {
            top: currentIndex * 50,
            height: 50,
            bottom: (currentIndex + 1) * 50,
            index: currentIndex
        };
    });
});

然后onUpdate的时候,更新index对应的实际高度,和位置值:

onUpdated(() => {
    const childrenList = virtualListRef.value.children || [];
    [...childrenList].forEach((node: any, index: number) => {
        const height = node.getBoundingClientRect().height;
        const currentIndex = state.start + index;
        if (state.cacheData[currentIndex].height === height) return;

        state.cacheData[currentIndex].height = height;
        state.cacheData[currentIndex].top = getCurrentTop(currentIndex);
        state.cacheData[currentIndex].bottom = state.cacheData[currentIndex].top + state.cacheData[currentIndex].height;
    });
});

改动onScroll方法:

const onScroll = (e: any) => {
    const { scrollTop } = e.target;
    if (state.scrollOffset === scrollTop) return;

    let startIndex = getStartIndex(scrollTop)

    const endIndex = startIndex + 10 + cacheCount)

     if (startIndex > cacheCount) {
        startIndex = startIndex - cacheCount;
    }

    // 偏移量
    const offset = getCurrentTop(startIndex);

    Object.assign(state, {
        start: startIndex,
        end: endIndex,
        scrollOffset: offset
    });
};

这里添加了两个核心方法:getStartIndexgetCurrentTop,第一个方法是通过scrollTop我们上面生成的cacheData去获取index,另一个是通过index去获取scrollTop

// 二分法去查找对应的index
const getStartIndex = (scrollTop = 0): number => {
    let low = 0;
    let high = state.cacheData.length - 1;
    while (low <= high) {
        const middle = low + Math.floor((high - low) / 2);
        const middleTopValue = getCurrentTop(middle);
        const middleBottomValue = getCurrentTop(middle + 1);

        if (middleTopValue <= scrollTop && scrollTop <= middleBottomValue) {
            return middle;
        } else if (middleBottomValue < scrollTop) {
            low = middle + 1;
        } else if (middleBottomValue > scrollTop) {
            high = middle - 1;
        }
    }
    return Math.min(10000 - 10, Math.floor(scrollTop / 50));
};

获取当前top的值,这里有一个小逻辑,如果cacheData里面存在当前index,则直接返回,如果当前的index,cacheData里面没有,则= cacheData最后一位的高度+(index-cacheData.length) × 预估高度。

const getCurrentTop = (index: number) => {
    const lastIndex = state.cacheData.length - 1;
    
    if (Object.hasOwn(state.cacheData, index)) {
        return state.cacheData[index].top;
    } else if (Object.hasOwn(state.cacheData, index - 1)) {
        return state.cacheData[index - 1].bottom;
    } else if (index > lastIndex) {
        return state.cacheData[lastIndex].bottom + Math.max(0, index - state.cacheData[lastIndex].index) * props.itemHeight;
    } else {
        return index * props.itemHeight;
    }
};

列表区域总高度也需要根据实际高度变化:

const getTotalHeight = computed(() => {
    return getCurrentTop(props.data.length));
});

实现效果:

5.gif

IntersectionObserver方法

前面我们使用的传统方法,根据scrollTop去计算可视区域的元素,但是滚动后,scrollTop会触发多次,可能会造成性能上的浪费。所以我们还可以通过 intersectionObserver去实现虚拟滚动。

基础用法

IntersectionObserver 又称交叉观察器,主要作用是用来观察元素是否出现在可视窗口上。

主要用法:

const io = new IntersectionObserver(callback, options) 

io.observe(DOM)

参数

  • callback:回调函数,当观察的显示或消失,会触发该回调方法,我们可以用该方法来进行判断元素是否显示或隐藏。该回调函数接收一个数组参数 entries,我们主要使用其中的 isIntersecting参数,通过该参数可以判断该观察的元素是否出现在可视区域,其他参数有兴趣可以了解下。

image.png

  • options:配置可选项,root(监听对象的root元素),rootMargin(到root的偏移量) 和 threshold(阈值,监听对象交叉比例超过阈值后会触发callback)。
  • io.observe(DOM):开始观察一个元素。
  • io.unobserve(DOM):停止观察对应元素。
  • io.takeRecords 返回当前观察的元素数组。
  • io.disconnect: 停止观察所有元素。

小案例

通过这个方法,可以用来做图片懒加载的小案例和滚动加载更多小案例。

图片懒加载

const imgList=this.$ref.imgRef;

const io = new IntersectionObserver((entries) =>{ 
  entries.forEach(item => { 
      if (item.isIntersecting) { 
         item.target.src = item.target.dataset.src
         io.unobserve(item.target) 
      } 
  }) 
})

//开始监听
imgList.forEach(img => io.observe(img))

滚动加载更多

<template>
    <div v-for="item in list" :key="item.id">
        {{item}}
    <div>
    <div ref="loadMoreRef">正在加载更多...</div>
</template>

<script setup>
const loadMoreRef=ref(null)



onMounted(()=>{
    const io = new IntersectionObserver((entries) =>{ 
          if (entries[0].isIntersecting) { 
             pageNum++;
             loadMore()
          } 
    })
    io.observe(unref(loadMoreRef))
})
</script>

实现虚拟滚动

先从简单的等高列表滚动开始,假设条件和上面一致,同样是固定高度50px,可视区域10条数据。

实现思路: 需要默认加载20个元素,因为如果只有10个,初始化就触发了底部的观察,给第一个元素和最后一个元素打一个标记,然后监听观察这两个元素,分别对应向下和向上滚动,然后如果底部元素出现了,那就说明是向下滚动,如果头部元素出现了,那就说明是向上滚动。

 <div ref="wrapperRef" style="height:500px"  @scroll="onScroll">
        <div class="inner" ref="innerRef" style="height:500000px">
            <div class="list" ref="virtualListRef" >
                <div v-for="(item, index) in clientData" :key="index + state.start" :id="index === clientData.length - 1 ? '_bottom' : index === 0 ? '_top' : ''">
                    {{item}}
                </div>
            </div>
        </div>
  </div>

定义初始方法:

const state = reactive<any>({
    start: 0,
    end: 20,
    scrollOffset: 0,
});

const observerInstance = ref();

//当前可视的数据。
const clientData = computed(() => {
    return props.data.slice(state.start, state.end);
});

//主要通过监听end的变化,去更换当前观察的元素。
watch(
    () => state.end,
    () => {
        clearObserver();
        initObserver();
    },
    { immediate: true }
);

主要核心方式是clearObserver 和 initObserver。

const clearObserver = () => {
    //停止观察。
    nextTick(() => {
        unref(observerInstance)?.unobserve(document.getElementById("_top"));
        unref(observerInstance)?.unobserve(document.getElementById("_bottom"));
    });
};
const initObserver = () => {
    //开始观察
    observerInstance.value = new IntersectionObserver(observerCallback, { threshold: 0.1 });

    nextTick(() => {
        unref(observerInstance).observe(document.getElementById("_top"));
        unref(observerInstance).observe(document.getElementById("_bottom"));
    });
};

观察处理函数observerCallback:

const observerCallback = (entries: any[]) => {
    entries.forEach((entry: any) => {
        if (entry.isIntersecting && entry.target.id === "_bottom") {
            //向下滚动
            state.end = state.end + 10
            //设置两倍,类似于无缝滚动。
            state.start = state.end - 20
            state.scrollOffset = state.start * 50
        }

        if (entry.isIntersecting && entry.target.id === "_top") {
           //向上滚动
            state.end = state.end === 20 ? 20 : state.end - 10 > 20 ? state.end - 10 : 20;
            state.start = state.start === 0 ? 0 : state.start - 10 > 0 ? state.start - 10 : 0;
        }
        state.scrollOffset = state.start * 50
    });
};

实现效果:

5.gif

动态高度(新)

上面动态高度我们是用 onUpdate 钩子去实现高度更新的,但其实还可以使用 ResizeObserverapi去实现这个功能,ResizeObserver的作用就是目标元素大小的变化,可以用来取代resize方法。

有兴趣可以去了解下该api,这里就不做过多描述了,在虚拟滚动中,也可以通过该api去更新高度,可以自己动手实战下,这里就不做过多讲解了。

最后

到这里虚拟列表滚动就完成了,关于虚拟滚动我封装成了一个组件,代码放在vue3-baisc-admin里面了,有兴趣可以前去下载了解下,如果对你有帮助,可以点个赞,有问题可以评论区留言。

其他

vue3-baisc-admin 是一款开源开箱即用的中后台管理系统。基于 Vue3、Vite、Element-Plus、TypeScript、Pinia 等主流技术开发,内置许多开箱即用的组件,能快速构建中后台管理系统,目前决定完全开源。

地址:vue3-basic-admin

其他文章