✨「前端进阶」从select-v2讲虚拟列表

一、背景

​ 在开发中经常有长列表展示的需求,长列表在渲染时容易因为DOM节点过多而使页面明显的卡顿。比如在使用element-ui中的el-select组件时,options选项达到几百个时,组件使用上就极为不流畅等等。对于这种需求,简单的往往是懒加载思路:监听滚动分批次地加载数据。一开始我也采用了懒加载的思路去优化el-select,但这种做法并不能完全解决问题,它存在:

  1. 随着滚动的进行,页面内DOM节点还是会累积越来越多,还是会存在卡顿
  2. 组件值回显的时候,若数据还未加载到,会显示不正确的label值。

后面了解到element-plus新增了select V2选择器组件解决了该问题,

出于好奇,我们查看下源码实现,了解到一个新的思路:虚拟列表 ,一种局部渲染的方式。其实不止element-plus,市面上也有一些虚拟列表的库,比如vue-virtual-scroller,这种插件也能在Vue2中解决一些长列表的问题,下面我们讲讲selectV2及什么是虚拟列表。

二、虚拟列表

​ 我们先看看select-v2在传入1000项数据后,渲染后的DOM,由下图中看出,在el-select-dropdown内,只有10项 li 数据节点是渲染的,并没有一次性渲染1000个DOM。(当然 这个大小可以控制,也不一定是10)而随着滚动,里面的10项 li 节点不断更新替换成应该显示的值,这就构成了一个虚拟列表,只展示可视区域的数据,只渲染可视区域的DOM节点。

可以注意到,虚拟列表中,每一项的定位都不相同,它们针对自己所在数组的位置,计算出相应的top偏差,结合绝对定位,当滚动到指定位置就能进行相应的展示与隐藏。

​ 虚拟列表的大体思路可总结为:

  1. 监听渲染区域的滚动事件,通过计算与初始位置的偏移量
  2. 通过偏移量,计算当前可视区域的起始位置索引
  3. 通过偏移量,计算当前可视区域的结束位置索引
  4. 根据索引获取当前可视区域的数据,渲染到页面
  5. 根据偏移量,适当调整使渲染区域的数据完全展示

补充:select v2为了优化效果,将每次滚动的偏移量都设置为一页数据的高度,方便展示

三、select V2的实现

​ select v2在组件内维护长列表的数据,然后控制一次只渲染部分数据,完美地解决了长列表卡顿及数据回显的问题。我们知道select组件的长列表主要存在于下拉菜单中,所以我们直接定位到select-v2的下拉菜单内容,也就是下图中的el-select-menu组件

vue 复制代码
     <template #content>
        <el-select-menu
          ref="menuRef"
          :data="filteredOptions"
          :width="popperSize"
          :hovering-index="states.hoveringIndex"
          :scrollbar-always-on="scrollbarAlwaysOn"
        >
          <template #default="scope">
            <slot v-bind="scope" />
          </template>
          <template #empty>
            <slot name="empty">
              <p :class="nsSelectV2.e('empty')">
                {{ emptyText ? emptyText : '' }}
              </p>
            </slot>
          </template>
        </el-select-menu>
      </template>

在select.dropdown.tsx中 我们了解到el-select-menu主要是基于List渲染的,这里的List也就是我们的虚拟列表,这边还根据是否传入Item项的大小来决定采用固定大小列表,还是动态大小列表。

tsx 复制代码
      const List = unref(isSized) ? FixedSizeList : DynamicSizeList

      return (
        <div class={[ns.b('dropdown'), ns.is('multiple', multiple)]}>
          <List
            ref={listRef}
            {...unref(listProps)}
            className={ns.be('dropdown', 'list')}
            scrollbarAlwaysOn={scrollbarAlwaysOn}
            data={data}
            height={height}
            width={width}
            total={data.length}
            // @ts-ignore - dts problem
            onKeydown={onKeydown}
          >
            {{
              default: (props: ItemProps<any>) => <Item {...props} />,
            }}
          </List>
        </div>
      )

接着我们定位到FixedSizeList,先看看其实现。组件的render函数的主要部分:由scrollbar和listContainer组成。listContainer就是虚拟列表的容器,还绑定了相应的onScroll、onWheel事件。

php 复制代码
      const scrollbar = h(Scrollbar, {
        ref: 'scrollbarRef',
        clientSize,
        layout,
        onScroll: onScrollbarScroll,
        ratio: (clientSize * 100) / this.estimatedTotalSize,
        scrollFrom:
          states.scrollOffset / (this.estimatedTotalSize - clientSize),
        total,
      })

      const listContainer = h(
        Container as VNode,
        {
          class: [ns.e('window'), className],
          style: windowStyle,
          onScroll,
          onWheel,
          ref: 'windowRef',
          key: 0,
        },
        !isString(Container) ? { default: () => [InnerNode] } : [InnerNode]
      )

      return h(
        'div',
        {
          key: 0,
          class: [ns.e('wrapper'), states.scrollbarAlwaysOn ? 'always-on' : ''],
        },
        [listContainer, scrollbar]
      )

可以看到监听滚动事件,先根据横向与纵向的区别做了区分,然后根据滚动的偏移量做了state状态的更新,进而触发itemsToRender的更新。

javascript 复制代码
  const onScroll = (e: Event) => {
        unref(_isHorizontal) ? scrollHorizontally(e) : scrollVertically(e)
        emitEvents()
  }
