分步实现带缓冲区的等高子元素的虚拟列表(vue & react)

效果展示

image

实现思路

  1. 撑开视口元素,出现滚动条

    根据单个元素高度itemHeight与元素总数allData.length,计算出总高度。并给一个元素设置上,用来撑出滚动条。

image

<script setup>
import { ref } from "vue";

const windowRef = ref(null);
    
const itemHeight = 100;
    
const allData = ref(Array.from({ length: 10000 }, (_, i) => i));
</script>

<template>
  <div class="window" ref="windowRef">
    <div :style="{ height: itemHeight * allData.length + 'px' }" />
  </div>
</template>

<style scoped>
.window {
  height: 550px;
  width: 300px;
  border: 1px solid red;
  overflow: auto;
}
</style>
  1. 创建可视元素

    • 组件挂载之后根据可视区(window元素)高度以及可视元素(listItem元素)高度计算可视元素个数showingNum

    • 根据可视元素个数showingNum,从总数据allData中截取出可是元素集合showingList

    image

    <script setup>
    import { ref, onMounted } from "vue";
    
    const windowRef = ref(null);
    
    const itemHeight = 100;
    
    const allData = ref(Array.from({ length: 10000 }, (_, i) => i));
    const showingList = ref([]); // 可视元素集合
    const showingNum = ref(0); // 可视元素个数
    const top = ref(0); // 可视元素父元素偏移量(此步骤一直为0)
    
    // 挂载之后,计算可视元素应该有几个
    onMounted(() => {
      showingNum.value =
        Math.ceil(
          parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight
        ) + 1;
      showingList.value = allData.value.slice(0, showingNum.value);
    });
    </script>
    
    <template>
      <div class="window" ref="windowRef">
        <div :style="{ height: itemHeight * allData.length + 'px' }" />
        <!-- 新增可视节点及其父元素showingListWrapper -->
        <div class="showingListWrapper" :style="{ top: top + 'px' }">
          <div
            class="listItem"
            :style="{
              height: itemHeight + 'px',
            }"
            v-for="num in showingList"
            :key="num"
          >
            {{ num }}
          </div>
        </div>
      </div>
    </template>
    
    <style scoped>
    .window {
      height: 550px;
      width: 300px;
      border: 1px solid red;
      overflow: auto;
      /* 设置为相对定位 */
      position: relative;
    }
    .showingListWrapper {
      /* 设置为绝对定位 */
      position: absolute;
      left: 0;
    }
    .listItem {
      width: 200px;
      border: 2px solid #dde5ff;
      box-sizing: border-box;
      background-color: #1772f6;
      color: #fff;
    }
    </style>
    
  2. 更新可视元素top偏移&可视元素内容

    这一步做完,虚拟滚动就已经实现了,关键在于top偏移量的计算。

    如果直接将e.target.scrollTop的值赋给偏移量top.value,会不像正常滚动,因为元素一直处于window顶端:

    image

    设置top.value = startIndex * itemHeight;:

    image

    <script setup>
    import { ref, onMounted } from "vue";
    
    const windowRef = ref(null);
    
    const itemHeight = 100;
    
    const allData = ref(Array.from({ length: 100 }, (_, i) => i));
    const showingList = ref([]);
    const showingNum = ref(0);
    const top = ref(0);
    
    onMounted(() => {
      showingNum.value =
        Math.ceil(
          parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight
        ) + 1;
      showingList.value = allData.value.slice(0, showingNum.value);
      // 添加滚动条滚动的事件监听
      windowRef.value.addEventListener("scroll", handleScroll);
    });
    
    // 计算可视元素
    function handleScroll(e) {
      const startIndex = Math.floor(e.target.scrollTop / itemHeight);
      const endIndex = startIndex + showingNum.value;
    
      // 更新可视元素
      showingList.value = allData.value.slice(startIndex, endIndex);
      // 计算top偏移量
      top.value = startIndex * itemHeight;
    }
    </script>
    
    <template>
      <div class="window" ref="windowRef">
        <div :style="{ height: itemHeight * allData.length + 'px' }" />
        <div class="showingListWrapper" :style="{ top: top + 'px' }">
          <div
            class="listItem"
            :style="{
              height: itemHeight + 'px',
            }"
            v-for="num in showingList"
            :key="num"
          >
            {{ num }}
          </div>
        </div>
      </div>
    </template>
    
    <style scoped>
    .window {
      height: 550px;
      width: 300px;
      border: 1px solid red;
      overflow: auto;
      position: relative;
    }
    .showingListWrapper {
      position: absolute;
      left: 0;
    }
    .listItem {
      width: 200px;
      border: 2px solid #dde5ff;
      box-sizing: border-box;
      background-color: #1772f6;
      color: #fff;
    }
    </style>
    
  3. 添加缓冲元素

    通过修改startIndexendIndex的计算方式,可以添加缓冲元素,以防止快速拖动的时候出现的前后空白。

    可视区域:
    image

    添加缓冲元素前:
    image

    添加缓冲元素之后:
    image

    <script setup>
    import { ref, onMounted } from "vue";
    
    const windowRef = ref(null);
    
    const itemHeight = 100;
    const bufferNum = 5; // 缓冲数量
    
    const allData = ref(Array.from({ length: 10000 }, (_, i) => i));
    const showingList = ref([]);
    const showingNum = ref(0);
    const top = ref(0);
    
    onMounted(() => {
      showingNum.value =
        Math.ceil(
          parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight
        ) + 1;
      showingList.value = allData.value.slice(0, showingNum.value);
      windowRef.value.addEventListener("scroll", handleScroll);
    });
    
    function handleScroll(e) {
      // 防止startIndex小于0
      const startIndex = Math.max(Math.floor(e.target.scrollTop / itemHeight) - bufferNum, 0);
      // 防止endIndex大于allData.value.length
      const endIndex = Math.min(startIndex + showingNum.value + bufferNum * 2, allData.value.length);
    
      showingList.value = allData.value.slice(startIndex, endIndex);
      top.value = startIndex * itemHeight;
    }
    </script>
    
    <template>
      <div class="window" ref="windowRef">
        <div :style="{ height: itemHeight * allData.length + 'px' }" />
        <div class="showingListWrapper" :style="{ top: top + 'px' }">
          <div
            class="listItem"
            :style="{
              height: itemHeight + 'px',
            }"
            v-for="num in showingList"
            :key="num"
          >
            {{ num }}
          </div>
        </div>
      </div>
    </template>
    
    <style scoped>
    .window {
      height: 550px;
      width: 300px;
      border: 1px solid red;
      overflow: auto;
      position: relative;
    }
    .showingListWrapper {
      position: absolute;
      left: 0;
    }
    .listItem {
      width: 200px;
      border: 2px solid #dde5ff;
      box-sizing: border-box;
      background-color: #1772f6;
      color: #fff;
    }
    </style>
    

