封装虚拟滚动组件,并使用Raf实现平滑滚动

38 阅读2分钟

HTML元素

<div class="fs-estimated-virtuallist-container">
  <div
   class="fs-estimated-virtuallist-content"
   ref="contentRef"
   @mouseleave="leave"
   @mouseenter="enter"
   @mouseover="over"
   @scroll="handleScroll"
  >
   <div class="fs-estimated-virtuallist-list" ref="listRef" :style="scrollStyle">
    <div class="fs-estimated-virtuallist-list-item" ref="itemRef" v-for="i in renderList" :key="i.project_id">
     <slot name="item" :item="i"></slot>
    </div>
   </div>
   <div class="fs-estimated-virtuallist-list" :style="scrollStyle2">
    <div class="fs-estimated-virtuallist-list-item" v-for="i in virList" :key="i.project_id">
     <slot name="item" :item="i"></slot>
    </div>
   </div>
  </div>
</div>

监听容器高度变化

1.使用ResizeObserver监听容器高度变化

const observeContent = () => {
	if (contentRef.value) {
		const observer = new ResizeObserver(debounce(handleSetPosition, 300));
		observer.observe(contentRef.value);
	}
};

2.动态计算尺寸 maxCount和itemHeight的高度

  if (itemRef.value) {
	// 拿数组第一项计算最大  也就是计算item的高度可以放下多少个item
	state.maxCount = Math.ceil(state.viewHeight / itemRef.value[0].offsetHeight) + 1;
	state.itemHeight = itemRef.value[0].offsetHeight;
}

4.虚拟滚动(核心)
startIndex默认为0

virList用于无缝滚动

// dada 的长度和 maxCount 最大长度 取最小的
const endIndex = computed(() => Math.min(propList.value.length, state.startIndex + state.maxCount));
// 截取的长度是 当前滚动到的索引到当前容器可视区域的最大索引
const renderList = computed(() => propList.value.slice(state.startIndex, endIndex.value));

const virList = computed(() => propList.value.slice(0, state.maxCount)); // 复制多几个用来无缝滚动的

当页面滚动时 重新计算startIndex值 state.startIndex = Math.floor(scrollTop / state.itemHeight);
当页面滚动到底部时contentRef.value!.scrollTop = 0;

const handleScroll = rafThrottle(() => {
  let { scrollTop } = contentRef.value!;
  // 计算启示索引  容器滚动的高度/单个数据的高度
  // 获取到当前滚动到的索引
  state.startIndex = Math.floor(scrollTop / state.itemHeight);
  if (renderList.value.length === 0) {
   contentRef.value!.scrollTop = 0;
   !props.loading && emit('scroll-end');
  }
});

5.无缝滚动 在move()函数中 通过不断修改scrollTop触发滚动

image.png

<hr>
// 复制多几个用来无缝滚动的
const scrollStyle = computed(
  () =>
   ({
    height: `${state.itemHeight * renderList.value.length}px`,
    transform: `translate3d(0, ${state.itemHeight * state.startIndex}px, 0)`
   } as CSSProperties)
);
const scrollStyle2 = computed(
  () =>
   ({
    transform: `translate3d(0, ${state.itemHeight * state.startIndex}px, 0)`
   } as CSSProperties)
);

当 scrollTop 超过第一个列表的高度时,第二个列表会通过 translate3d 的位移“顶替”第一个列表的位置。由于两个列表的内容相同,用户会感觉内容无限循环。

6.丝滑滚动 使用requestAnimationFrame实现高性能滚动事件 代替setTimeout 他的执行次数和屏幕的刷新率相当 比如屏幕是75Hz就是1秒执行75次 原因:

  • 1、动画更丝滑,不会出现卡顿
  • 2、性能更好,切后台会暂停

确保只有在存在有效的动画帧请求 ID 时,才尝试取消它

终止APIwindow.cancelAnimationFrame()

//自动滚动
const move = () => {
	if (state.isHover) return;
	// 加载中就停止
	if (props.loading) {
		_cancel();
		return;
	}
	contentRef.value!.scrollTop += 1;
	state.rafTimer = requestAnimationFrame(move);
	// setTimeout(() => {
	// 	move();
	// }, 50);
};
const enter = () => {
  state.isHover = true; //关闭_move
  _cancel();
};
//取消滚动
const _cancel = () => {
	state.rafTimer !== null && window.cancelAnimationFrame(state.rafTimer);
};
const leave = () => {
  state.isHover = false; //开启_move
  move();
};
const over = () => {
  _cancel();
};

项目地址:http://1.94.195.239:8000/#/home

完整代码

<template>
  <div class="fs-estimated-virtuallist-container">
   <div
    class="fs-estimated-virtuallist-content"
    ref="contentRef"
    @mouseleave="leave"
    @mouseenter="enter"
    @mouseover="over"
    @scroll="handleScroll"
   >
    <div class="fs-estimated-virtuallist-list" ref="listRef" :style="scrollStyle">
     <div class="fs-estimated-virtuallist-list-item" ref="itemRef" v-for="i in renderList" :key="i.project_id">
      <slot name="item" :item="i"></slot>
     </div>
    </div>
    <div class="fs-estimated-virtuallist-list" :style="scrollStyle2">
     <div class="fs-estimated-virtuallist-list-item" v-for="i in virList" :key="i.project_id">
      <slot name="item" :item="i"></slot>
     </div>
    </div>
   </div>
  </div>
</template>

<script setup lang="ts">
import { type CSSProperties, computed, onMounted, reactive, ref, watch, PropType, nextTick } from 'vue';
import type { VirtualStateType } from './data';
import { delayRef } from '@/utils/base';
import { GitHubItem } from '@/pages/home/composables/use-github';
import { debounce } from 'lodash';
import { slice } from 'lodash-es';

