今年vue3项目中两次用到虚拟列表

2,620 阅读5分钟

背景

今年两次用到虚拟列表技术,一种是二维平面因为实际现场要求支持1000 * 1000的百万节点展示,另一种是一维垂直列表最大每年有万条数据。

image.png

二维虚拟列表

如下图,实际场地上有横纵坐标都是1到1000,我们忽略每个坐标方格上样式和交互,显然直接渲染百万个小格子导致页面卡死,只有根据横纵滚动条的滚动动态渲染可视区内小格子。

特点:

  • 小格子长宽固定

f9160c33580551b4785c434d01a20f7.png

一维垂直虚拟列表

如下图,列表项内容高度不固定,每个红格子是一项。

image.png

三种技术特点

虚拟列表常见三种实现技术:

1、第一种最常见是只渲染可视化区域内容,结合滚动条的监听滚动事件动态计算要显示的节点,并计算各节点position:absolute绝对定位。缺点是复杂性很高(要实现滚动监听和节点定位),scroll 事件密集发生,计算量很大,容易造成性能问题。另外如果行高不固定(实际业务中往往需要这样), 那计算将会更加复杂。参考 juejin.cn/post/723285…

2、第二种是用 IntersectionObserver 监听DOM与文档视窗的交叉状态,缺点是事先需渲染全部元素才能监听每个元素是否进入可视区域中,就先渲染一个div占位,等到触发callback了再替换成实际的内容。监听的是占位DOM,渲染占位DOM和渲染实际内容相比开销小到忽略不计,但也需要渲染所有div数量。参考 juejin.cn/post/715860…

3、第三种是用css新属性 content-visibility,属性控制一个元素是否渲染其内容,它允许用户代理(浏览器)潜在地省略大量布局和渲染工作,直到需要它为止。缺点类似上面DIV占位,实际是不渲染子节点的内容,但是子节点框还是会渲染,所有子节点的DIV框框还是会实际生成参考 blog.csdn.net/qq_41581588…

上面三种方法各有优缺点,我还是倾向于第一种达到真正的只渲染小量DOM结构。

后面我们都说第一种要实现虚拟列表,我认为要解决这些问题:

1、滚动条长度计算:正常情况下由于内部高度大于父容器高度,所以才有滚动条出现。但是虚拟列表是高度实际上几乎等于父容器高度,所以滚动条长度要模拟出渲染所有节点撑开父容器的高度。

2、子节点内容高度不固定:如果是固定高度能容易计算出滚动条高度是所有子项数量 * 单个高度,子项绝对定位也方便;但是如果子项高度不固定,则需要动态根据实际渲染子项后再次动态计算。

实现思路

// 最外层容器
<div ref="layoutContainerRef" class="layout-container" @scroll="handleScroll">

    // 占位层
    <div class="layout-mark" :style="layoutMarkSize"/>
    
    // 布局层
    <div class="layout-room">
    
        // 渲染子项包裹组件,为了获取自定义组件的高度
        <visual-list-item>
        
            // 自定义内容组件
            <my-self-item  v-for="item in visualRecords" :item="item"/>
        
        </visual-list-item>
        
    </div>

</div>

.layout-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: auto;
}

.layout-mark {
  position: absolute;
  z-index: -1;
  top: 0;
  left: 0;
  right: 0;
}