完整代码

vue

<script setup>
import { ref, onMounted } from "vue";

const windowRef = ref(null);

const itemHeight = 100;
const bufferNum = 5;

const allData = ref(Array.from({ length: 10000 }, (_, i) => i));
const showingList = ref([]);
const showingNum = ref(0);
const top = ref(0);

onMounted(() => {
  showingNum.value =
    Math.ceil(
      parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight
    ) + 1;
  showingList.value = allData.value.slice(0, showingNum.value);
  windowRef.value.addEventListener("scroll", handleScroll);
});

function handleScroll(e) {
  const startIndex = Math.max(Math.floor(e.target.scrollTop / itemHeight) - bufferNum, 0);
  const endIndex = Math.min(startIndex + showingNum.value + bufferNum * 2, allData.value.length);

  showingList.value = allData.value.slice(startIndex, endIndex);
  top.value = startIndex * itemHeight;
}
</script>

<template>
  <div class="window" ref="windowRef">
    <div :style="{ height: itemHeight * allData.length + 'px' }" />
    <div class="showingListWrapper" :style="{ top: top + 'px' }">
      <div
        class="listItem"
        :style="{
          height: itemHeight + 'px',
        }"
        v-for="num in showingList"
        :key="num"
      >
        {{ num }}
      </div>
    </div>
  </div>
