Vue 无限滚动组件:一种简单的方法实现

lxf2023-02-17 01:51:31

持续创作,加速成长!这是我参与「AdminJS日新计划 · 10 月更文挑战」的第12天,点击查看活动详情

1. 前言

对于列表类型的大量数据,前端展示往往采用 分页无限滚动方式来展示,对于用户来说,鼠标滚轮和触控屏使滚动行为要比点击更快更容易。 element-plus 组件库提供了简单的 vue 指令,就可以轻易的实现

Vue 无限滚动组件:一种简单的方法实现

但是 element-plus 只支持无限向下滚动,不支持无限向上滚动,同时也没缺少丰富的 钩子函数,我们无法在这个基础上更好地 利用和改造滚动过程,所以,我们可以自己封装一个更具有个性化的组件

2. 整体思路

  1. 首先,外部的盒子会 隐藏 内部的盒子 溢出的部分

  2. 内部盒子的视图展示由数据提供,每当触发向上刷新或者向下刷新的时候,及时更新数据,最好的方式是使用 数组 维护所有的数据源,触发刷新的时候,只需要 操作数组 就可以了

  3. 怎么界定 向上刷新:这个很简单,只要滚动的高度接近于 0,就视作向上刷新

  4. 怎么界定 向下刷新 呢?

Vue 无限滚动组件:一种简单的方法实现

  • scrollTop:滚动条滚动距离
  • scrollHeight:滚动条的实际高度
  • clientHeight:元素的高度

随着滚动条滚到底部,此时 scrollTop + clientHeight = scrollHeight,那么此时就可以判断到达底部了

开始

const app = createApp(App);

app.directive('infinite-scroll', {
  mounted(el, binding) {},
});

为什么使用自定义指令实现?

自定义指令 相对于 组件 来说能够更好地操作dom元素

  • el 可以访问被添加指令的元素
  • 通过 binding.value 可以访问调用指令时传递的参数
  <ul v-infinite-scroll="{}"> // 配置参数
    <li v-for="i in dataArr">{{ i }}, len: {{ dataArr.length }}</li>
  </ul>

那么我们整体结构和整体思路就有了,就不难写出如下代码

// 为 el 单独指定类型
type InfiniteScrollEl = HTMLElement & {
  // 给 el 添加一个存储变量的空间
  [__SCOPE__]: {
    onScroll: () => void;
  };
};

interface DirectiveOpt {
  itemHeight: number; /// 内部每一列数据的高度
  rate: number; // 每次更新刷新数据的频率
  load: (dir: 'down' | 'up') => void; // 维护数据源的函数
}

// 获取并初始化配置选项
const getOptions = (binding: DirectiveBinding<DirectiveOpt>) => {
  const itemHeight = binding.value.itemHeight || 60;
  const rate = binding.value.rate || 1;
  const load = binding.value.load || (() => {});

  return { itemHeight, rate, load };
};

app.directive('infinite-scroll', {
  async mounted(el: InfiniteScrollEl, binding: DirectiveBinding<DirectiveOpt>) {
    await nextTick(); // 确保父元素加载完毕

    const { rate, load } = getOptions(binding);

    const onScroll = () => {
      // 向下刷新
      if (el.scrollTop + el.clientHeight >= el.scrollHeight) {
        for (let i = 0; i < rate; i++) {
          load('down');
        }
      }

      // 向上刷新
      if (el.scrollTop <= 0) {
        for (let i = 0; i < rate; i++) {
          load('up');
        }
      }
    };

    el[__SCOPE__] = {
      onScroll,
    };

    el.addEventListener('scroll', onScroll);
  },

  unmounted(el) {
    const { onScroll } = el[__SCOPE__];
    el.removeEventListener('scroll', onScroll);
  },
});

在组件被完全渲染完毕的时候,立即给 el 添加滚动事件 的处理函数,同时将这个回调函数挂载在 el 上,组件被销毁的时候,删除滚动事件

调用:

<script lang="ts" setup>
import { reactive } from 'vue';

const dataArr = reactive([1, 2, 3, 4, 5]);
let i = 0;

const load = (dir: 'down' | 'up') => {
  if (dir === 'down') {
    dataArr.push(dataArr.length + 1);
  } else {
    dataArr.unshift(i--);
  }
};
</script>

<template>
  <ul v-infinite-scroll="{ load, itemHeight: 60 }">
    <li v-for="i in dataArr">{{ i }}, len: {{ dataArr.length }}</li>
  </ul>
</template>

