likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 11

一、背景

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

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

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

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

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

二、虚拟列表

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

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

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

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

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

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

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

三、select V2的实现

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

     <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项的大小来决定采用固定大小列表,还是动态大小列表。

      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事件。

      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的更新。

  const onScroll = (e: Event) => {
        unref(_isHorizontal) ? scrollHorizontally(e) : scrollVertically(e)
        emitEvents()
  }
 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则返回的是虚拟列表渲染的数据索引

      // 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去计算开始索引和结束索引,它们的分别实现如下:

  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 // 开始索引加上当前区域可渲染数量 * 数据项高度
 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值:

     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的虚拟列表则是采用一个预设高度,后面再update进行相应的调整达到实际高度,因为每一项都不同的高度,所以查找开始索引也类似采取二分化进行优化。

参考