</template>

<style scoped>
.window {
  height: 550px;
  width: 300px;
  border: 1px solid red;
  overflow: auto;
  position: relative;
}
.showingListWrapper {
  position: absolute;
  left: 0;
}
.listItem {
  width: 200px;
  border: 2px solid #dde5ff;
  box-sizing: border-box;
  background-color: #1772f6;
  color: #fff;
}
</style>

react

// jsx文件:
import styles from "./virtualScrolling.module.css";
import { useState, useRef, useEffect } from "react";

function App() {
  const windowRef = useRef(null);
  const showingListWrapperRef = useRef(null);

  const itemHeight = 100;
  const bufferNum = 5;

  const [allData, setAllData] = useState(Array.from({ length: 10000 }, (_, i) => i));
  const [showingList, setShowingList] = useState([]);
  const showingNumRef = useRef(0);

  useEffect(() => {
    const showingNum =
      Math.ceil(
        parseFloat(window.getComputedStyle(windowRef.current).getPropertyValue("height")) /
          itemHeight
      ) + 1;
    setShowingList(allData.slice(0, showingNum));
    console.log(allData.slice(0, showingNum));
    showingNumRef.current = showingNum;
    windowRef.current.addEventListener("scroll", handleScroll);
  }, []);

  useEffect(() => {}, []);

  function handleScroll(e) {
    const startIndex = Math.max(0, Math.floor(e.target.scrollTop / itemHeight) - bufferNum);
    const endIndex = Math.min(startIndex + showingNumRef.current + bufferNum * 2, allData.length);
    showingListWrapperRef.current.style.top = startIndex * itemHeight + "px";
    setShowingList(allData.slice(startIndex, endIndex));
  }

  return (
    <div className={styles.window} ref={windowRef}>
      <div style={{ height: itemHeight * allData.length }} />
      <div ref={showingListWrapperRef} className={styles.showingListWrapper}>
        {showingList.map((num) => (
          <div
            className={styles.listItem}
            style={{
              height: itemHeight,
            }}
            key={num}
          >
            {num}
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;
/* css文件:*/
.window {
  height: 550px;
  width: 300px;
  border: 1px solid red;
  overflow: auto;
  position: relative;
}
.showingListWrapper {
  position: absolute;
  left: 0;
  top: 0;
}
.listItem {
  width: 200px;
  border: 2px solid #dde5ff;
  box-sizing: border-box;
  background-color: #1772f6;
  color: #fff;
}

react注意点:

  • 由于showingNum在组件挂载之后会更新,并且scroll的事件回调中handleScroll使用了showingNum,此时需要使用useRef钩子包装一层,变成上述代码中的showingNumRef

    如果使用[showingNum, setShowingNum] = useState(0),会由于闭包的存在,导致handleScroll回调中的showingNum值一直是初始值0,导致刚进入页面的时候看不到列表内容。

  • 由于react的渲染机制,我们设置showingListWrapper的top偏移量的时候,需要使用给dom直接设置top的方式设置showingListWrapperRef.current.style.top = startIndex * itemHeight + "px";

    如果在jsx中使用top来控制top偏移量,会导致滚动时出现闪烁:
    image

    const [top, setTop] = useState(0);
    // ...
    function handleScroll(e) {
      // ...
      setTop(startIndex * itemHeight);
      // ...
    }
    
     return (
         // ...
          <div
            style={{
              top: top,
            }}
            className={styles.showingListWrapper}
          >
            // ...
          </div>
      );
    
posted @ 2024-03-19 15:54  Cat_Catcher  阅读(49)  评论(0编辑  收藏  举报
#