<style scoped>
ul {
  padding: 0;
  margin: 0;
  width: 400px;
  height: 200px;
  overflow: auto;
}

li {
  box-sizing: border-box;
  list-style: none;
  margin-bottom: 10px;
  height: 50px;
  background-color: skyblue;
  text-align: center;
  line-height: 50px;
}
</style>

此时会带来诸多 bug

  • 第一向下滚动的时候:此时的 scrollTop === 0,会触发向上刷新
  • 当滚动条位于顶部的时候无法向上刷新,因为这个时候 scrollTop === 0,向上滚动滚轮无法触发滚动事件

这里的解决方案有很多种,我们采用一种比较简单易懂的方法:

组件加载完毕的时候,给列表顶部 预留一点位置,这样不会导致第一次向上滚动无法触发滚动事件,每次向上滚动的时候,列表顶部刷新多少数据,就让列表的 scrollTop 位于 新数据与旧数据的分界处

async mounted(el: InfiniteScrollEl, binding: DirectiveBinding<DirectiveOpt>) {
    // ...

    const { rate, load, itemHeight } = getOptions(binding);

    el.scrollTop = itemHeight; // 组件加载完毕,让列表视口顶部为第二项数据

    const onScroll = () => {
      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
        for (let i = 0; i < rate; i++) {
          load('down');
        }
        await nextTick(); // 等待新增的元素加载完毕
      }

      if (el.scrollTop <= 10) {
        for (let i = 0; i < rate; i++) {
          load('up');
        }
        await nextTick(); // 等待新增的元素加载完毕
        el.scrollTop = rate * itemHeight;
      }
    };

    // ...
  },

为了解决 滚动条位于顶部的时候无法向上刷新,在触发向上滚动的时候,立即改变列表 scrollTop 值,让列表视口顶部处于 刷新的数据的底部,这样就模拟了模拟了向上刷新的过程了

此外为了防止滚动过程的卡顿,我们让刷新条件多了 10px缓冲区域,注意如果 load 函数中具有 异步任务,一定不要设置缓冲区域,因为这样会频繁的触发 load 函数。

3. 钩子函数

3.1 获取偏移初始位置的像素值

很多小伙伴可能会问了?这个值不就是 scrollTop?真的是这样的吗

一旦触发向上更新,原先 scrollTop 记录的位置随着新数据的增加被 挤下来,那么新的 scrollTop 值代表的一定不是原先位置了

我们得从 getOptions 中多添加一个处理 获取偏移值的函数,这个函数在每次 onScroll 执行完毕的时候触发

async mounted(el: InfiniteScrollEl, binding: DirectiveBinding<DirectiveOpt>) {
    // ...

+   const { rate, load, itemHeight, scrolledCb } = getOptions(binding);

+   let topAddedPx = 0; // 顶部新增的高度
+   let offset = 0; // 偏移值

    const onScroll = async () => {
      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
        for (let i = 0; i < rate; i++) {
          load('down');
+         offset = el.scrollTop - itemHeight - topAddedPx;
        }
        await nextTick(); // 等待新增的元素加载完毕
      }
      if (el.scrollTop <= 10) {
        for (let i = 0; i < rate; i++) {
          load('up');
+         topAddedPx += itemHeight;
        }
        await nextTick(); // 等待新增的元素加载完毕
        el.scrollTop = rate * itemHeight;
      }
+     offset = el.scrollTop - itemHeight - topAddedPx;
+     scrolledCb(offset);
    };

    // ...
  },

首先我们声明了两个变量 offset 获取实时的偏移值,topAddedPx 记录向上刷新时候新增的高度

onScroll 结束的时候 offset 等于 此时的位置 减去 向上刷新时候新增的高度,但是别忘了还要减去 itemHeight,因为在组件初始化的时候,我们预留了一个 itemHeight 的高度

调用:

<script lang="ts" setup>
// ...
    
const scrolledCb = (offset: number) => {
  console.log(offset);
};
</script>

<template>
  <ul v-infinite-scroll="{ load, itemHeight: 60, scrolledCb }">
    <li v-for="i in dataArr">{{ i }}, len: {{ dataArr.length }}</li>
  </ul>
</template>

3.2 获取开始滚动和结束滚动的钩子函数

有时候,我们需要在 监听 开始滚动结束滚动 的特定时刻

首先,在 getOptions 中多添加一个处理 获取切换滚动状态时刻的函数

  async mounted(el: InfiniteScrollEl, binding: DirectiveBinding<DirectiveOpt>) {
   // ...

   const { rate, load, itemHeight, scrolledCb, scrollingCb } = getOptions(binding);

    // ...

    let isScrolling = ref<boolean>(false); // 记录是否触发滚动
    let isNotFirst = false; // 第一次滚动不触发
    let timer: NodeJS.Timeout | null = null;
    
    // 监听是否滚动,如果监听值改变,立即触发滚动时刻的回调函数
    watch(isScrolling, () => {
      scrollingCb(isScrolling.value);
    });

    const onScroll = async () => {
      if (isNotFirst) isScrolling.value = true;
      isNotFirst = true;

      timer && clearTimeout(timer);
      timer = setTimeout(() => {
        isScrolling.value = false;
      }, 200);

      // ...
    };

   // ...
  },
  • 滑动滚轮,此时立即触发滚动的回调函数,触发 scrollingCb,并开启一个定时器,如果在 200ms 内没有再次滚动就断定为停止滚动,再次触发 scrollingCb
  • 如果在滚动开始的 200ms 内再次滑动滚轮,清空定时器,重新计时

调用:

<script lang="ts" setup>
const scrollingCb = (isScrolling: boolean) => {
  console.log(isScrolling);
};
</script>

<template>
  <ul v-infinite-scroll="{ load, itemHeight: 60, scrollingCb }">
    <li v-for="i in dataArr">{{ i }}, len: {{ dataArr.length }}</li>
  </ul>
</template>

4. 完整代码

还可以设置 隐藏滚动条,让无限滚动的过程变得更加自然,只需要在使用 无限滚动指令 的元素上添加 element::-webkit-scrollbar { display: none; }

const __SCOPE__ = 'scope';

// 为 el 单独指定类型
type InfiniteScrollEl = HTMLElement & {
  // 给 el 添加一个存储变量的空间
  [__SCOPE__]: {
    onScroll: () => void;
  };
};

interface DirectiveOpt {
  itemHeight: number; /// 内部每一列数据的高度
  rate: number; // 每次更新刷新数据的频率
  load: (dir: 'down' | 'up') => void; // 维护数据源的函数
  scrolledCb: (offset: number) => void; // 获取偏移值
  scrollingCb: (isScrolling: boolean) => void; // 获取改变滚动状态的时刻
}

// 获取并初始化配置选项
const getOptions = (binding: DirectiveBinding<DirectiveOpt>) => {
  const itemHeight = binding.value.itemHeight || 60;
  const rate = binding.value.rate || 1;
  const load = binding.value.load || (() => {});
  const scrolledCb = binding.value.scrolledCb || (() => {});
  const scrollingCb = binding.value.scrollingCb || (() => {});

  return { itemHeight, rate, load, scrolledCb, scrollingCb };
};

app.directive('infinite-scroll', {
  async mounted(el: InfiniteScrollEl, binding: DirectiveBinding<DirectiveOpt>) {
    await nextTick();

    const { rate, load, itemHeight, scrolledCb, scrollingCb } = getOptions(binding);

    el.scrollTop = itemHeight;
    let topAddedPx = 0;
    let offset = 0;

    let isScrolling = ref<boolean>(false);
    let isNotFirst = false;
    let timer: NodeJS.Timeout | null = null;

    watch(isScrolling, () => {
      scrollingCb(isScrolling.value);
    });

    const onScroll = async () => {
      if (isNotFirst) isScrolling.value = true;
      isNotFirst = true;

      timer && clearTimeout(timer);
      timer = setTimeout(() => {
        isScrolling.value = false;
      }, 200);

      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
        for (let i = 0; i < rate; i++) {
          load('down');
          offset = el.scrollTop - itemHeight - topAddedPx;
        }
        await nextTick(); // 等待新增的元素加载完毕
      }
      if (el.scrollTop <= 10) {
        for (let i = 0; i < rate; i++) {
          load('up');
          topAddedPx += itemHeight;
        }
        await nextTick(); // 等待新增的元素加载完毕
        el.scrollTop = rate * itemHeight;
      }
      offset = el.scrollTop - itemHeight - topAddedPx;
      scrolledCb(offset);
    };

    el[__SCOPE__] = {
      onScroll,
    };

    el.addEventListener('scroll', onScroll);
  },

  unmounted(el) {
    const { onScroll } = el[__SCOPE__];
    el.removeEventListener('scroll', onScroll);
  },
});

5. 结语

如果觉得本文不错的话,可以给作者点一个小小的赞,你的鼓励将是我前进的动力
如果本文对你有帮助或者你有不同的意见,欢迎在评论区留下你的足迹