🪛 关于虚拟滚动

    391

0️⃣ Begin

秉承着用不到就不去了解的初衷(用不到也要看的 😡

于是还是碰上了

在编写一个图标选择组件,图标库使用的 Fontawesome

原本想使用遍历产生 dom ,不过图标太多了,依照此方案或许会有性能问题

然后恰巧最近在某个B站直播弹幕采集站上看到了其使用虚拟滚动实现的弹幕列表

(顺带一提,B站的直播间弹幕也有使用虚拟滚动,不过其实现的思路似乎并不一致

对于当下热门的前端框架,Vue或者React,实际上也都有现成的轮子可以使用

1️⃣ 所以,我选择手搓 🤭

(这个例子中,滚动会修 `translateY` 的值,这是为了让视口保持在同一位置,不过似乎直接使用 `sticky top-0` 就好了 🤔)

虚拟滚动本质上都是限制 `DOM元素` 的渲染数量,并依靠滚动来不断刷新可视区域的内容

我所写的Demo结构是这样的 👇

  • 绿色部分表示外层容器
  • 紫色部分为缓冲区,需要手动计算高度以撑开外层容器,使容器出现滚动条
  • 蓝色部分为视口,高度与外层容器一致,用于渲染图标(内容)

前置条件是容器以及内容的高度需要固定,比如我这边给了外层容器的高度为 ' 300px ' ,内容元素 ' 75px ',然后每次渲染 20 个图标

1️⃣ Source Code

<template>
  <div class="fixed flex justify-center items-center bg-black bg-opacity-10 top-0 bottom-0 left-0 right-0 z-[99]">
    <div class="w-[600px] bg-white rounded-md flex flex-col p-[20px] gap-[10px]">
      <div class="flex items-center">
        <span class="text-gray-600 text-[14px]">图标分类</span>
        <el-radio-group v-model="currentIndex" class="ml-4" @change="indexChange">
          <el-radio :label="index" v-for="(item, index) in icons" :key="index">{{
            item.prefix
          }}</el-radio>
        </el-radio-group>
      </div>
      <el-input v-model="iconName" placeholder="请输入"></el-input>
      <div class="overflow-y-auto border-1 border-solid border-gray-300 relative" :style="{ height: `${containerH}px` }"
        @scroll="scrollHanlde" id="icon-viewer">
        <div :style="{ height: `${virtualDomHeight}px`, paddingBottom: `${itemH}px` }">
          <div class="sticky top-0 left-0 right-0" :style="{ height: `${containerH}px` }">
            <div :class="[
              `grid grid-cols-5 h-full relative`
            ]" :style="{ top: `${topV}px` }">
              <div class="flex flex-col justify-center items-center gap-[10px]" :style="{ height: `${itemH}px` }"
                v-for="(item, index) in virtualList" :key="index">
                <font-awesome-icon :icon="[icons[iconIndex].prefix, item]" class="text-blue-400 text-18px" />
                <span class="text-[12px] max-w-[80px] overflow-hidden text-gray-500 whitespace-nowrap text-ellipsis">{{
                  item
                }}</span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, onBeforeMount } from "vue";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { fab } from "@fortawesome/free-brands-svg-icons";
import { far } from "@fortawesome/free-regular-svg-icons";
import type { IconPrefix } from "@fortawesome/fontawesome-common-types";

type Icon = {
  prefix?: IconPrefix;
  icons?: string[];
};

let iconName = ref<string>("");
// 容器高度
let containerH: number = 300,
  // 图标item高度
  itemH: number = 75,
  // 每行元素数量
  rowNum: number = 5;
let icons = ref<Icon[]>([]);
let currentIndex = ref<number>(0), iconIndex = ref<number>(0);
let virtualDomHeight = ref<number>(0);
let virtualList = ref<string[]>([]);
let containerDom: Element | null = null;
let topV = ref<number>(0)

onMounted(() => {
  containerDom = document.querySelector("#icon-viewer");
  showIconHandle(0);
});

const scrollHanlde = (e: any) => {
  showIconHandle(e.target.scrollTop)
}

let lastBegin = 0
const showIconHandle = (y: number) => {
  virtualDomHeight.value = (icons.value[iconIndex.value]?.icons?.length || 0) * 15;
  let start = Math.floor(y / itemH) * rowNum;
  let end = Math.floor((y + containerH) / itemH) * rowNum + 5;

  lastBegin = start
  virtualList.value = icons.value[iconIndex.value]?.icons?.map((item, index) => item).slice(start, end) || []

  if (lastBegin === start) {
    topV.value = -y % itemH
  } else {
    topV.value = 0
  }
};

const indexChange = (e: any) => {
  virtualList.value = []
  iconIndex.value = e;
  showIconHandle(0);
  containerDom?.scrollTo({
    top: 0,
  });
};

onBeforeMount(() => {
  const allIcons = {
    ...fas,
    ...fab,
    ...far,
  };
  Object.keys(allIcons).forEach((iconName) => {
    const icon = allIcons[iconName];
    let iconType: Icon = icons.value.filter((item) => item.prefix === icon.prefix)[0];
    if (!iconType) {
      iconType = {
        prefix: icon.prefix,
        icons: [],
      };
      iconType.icons?.push(icon.iconName);
      icons.value.push(iconType);
    } else {
      iconType.icons?.push(icon.iconName);
    }
  });
});
</script>

2️⃣ End

这里光依靠对外层滚动条的监听来渲染图标并没有滚动效果,所以加上了 `topV`,这样可以让它看起来更像是在滚动,于是我就有点诟病这个点 🤣

然后也和 ChatGPT 唠了唠,其给出的方案十分传统和简单

` 监听滚动条,触底时添加元素 `

这样可以避免一次性的渲染,但是也会随着滚动而导致元素越来越多

上方的图标选择器其实可以用这种方式来实现,因为其大多是在搜索后进行图标的选择,并不会频繁进行滚动

另外有想到几种其它的方案,不过暂时还没有实践,有机会再写捏 😴

消息盒子
# 您需要首次评论以获取消息 #
# 您需要首次评论以获取消息 #

只显示最新10条未读和已读信息