render 虚拟滚动组件

1,078 阅读1分钟
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { defineComponent, h, useSlots, ref, toRefs, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAnimationFrame } from './useAnimationFrame'
import { useElementSize } from './useElementSize'
/**
 * @description: 抽象虚拟滚动组件
 * @return {*}
 */
export interface IUseVirtualListProps {
  /**
   * @description: 传递进来的数据
   */
  list: Array<any>
  /**
   * @description: 数据的唯一 key
   */
  key: string
  /**
   * @description: 每一项的高度
   */
  itemHeight: number
  /**
   * @description: 可视区展示的数量
   */
  visualCount: number
  /**
   * @description: 当前位置
   */
  currentIndex?: number
  /**
   * @description: 容器层的高度
   */
  height?: number
}

export const UseVirtualList = defineComponent<IUseVirtualListProps>({
  name: 'UseVirtualList',
  props: [
    'list',
    'key',
    'itemHeight',
    'visualCount',
    'currentIndex',
    'height',
  ] as unknown as undefined,
  emits: ['toSwitch'],
  setup(props, { emit }) {
    const { requestAnimateFrame, cancelAnimateFrame } = useAnimationFrame()
    const slots = useSlots()
    const { list, itemHeight, visualCount } = toRefs(props)
    const root = ref<HTMLElement | null>(null)
    const scrollHeight = ref(list.value.length * itemHeight.value)
    const range: number[] = []
    const paddingTop = ref(0)
    const pool = ref([] as any[])
    const visualHight = ref(0)
    let cancelFrame: any
    let isScrollBusy = false
    watch(list, (cData) => {
      console.log('是否死循环')
      scrollHeight.value = cData.length * itemHeight.value
      pool.value = list.value
        .slice(range[0], range[1])
        .map((v, i) => ({ ...v, _index: (range[0] || 0) + i }))
    })

    const handleClick = (item: any, index: number) => {
      emit('toSwitch', item, index)
    }

    const handleScroll = () => {
      if (!root.value) return
      if (isScrollBusy) return
      isScrollBusy = true
      // 每次先清除动画
      cancelFrame && cancelAnimateFrame(cancelFrame)

      cancelFrame = requestAnimateFrame(() => {
        isScrollBusy = false
        if (!root.value) return
        range[0] =
          Math.floor(root.value.scrollTop / itemHeight.value) - Math.floor(visualCount.value / 2)
        range[0] = Math.max(range[0], 0)
        range[1] =
          range[0] + Math.floor(root.value.clientHeight / itemHeight.value) + visualCount.value
        range[1] = Math.min(range[1], list.value.length)
        pool.value = list.value
          .slice(range[0], range[1])
          .map((v, i) => ({ ...v, _index: (range[0] || 0) + i }))
        paddingTop.value = range[0] * itemHeight.value
      })
    }

    onMounted(() => {
      if (!root.value) return
      visualHight.value = useElementSize(root).height
      const contentLines = Math.ceil(visualHight.value / itemHeight.value)
      const totalLines = contentLines + visualCount.value
      const range = [0, totalLines]
      pool.value = list.value
        .slice(range[0], range[0] + range[1])
        .map((v, i) => ({ ...v, _index: range[0] + i }))
    })

    onBeforeUnmount(() => {
      // 清除动画
      cancelFrame && cancelAnimateFrame(cancelFrame)
      cancelFrame = null
    })

    return () =>
      h(
        'div',
        {
          ref: root,
          class: 'vue3-virtual-list-container',
          onScroll: handleScroll,
          style: `height: ${props.height ? props.height : visualHight}px`,
        },
        [
          h(
            'div',
            {
              class: 'vue3-virtual-list-scroll',
              style: `height: ${scrollHeight.value}px;padding-top: ${paddingTop.value}px`,
            },
            [
              pool.value.map((child) => {
                return h(
                  'li',
                  {
                    key: props.key,
                    style: `height: ${props.itemHeight}px`,
                    class: { active: child._index === props.currentIndex },
                    onClick: handleClick.bind({}, child, child._index),
                  },
                  [slots.default?.({ item: child, index: child._index })]
                )
              }),
            ]
          ),
        ]
      )
  },
})

useElementSize

export interface ElementHeightOrWidth {
  height: number
  width: number
}
/**
 * @description: 获取元素的宽高
 * @param {Ref} ele
 * @return {*}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export const useElementSize = (ele: any): ElementHeightOrWidth => {
  const obj: ElementHeightOrWidth = {
    height: 0,
    width: 0,
  }
  if (window.getComputedStyle(ele.value)) {
    const height = (window.getComputedStyle(ele.value).height.split('px')[0] || 0) as number
    const width = (window.getComputedStyle(ele.value).width.split('px')[0] || 0) as number
    obj.height = height
    obj.width = width
  } else if (ele.value.getBoundingClientRect) {
    const height = ele.value.getBoundingClientRect().height
    const width = ele.value.getBoundingClientRect().width
    obj.height = height
    obj.width = width
  } else {
    obj.height = ele.value.clientHeight
    obj.width = ele.value.clientWidth
  }

  return obj
}