const props = defineProps({
  loading: {
   type: Boolean,
   required: true
  },
  dataSource: {
   type: Array as PropType<GitHubItem[]>,
   required: true
  }
});
const emit = defineEmits(['scroll-end']);

const propList = ref<GitHubItem[]>([]);

const contentRef = ref<HTMLDivElement>();

const listRef = ref<HTMLDivElement>();
const itemRef = ref<HTMLDivElement[]>();

// const positions = ref<IPosInfo[]>([]);
/**
 * @param viewHeight  当前可视区域高度
 * @param itemHeight  每个item的高度
 * @param startIndex  当前可视区域的起始索引
 * @param maxCount  当前可视区域的最大索引
 * @params isHove
 * **/

const state = reactive<VirtualStateType>({
  viewHeight: 0,
  itemHeight: 0,
  startIndex: 0,
  maxCount: 1,
  preLen: 0,
  rafTimer: null,
  isHover: false
});

const endIndex = computed(() => Math.min(propList.value.length, state.startIndex + state.maxCount));

const renderList = computed(() => propList.value.slice(state.startIndex, endIndex.value));

const virList = computed(() => propList.value.slice(0, state.maxCount));

// 复制多几个用来无缝滚动的
const scrollStyle = computed(
  () =>
   ({
    height: `${state.itemHeight * renderList.value.length}px`,
    transform: `translate3d(0, ${state.itemHeight * state.startIndex}px, 0)`
   } as CSSProperties)
);
const scrollStyle2 = computed(
  () =>
   ({
    transform: `translate3d(0, ${state.itemHeight * state.startIndex}px, 0)`
   } as CSSProperties)
);
/**
 * @description 播放滚动  虚拟滚动
 */
const move = () => {
  if (state.isHover) return;
  // 加载中就停止
  if (props.loading) {
   _cancel();
   return;
  }
  contentRef.value!.scrollTop += 1;
  state.rafTimer = requestAnimationFrame(move);
  // setTimeout(() => {
  //   move();
  // }, 50);`
};

const _cancel = () => {
  console.log(state.rafTimer, 'rafTimer');
  state.rafTimer !== null && window.cancelAnimationFrame(state.rafTimer);
};

const enter = () => {
  state.isHover = true; //关闭_move
  _cancel();
};

const leave = () => {
  state.isHover = false; //开启_move
  move();
};

const over = () => {
  _cancel();
};

// 节流
function rafThrottle(fn: Function) {
  let lock = false;
  return function (this: any, ...args: any[]) {
   if (lock) return;
   lock = true;
   window.requestAnimationFrame(() => {
    fn.apply(this, args);
    lock = false;
   });
  };
}

const handleScroll = rafThrottle(() => {
  let { scrollTop } = contentRef.value!;
  // 计算启示索引  容器滚动的高度/单个数据的高度
  // 获取到当前滚动到的索引
  state.startIndex = Math.floor(scrollTop / state.itemHeight);
  console.log(state.startIndex, 'startIndex');
  if (renderList.value.length === 0) {
   contentRef.value!.scrollTop = 0;
   !props.loading && emit('scroll-end');
  }
});

/**
 * @description 初始化
 */

const init = () => {
  // 初始化容器高度
  state.viewHeight = contentRef.value ? contentRef.value.offsetHeight : 0;
};

/**
 * @description 将新拿到的列表赋值给propList
 */
const addNewList = () => {
  propList.value = props.dataSource;
  console.log(propList.value, 'value');
};
/**
 * @description 重新计算最大数
 */
const handleSetPosition = () => {
  state.viewHeight = contentRef.value ? contentRef.value.offsetHeight : 0;
  nextTick(() => {
   if (itemRef.value) {
    // 拿数组第一项计算最大  也就是计算item的高度可以放下多少个item
    state.maxCount = Math.ceil(state.viewHeight / itemRef.value[0].offsetHeight) + 1;
    state.itemHeight = itemRef.value[0].offsetHeight;
   }
  });
};

/**
 * @description 监听容器宽高变化
 * */
const observeContent = () => {
  if (contentRef.value) {
   const observer = new ResizeObserver(debounce(handleSetPosition, 300));
   observer.observe(contentRef.value);
  }
};

onMounted(() => {
  //初始化做两件事情
  // 监听容器宽高变化
  // 1.计算最大数 maxCount
  // 2.计算item的高度 itemHeight
  // 滚动时 计算当前滚动到的索引 state.startIndex
  // 将新拿到的列表赋值给propList
  observeContent();
  init();
});

watch(
  () => props.dataSource.length,
  () => {
   // 接受到的列表高度变化
   addNewList();
   handleSetPosition();
  }
);

watch(
  () => props.loading,
  value => {
   // 加载完之后开始动画
   if (!value) {
    move();
   }
  }
);
</script>

<style scoped lang="scss">
// 隐藏滚动条
::-webkit-scrollbar {
  display: none;
  background-color: transparent;
}

.fs-estimated-virtuallist {
  &-container {
   position: relative;
   width: 100%;
   height: 100%;
  }

  &-content {
   position: absolute;
   width: 100%;
   height: 100%;
   overflow: auto;
  }

  &-list-item {
   box-sizing: border-box;
   width: 100%;
  }
}
</style>

欢迎大家积极讨论,可以加我微信,后期建群共同学习前端,也会发一下接单信息方便大家做一些兼职。
作者微信:xiaoyukejikun,后期建群共同学习与讨论。

77d44106111ba04a65aa98bdca737a2.jpg