• 虚拟滚动(Virtual Scrolling)实现


    针对大数据列表一般不会一次性加载,会采用上拉加载或者分页的方式展示。如果 10W 条数据,列表对应着 10W 个 DOM 节点,性能方面体验可能会不太好,因此引入虚拟滚动来优化。

    虚拟滚动:要渲染完整列表对应的高度是通过 [虚拟计算] 的,并不是文档中存在对应的 DON 节点数。只要 [虚拟列表高度] > [列表可视区高度] 时,就会产生滚动条即可发生滚动操作。

    在滚动操作时,保证 [实际渲染的列表] 一直存在 [列表可视区] 中,并且动态切换需要渲染的列表数据。

    虚拟滚动实现

    组件对外暴漏的接口如下:

    type NumberOrNumberString = PropType<string | number | undefined>
    
    const props = {
        // 容器高度/宽度
        height: [Number, String] as NumberOrNumberString,
        width: [Number, String] as NumberOrNumberString,
        maxHeight: [Number, String] as NumberOrNumberString,
        maxWidth: [Number, String] as NumberOrNumberString,
        minHeight: [Number, String] as NumberOrNumberString,
        minWidth: [Number, String] as NumberOrNumberString,
        // 列表项高度
        itemHeight: {
            type: [Number, String] as NumberOrNumberString,
            required: true,
        },
        // 数据
        items: {
            type: Array as PropType<any[]>,
            default: () => [],
        },
        // 预加载数据数量
        bench: {
            type: [Number, String] as NumberOrNumberString,
            default: 0,
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • getItemHeightRef : 列表项高度
    • getContainerStyleRef: 虚拟列表高度 ,即 getContainerStyleRef = getItemHeightRef * items.length
    • getWrapStyleRef: 滚动区域样式
    const prefixCls = 'virtual-scroll';
    
    export default defineComponent({
        name: 'VirtualScroll',
        setup(props, { slots }) {
            const wrapElRef = ref<HTMLDivElement | null>(null);
            // 列表项高度
            const getItemHeightRef = computed(() => {
                return parseInt(props.itemHeight as string, 10);
            });
            // 虚拟列表高度
            const getContainerStyleRef = computed((): CSSProperties => {
                return {
                    height: convertToUnit((props.items || []).length * unref(getItemHeightRef)),
                };
            });
    
            // 滚动区域样式
            const getWrapStyleRef = computed((): CSSProperties => {
                const styles: Recordable<string> = {};
                const height = convertToUnit(props.height);
                const minHeight = convertToUnit(props.minHeight);
                const minWidth = convertToUnit(props.minWidth);
                const maxHeight = convertToUnit(props.maxHeight);
                const maxWidth = convertToUnit(props.maxWidth);
                const width = convertToUnit(props.width);
    
                if (height) styles.height = height;
                if (minHeight) styles.minHeight = minHeight;
                if (minWidth) styles.minWidth = minWidth;
                if (maxHeight) styles.maxHeight = maxHeight;
                if (maxWidth) styles.maxWidth = maxWidth;
                if (width) styles.width = width;
                return styles;
            });
    
            return () => {
                <div class={prefixCls} ref={wrapElRef} style={unref(getWrapStyleRef)}>
                    <div class={`${prefixCls}__container`} style={unref(getContainerStyleRef)}>
                        {renderChildren()}
                    </div>
                </div>
            }
        }
    })
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    接下来设置真正渲染数据的索引 first 和 last,用于从列表数据 items 中获取对应的数据内容。

    export default defineComponent({
        name: 'VirtualScroll',
        setup(props, { slots }) {
            const wrapElRef = ref<HTMLDivElement | null>(null);
            const state = reactive({
                first: 0,
                last: 0,
                scrollTop: 0,
            });
    
            const getBenchRef = computed(() => {
                return parseInt(props.bench as string, 10);
            });
            // 数组渲染 first 和 last 索引值
            const getFirstToRenderRef = computed(() => {
                return Math.max(0, state.first - unref(getBenchRef));
            });
            const getLastToRenderRef = computed(() => {
                return Math.min((props.items || []).length, state.last + unref(getBenchRef));
            });
        }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    对于虚拟组件支持外部通过插槽插入相关组件内容,我们使用 renderChildren 函数进行处理。通过 getFirstToRenderRef 和 getLastToRenderRef 分别计算到数组渲染起始和终止位置。

    export default defineComponent({
        name: 'VirtualScroll',
        setup(props, { slots }) {
    
            function renderChildren() {
                const { items = [] } = props
                return items.slice(unref(getFirstToRenderRef), unref(getLastToRenderRef).map(genChild))
            }
    
            function genChild(item: any, index: number) {
                index += unref(getFirstToRenderRef)
                const top = convertToUnit(index * unref(getItemHeightRef))
                return (
                    <div class={`${prefixCls}__item`} style={{ top }} key={index}>
                        {getSlot(slots, 'default', { index, item })}
                    </div>
                )
            }
        }
    })
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    genChild 函数负责处理列表项渲染,通过计算 index * getItemHeightRef 的值 top ,即 [实际渲染列表元素] 相对位置的数值,保证 [实例渲染列表元素] 一直存在可视区域中。

    我们需要注册并监听滚动事件 onScroll,代码实现如下:

    export default defineComponent({
        name: 'VirtualScroll',
        setup(props, { slots }) {
            const wrapElRef = ref<HTMLDivElement | null>(null);
            // 滚动事件
            function onScroll() {
                const wrapEl = unref(warpElRef)
                if (!wrapEl) {
                    return
                }
                state.scrollTop = wrapEl.scrollTop
                // first 和 last 为数组下标
                state.first = getFirst()
                state.last = getLast(state.first)
            }
            onMounted(() => {
                state.last = getLast(0)
                nextTick(() => {
                    const wrapEl = unref(wrapElRef);
                    if (!wrapEl) {
                        return;
                    }
                    // 监听滚动事件
                    useEventListener({
                        el: wrapEl,
                        name: 'scroll',
                        listener: onScroll,
                        wait: 0,
                    });
                })
            })
        }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • scrollTop :获取当前实际滚动距离
    • 根据实际的滚动距离 scrollTop,动态计算列表新的起始索引 first、last
     export default defineComponent({
        name: 'VirtualScroll',
        setup(props, { slots }) {
            const state = reactive({
                first: 0,
                last: 0,
                scrollTop: 0,
            });
            function getFirst(): number {
                return Math.floor(state.scrollTop / unref(getItemHeightRef))
            }
            function getLast(first: number): number {
                const wrapEl = unref(warpElRef)
                if (!wrapEl) {
                    return 0
                }
                const height = parseInt(props.height || 0, 10) || wrapEl.clientHeight
                return first + Math.ceil(height / unref(getItemHeightRef))
            }
        }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    关于更多虚拟滚动下拉刷新、上拉加载更多等功能细节,请参考: taro-ui-vue3 之虚拟组件

    工具函数

    useEventListener

    vueuse 库中的 useEventListener hook 函数,在挂载时使用 addEventListener 注册,在卸载时使用 removeEventListener 自动移出监听事件。接下来我们实现一个 hook,在 mounted 时添加事件监听,页面销毁时移出。

    首先 hook 钩子函数入参设定,具体代码如下:

    interface UseEventParams {
      el?: Element;
      name: string;
      listener: EventListener;
      options?: boolean;
    }
    export function useEventListener({
      el = window,
      name,
      listener,
      options,
      autoRemove = true,
      isDebounce = true,
      wait = 80
    }: UseEventParams) {
      ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • el:监听目标
    • name:监听事件名称
    • listener:监听回调函数
    • options:事件函数选项
    • autoRemove:是否自动移出
    • isDebounce: 是否防抖/节流
    • wait:延时时间
    // src/hooks/event/useEventListener.ts
    import type { Ref } from 'vue';
    import { ref, watch, unref } from 'vue';
    import { useThrottleFn, useDebounceFn } from '@vueuse/core'
    
    export type RemoveEventFn = () => void;
    
    export function useEventListener({
      el = window,
      name,
      listener,
      options,
      autoRemove = true,
      isDebounce = true,
      wait = 80
    }: UseEventParams) {
      let remove: removeEventFn = () => {}
      // 是否移出标识
      const isAddRef = ref(false)
    
      if (el) {
        const element = ref(el as Element) as Ref<Element>
    
        const handler = isDebounce ? useDebounceFn(listener, wait) : useThrottleFn(listener, wait)
        const realHandler = wait ? handler : listener
        // 移除监听
        const removeEventListener = (e: Element) => {
          isAddRef.value = true
          e.removeEventListener(name, realHandler, options)
        }
        // 增加监听
        const addEventListener = (e: Element) => {
          return e.addEventListener(name, realHandler, options)
        }
        // 监听: 使用 watch 方法返回的一个 unwatch 方法
        const removeWatch = watch(
          element,
          (v, _ov, cleanUp) => {
            if (v) {
              // 添加事件监听并执行 cleanUp 函数
              !unref(isAddRef) && addEventListener(v);
              cleanUp(() => {
                autoRemove && removeEventListener(v);
              });
            }
          },
          // 立即触发回调
          { immediate: true },
        );
    
        // 更新 remove 函数
        remove = () => {
          removeEventListener()
          removeWatch()
        }
      }
    
      return { removeEvent: remove }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    更多细节,请参考 vueuse 官网下的 useEventListener hook 源码实现。

    getSlot
    // src/utils/helper/tsxHelper.tsx
    import { Slots } from 'vue'
    
    function getSlot(slots: Slots, slot = 'default', data?: any) {
      if (!slots || !Reflect.has(slots, slot)) {
        return null
      }
      if (!isFunction(slots[slot])) {
        console.error(`${slot} is not a function!`)
        return null
      }
    
      const slotFn = slots[slot]
      if (!slotFn) return null
      return slotFn(data)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
  • 相关阅读:
    element 表格气泡是如何实现的
    C //例5.11 译密码。为使电文保密,往往按一定规律将其转换成密码,收报人再按约定的规律将其译回原文。
    开机强制进入安全模式的三种方法
    MySQL-数据库优化策略概述
    【JS】获取当前时间的简便方法
    js面试题(更新中...)
    如何管理数据湖中的小文件
    详述Python环境下配置AI大模型Qwen-72B的步骤
    【FFmpeg】av_write_frame函数
    SpringBoot+ShardingSphereJDBC实现读写分离
  • 原文地址:https://blog.csdn.net/qq_36437172/article/details/127561718