.layout-room {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

第一步画外部容器宽高

这是最外层容器 设置宽高像素,设置可滚动,设置相对定位属性,后面都根据这一层来绝对定位

image.png

第二步计算占位层高度和绝对定位

占位层的作用是撑开外部容器,使得出现滚动条,难点是怎么计算占位层高度。

image.png

// 初始化复制所有子项高度
// 预设高度 100
// __height 每个子项高度
// __top 每个子项相对于占位层的top距离
// __bottom 每个子项相对于占位层的top距离
const allRecords = computed(() => {
    let top = 0;
    props.records.forEach((item) => {
      item.__height = item.__height || 100;
      item.__top = top;
      top = item.__top + item.__height;
      item.__bottom = top;
    });
    return props.records;
});
  
// 占位层宽高
const layoutContainerRef = ref();
const layoutMarkSize = computed(() => {
    const lastItem = allRecords.value.length > 0 ? allRecords.value[allRecords.value.length - 1] : null;
    // 最后一个子项底部距离即可
    return {
      height: (lastItem ? lastItem.__bottom : 0) + 'px',
    };
});
情况一:每个子项高度固定

如果是内部子项高度是固定的比如100px,那用 100 * 子项数量就可以设置占位层高度

情况二:子项高度不固定

子项高度不固定,则需要首先预设每项高度,再根据实际渲染子项之后重新计算占位层高度,这种情况是兼容情况一的

这里需要增加 渲染子项包裹组件 VisualListItem

  • VisualListItem 组件,给用户自定义组件获取到 __height 属性,
<template>
  <div ref="itemRef">
    <slot :item="item"></slot>
  </div>
</template>
<script setup>
  import { onMounted, ref } from 'vue';
  const props = defineProps({
    item: {
      type: Object,
      required: true,
    },
  });
  const itemRef = ref();
  onMounted(() => {
    props.item.__height = itemRef.value.clientHeight;
  });
</script>

这样经过 VisualListItem 的 onMounted 之后,实际高度就会生成,在外面会重新计算 layoutMarkSize 属性,就重新修改了占位层的高度

第三步监听外部层滚动事件

原理是通过监听滚动,计算出滚动top值,计算出应该显示哪些子项,将子项包裹层用transform偏移到可视化显示区,用translate3D是性能优化,交给GPU渲染

image.png

// 可视层位置
const layoutRoomPositionStyle = ref({
    minRow: 0,
    maxRow: 0,
    transform: `translate3D(0px,0px,0)`,
});

// 可见数据,后面在滚动事件中修改 minRow和maxRow值,就更新了要显示的数据项是哪些
const visualRecords = computed(() => allRecords.value.slice(layoutRoomPositionStyle.value.minRow, layoutRoomPositionStyle.value.maxRow + 1));

// 滚动事件
function handleScroll(scrollEvent) {
    const { scrollTop } = scrollEvent.target;
    // 计算显示隐藏的cell的横纵坐标
    const { minRow } = layoutRoomPositionStyle.value;
    // 格子尺寸
    const { height } = props;
    // 最小可视化行、列
    layoutRoomPositionStyle.value.minRow = allRecords.value.findIndex((item) => item.__top <= scrollTop && item.__bottom >= scrollTop);
    // 顶部多渲染4条
    layoutRoomPositionStyle.value.minRow = Math.max(0, layoutRoomPositionStyle.value.minRow - 4);
    // 最大可视化行、列
    const tempMaxRow = layoutRoomPositionStyle.value.minRow + Math.floor(scrollEvent.target.clientHeight / height);
    layoutRoomPositionStyle.value.maxRow = allRecords.value.findIndex(
      (item) => item.__top <= scrollTop + scrollEvent.target.clientHeight && item.__bottom >= scrollTop + scrollEvent.target.clientHeight
    );
    // 底部多渲染4条
    layoutRoomPositionStyle.value.maxRow = Math.max(tempMaxRow, layoutRoomPositionStyle.value.maxRow) + 4;
    if (allRecords.value.length > 0) {
      layoutRoomPositionStyle.value.maxRow = Math.min(allRecords.value.length, layoutRoomPositionStyle.value.maxRow);
    }
    // 可视化层偏移量
    if (minRow != layoutRoomPositionStyle.value.minRow) {
      layoutRoomPositionStyle.value.transform = `translate3D(0,${allRecords.value[layoutRoomPositionStyle.value.minRow].__top}px,0)`;
    }
}

到此就基本做完了,但是要优化一下,第一次进来页面的时候,没有计算可视化区域应该渲染多少条,所以第一次页面渲染的时候,要先执行以下 handleScroll 方法,在 onMounted里面加上

onMounted(() => {
    handleScroll({ target: layoutContainerRef.value });
});

一维垂直虚拟列表动态内容高度变化组件就支持好了,二维平面的看源码 VisualGridList 组件,基本类似

源码

gitee.com/rootegg/vis…