element plus虚拟下拉列表原理解析

1,507 阅读2分钟

截屏2022-07-30 11.53.22.png

随着互联网的发展,互联网用户量与日俱增,产生了大量的信息数据。为了方便用户可以直观的搜索数据,查看数据,就需要页面有能力将大数据进行处理并展示出来,并且还需要兼顾页面性能。

我平时也挺喜欢钻研一些关于页面性能的知识和优秀的框架。今天我就说一说element plus中的虚拟列表是如何来解决大数据量渲染的问题的。

一、调试准备阶段

很多小伙伴想要阅读源码,但是干看源码不免有些枯燥,想实际去调试,又不知道如何去debugger。那么我就说说,我调试源码的步骤。

  1. 拉取element plus代码仓库和安装依赖(这个不多说,会用git的都懂了哈)
  2. 在element plus的根目录新建index.html文件(vite中有说明
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + Vue + TS</title>
</head>

<body>
  <div id="app"></div>
  <script type="module" src="./example/main.ts"></script>
</body>

</html>

  1. 在element plus的根目录新建一个example的包,新建一个main.ts和App.vue,main.ts是用来导出到index.html的入口文件,App.vue则是用来编写调试代码的文件
// main.ts
import { createApp } from 'vue'
// 引入虚拟列表的组件
import select from '../packages/components/select-v2'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App).use(select)

app.mount('#app')

// App.vue
<template>
  <el-select-v2 v-model="value" :options="options" placeholder="Please select" size="large" />
</template>

<script lang="ts" setup>
import { ref } from 'vue'
const initials = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

const value = ref('')
const options = Array.from({ length: 1000 }).map((_, idx) => ({
  value: `Option ${idx + 1}`,
  label: `${initials[idx % 10]}${idx}`,
}))
</script>

<style scoped>
.example-showcase .el-select-v2 {
  margin-right: 20px;
}
</style>
  1. 修改将vitest.config.ts改名为vite.config.ts
  2. 运行yarn vite

ok!准备工作搞定后,现在就可以去浏览器进行调试啦

二、源码分析

顺着select一步步调试下去,就来到了select-dropdown组件中,可以发现引入了一个新的组件virtual-list,帷幕缓缓落下,主角终于上场了!

...
import {
  DynamicSizeList,
  FixedSizeList,
} from '@element-plus/components/virtual-list'
...

export default defineComponent({
  name: 'ElSelectDropdown',

  props: {
    data: {
      type: Array,
      required: true,
    },
    hoveringIndex: Number,
    width: Number,
  },
  setup(props, { slots, expose }) {
    const select = inject(selectV2InjectionKey)!
    ...

    const isSized = computed(() =>
      isUndefined(select.props.estimatedOptionHeight) // 默认为undefined
    )
    const listProps = computed(() => {
      if (isSized.value) {
        return {
          itemSize: select.props.itemHeight,// 默认为34
        }
      }

      return {
        estimatedSize: select.props.estimatedOptionHeight,
        itemSize: (idx: number) => cachedHeights.value[idx],
      }
    })
    ...
    
     return () => {
         
          const List = unref(isSized) ? FixedSizeList : DynamicSizeList

          return (
            <div class={[ns.b('dropdown'), ns.is('multiple', multiple)]}>
              <List
                ...
              >
                {{
                  default: (props: ItemProps<any>) => <Item {...props} />,
                }}
              </List>
            </div>
          )
        }
  },
})

通过阅读源码,estimatedOptionHeight走默认条件,系统会默认每个item高度为34px,否则需要自定义单条数据的高度。此处的高度赋值,为后续进行虚拟渲染计算做好了准备工作

接着我们进入FixedSizeList组件中来一探究竟

const FixedSizeList = buildList({
  ...
  // 计算开始位置的偏移量
  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
    /**
     * 计算可见区域能展示几个listItem组件
     */
    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
      )
    )
  },

  /**
   * Fixed size list does not need this cache
   * Using any to bypass it, TODO: Using type inference to fix this.
   */
  initCache() {
    return undefined as any
  },
...
})

export default FixedSizeList

可以发现该组件使用了一个高阶组件进行装饰,这里面我觉得最重要的就是这两个偏移量下标计算函数。他们是用来计算每次列表下拉时展示区位置所对应数据数组的开始下标和结束下标。

在buildList中,我们可以看到有几个比较重要的函数,首先是用于计算dom生成区对应到数据数组的开始和结束位置下标信息

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)
        )
        /**
         * 这里代表在滑动时,上下可存在的数量,多的会被清除
         *    cache 2 item
         * |——————————————|
         * |    content   |
         * |              |
         * |——————————————|
         *    cache 2 item
         */
        const cacheBackward =
          !isScrolling || scrollDir === BACKWARD ? Math.max(1, cache) : 1
        const cacheForward =
          !isScrolling || scrollDir === FORWARD ? Math.max(1, cache) : 1
        /**
         * [startIndex,endIndex,visibleStart,visibleEnd]
         */
        return [
          Math.max(0, startIndex - cacheBackward),
          Math.max(0, Math.min(total! - 1, stopIndex + cacheForward)),
          startIndex,
          stopIndex,
        ]
      })

调用hooks注册onWheel的监听事件,用于监听鼠标滚动偏移量

      const { onWheel } = useWheel(
        {
          atStartEdge: computed(() => states.value.scrollOffset <= 0),
          atEndEdge: computed(
            () => states.value.scrollOffset >= estimatedTotalSize.value
          ),
          layout: computed(() => props.layout),
        },
        (offset) => {
          ;(
            scrollbarRef.value as any as {
              onMouseUp: () => void
            }
          ).onMouseUp?.()
          scrollTo(
            Math.min(
              states.value.scrollOffset + offset,
              estimatedTotalSize.value - (clientSize.value as number)
            )
          )
        }
      )
      
      const scrollTo = (offset: number) => {
        offset = Math.max(offset, 0)

        if (offset === unref(states).scrollOffset) {
          return
        }

        states.value = {
          ...unref(states),
          scrollOffset: offset,
          scrollDir: getScrollDir(unref(states).scrollOffset, offset),
          updateRequested: true,
        }

        ...
      }

getItemStyle函数为每个item设置上相对于下拉组件的绝对位置,主要原因还是由于在页面上生成了一个超级高的容器,鼠标在滚动时,容器也会跟着滚动,但是页面元素并不是从上往下堆排出来的,从始至终都只会存在那六、七个item,这也就是所谓的虚拟滚动,所以设置绝对定位尤为重要

截屏2022-07-30 16.09.34.png

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
      }

接着调用生命周期钩子,在滚动发生时,实时更新el-select-dropdown__list的scrollTop,这样就是就实现了滚动的效果(此处只研究垂直滚动)

onUpdated(() => {
        const { direction, layout } = props
        const { scrollOffset, updateRequested } = unref(states)
        const windowElement = unref(windowRef)
        if (updateRequested && windowElement) {
          ...
          } else {
            windowElement.scrollTop = scrollOffset
          }
        }
      })

render(ctx: any) {
      ...
      const [start, end] = itemsToRender // 获取数据的起点和终点位置

      ...

      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),
            })
          )
        }
      }
      // 这是刚才height:34000px的box
      const InnerNode = [
        h(
          Inner as VNode,
          {
            style: innerStyle,
            ref: 'innerRef',
          },
          !isString(Inner)
            ? {
                default: () => children,
              }
            : children
        ),
      ]
      /**
       * 下拉条组件
       */
      const scrollbar = h(Scrollbar, {
        ref: 'scrollbarRef',
        clientSize,
        layout,
        onScroll: onScrollbarScroll,
        ratio: (clientSize * 100) / this.estimatedTotalSize,
        scrollFrom:
          states.scrollOffset / (this.estimatedTotalSize - clientSize),
        total,
      })
      /**
       * el-select-dropdown__list
       * 下拉框容器
       */
      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]
      )
    },
  })

这是我第一次写博客,有不足的地方还望各位大佬们多多指点。