ini 复制代码
 const scrollHorizontally = (e: Event) => {
        const { clientWidth, scrollLeft, scrollWidth } =
          e.currentTarget as HTMLElement
        const _states = unref(states)

        if (_states.scrollOffset === scrollLeft) {
          return
        }

        const { direction } = props

        let scrollOffset = scrollLeft

        if (direction === RTL) {
          switch (getRTLOffsetType()) {
            case RTL_OFFSET_NAG: {
              scrollOffset = -scrollLeft
              break
            }
            case RTL_OFFSET_POS_DESC: {
              scrollOffset = scrollWidth - clientWidth - scrollLeft
              break
            }
          }
        }

        scrollOffset = Math.max(
          0,
          Math.min(scrollOffset, scrollWidth - clientWidth)
        )

        states.value = {
          ..._states,
          isScrolling: true,
          scrollDir: getScrollDir(_states.scrollOffset, scrollOffset),
          scrollOffset,
          updateRequested: false,
        }
        nextTick(resetIsScrolling)
     }

其中虚拟列表区域依次调用 listContainer => InnerNode =>itemsToRender。而itemsToRender则返回的是虚拟列表渲染的数据索引

javascript 复制代码
      // computed
      const itemsToRender = computed(() => {
        const { total, cache } = props
        const { isScrolling, scrollDir, scrollOffset } = unref(states)

        if (total === 0) {
          return [0, 0, 0, 0]
        }

        const startIndex = getStartIndexForOffset(
          props,
          scrollOffset,
          unref(dynamicSizeCache)
        )
        const stopIndex = getStopIndexForStartIndex(
          props,
          startIndex,
          scrollOffset,
          unref(dynamicSizeCache)
        )

        const cacheBackward =
          !isScrolling || scrollDir === BACKWARD ? Math.max(1, cache) : 1
        const cacheForward =
          !isScrolling || scrollDir === FORWARD ? Math.max(1, cache) : 1

        return [
          Math.max(0, startIndex - cacheBackward),
          Math.max(0, Math.min(total! - 1, stopIndex + cacheForward)),
          startIndex,
          stopIndex,
        ]
      })

这边它分别使用getStartIndexForOffset、getStopIndexForStartIndex去计算开始索引和结束索引,它们的分别实现如下:

javascript 复制代码
  getStartIndexForOffset: ({ total, itemSize }, offset) =>
    Math.max(0, Math.min(total - 1, Math.floor(offset / (itemSize as number)))),

  getStopIndexForStartIndex: (
    { height, total, itemSize, layout, width }: Props,
    startIndex: number,
    scrollOffset: number
  ) => {
    const offset = startIndex * (itemSize as number)
    const size = isHorizontal(layout) ? width : height
    const numVisibleItems = Math.ceil(
      ((size as number) + scrollOffset - offset) / (itemSize as number)
    )
    return Math.max(
      0,
      Math.min(
        total - 1,
        // because startIndex is inclusive, so in order to prevent array outbound indexing
        // we need to - 1 to prevent outbound behavior
        startIndex + numVisibleItems - 1
      )
    )
  }
  • 开始索引: Math.floor(offset / (itemSize as number)) // 偏移量除以数据项高度向下取整
  • 结束索引: startIndex + numVisibleItems - 1 // 开始索引加上当前区域可渲染数量 * 数据项高度
javascript 复制代码
 const [start, end] = itemsToRender
 
 const Container = resolveDynamicComponent(containerElement)
 const Inner = resolveDynamicComponent(innerElement)

 const children = [] as VNodeChild[]

 if (total > 0) {
        for (let i = start; i <= end; i++) {
          children.push(
            ($slots.default as Slot)?.({
              data,
              key: i,
              index: i,
              isScrolling: useIsScrolling ? states.isScrolling : undefined,
              style: getItemStyle(i),
            })
          )
        }
     }

这边值得注意的是它采取数据的区间并不是计算出的[startIndex, stopIndex],而是在这基础上添加了 [startIndex - cacheBackward, stopIndex + cacheForward],扩大了渲染区间,避免用户滚动过快时出现白屏的数据现象。这边它针对每一项数据,都进行了独立的style计算,这也是为什么每项都会有不同的top值:

javascript 复制代码
     const getItemStyle = (idx: number) => {
        const { direction, itemSize, layout } = props

        const itemStyleCache = getItemStyleCache.value(
          clearCache && itemSize,
          clearCache && layout,
          clearCache && direction
        )
        let style: CSSProperties
        if (hasOwn(itemStyleCache, String(idx))) {
          style = itemStyleCache[idx]
        } else {
          const offset = getItemOffset(props, idx, unref(dynamicSizeCache))
          const size = getItemSize(props, idx, unref(dynamicSizeCache))
          const horizontal = unref(_isHorizontal)

          const isRtl = direction === RTL
          const offsetHorizontal = horizontal ? offset : 0
          itemStyleCache[idx] = style = {
            position: 'absolute',
            left: isRtl ? undefined : `${offsetHorizontal}px`,
            right: isRtl ? `${offsetHorizontal}px` : undefined,
            top: !horizontal ? `${offset}px` : 0,
            height: !horizontal ? `${size}px` : '100%',
            width: horizontal ? `${size}px` : '100%',
          }
        }

        return style
      }

利用itemStyleCache的缓存机制,可以避免重复计算。

四、总结

​ 虚拟列表原理是利用视觉差在页面内渲染出一份虚拟的列表,长列表撑起了一个看不见的高度,用户在数据区域内滚动察觉不到长列表的存在,只有渲染区域的数据不断切换虚拟出一种滚动的感觉。目前element-plus的select v2还在beta阶段,一些监听srcoll的事件处理还可以进一步调优。另外针对定高和不定高的数据项,都有对应的解决方案,这边主要根据定高的数据项展开,至于dynamic-size-list的虚拟列表后面再另开一篇。

参考

相关推荐
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅7 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端
爱敲代码的小鱼7 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax