夜听城嚣 夜听城嚣
首页
  • 学习笔记

    • 《JavaScript高级程序设计》
    • 前端基建与架构
  • 专题分享

    • Git入门与开发
    • 前端面试题汇总
    • HTML和CSS知识点
  • 项目实践
  • 抓包工具
  • 知识管理
  • 工程部署
  • 团队规范
bug知多少
  • 少年歌行
  • 青年随笔
  • 文海泛舟
  • 此事躬行

    • 项目各工种是如何协作的
    • TBA课程学习
收藏

dwfrost

前端界的小学生
首页
  • 学习笔记

    • 《JavaScript高级程序设计》
    • 前端基建与架构
  • 专题分享

    • Git入门与开发
    • 前端面试题汇总
    • HTML和CSS知识点
  • 项目实践
  • 抓包工具
  • 知识管理
  • 工程部署
  • 团队规范
bug知多少
  • 少年歌行
  • 青年随笔
  • 文海泛舟
  • 此事躬行

    • 项目各工种是如何协作的
    • TBA课程学习
收藏
  • 读书笔记

  • 专题分享

    • Git入门与开发
    • 前端面试题汇总
    • HTML和CSS知识点
    • 前端路由原理及实践
    • 性能优化之虚拟列表
      • mysql教程
      • python项目实践
      • 图片懒加载原理
      • WallectConnect开发心得
      • 企微机器人
    • 项目实践

    • 框架应用

    • 前端一览
    • 专题分享
    frost
    2022-08-30

    性能优化之虚拟列表

    在遇到大量数据(成千上万)列表渲染的时候,由于 dom 元素创建和渲染开销很大,导致页面卡顿。这种情况下社区提出了“虚拟列表”的解决方案,类似于懒加载,即只在可见区域 进行渲染,而不必一开始就全部挂载,这样可以达到极高的初次渲染性能。

    再谈前端虚拟列表的实现 (opens new window)

    # 问题

    假设初始化渲染 1w 条数据,我们使用 vue3+element-plus 的 select 组件来渲染,计算列表数据从初始化到挂载到页面上的耗时。

    数量量/条 100 1k 10k 50k
    耗时/ms 22 164 2186 9372

    可以看到,当数据量超过 1 万条后,dom 挂载出现不可接受的卡顿,这种场景虽然少见,但仍可能出现,比如一次性显示所有的门店或仓库。

    接下来,应用 element-plus 的 select-v2 组件来渲染,其耗时如下:

    数量量/条 100 1k 10k 50k
    耗时/ms 9 14 17 58

    demo 地址见github (opens new window),操作步骤为pnpm i,pnpm dev。

    由此,可以看出虚拟列表的性能优势。

    # 节点调试

    以渲染 1 万条数据为例,组件渲染后,我们将其分为 3 个部分:可视区,可滚动区和列表项。如下图:

    虚拟列表

    • 可视区:select-v2 的选择框默认高度为 170px,即为可视区的高度,内部内容可以在可视区进行滚动。其行内样式为:

      position: relative;
      overflow-y: scroll;
      will-change: transform;
      direction: ltr;
      height: 170px;
      width: 213px;
      
      1
      2
      3
      4
      5
      6
    • 可滚动区:是一个占位的可滚动区域,对于列表项高度固定的场景,可滚动区的高度=数据条数*列表项的高度。样式为:

      height: 340000px;
      width: 100%;
      
      1
      2
    • 列表项:从全部数据中截取的部分数据,用于渲染在可视区的列表项。随着滚动区的滚动,列表项的内容也随之变化。每个列表样式如下(top 值不同):

      position: absolute;
      left: 0px;
      top: 442px;
      height: 34px;
      width: 100%;
      
      1
      2
      3
      4
      5

    # 主要源码

    基于上面的认知,开始从源码上寻求验证。

    1. 找到 select-v2 入口

      组件入口在packages\components\select-v2\index.ts,注册为全局组件,并以插件的形式供外部使用。

      import Select from './src/select.vue'
      Select.install = (app: App): void => {
        app.component(Select.name, Select)
      }
      export default Select
      
      1
      2
      3
      4
      5
    2. select 组件

      查看select.vue,其主要结构如下:

      <div>
        <el-tooltip>
          <template #default></template>
          <template #content>
            <el-select-menu></el-select-menu>
          </template>
        </el-tooltip>
      </div>
      
      1
      2
      3
      4
      5
      6
      7
      8
      // 选择框组件
      import ElSelectMenu from './select-dropdown'
      // hook
      import useSelect from './useSelect'
      
      1
      2
      3
      4

      由此,我们查看select-dropdown.tsx。

    3. select-dropdown 组件

      在 setup 中,主要代码如下:

      import { DynamicSizeList, FixedSizeList } from '@element-plus/components/virtual-list'
      import OptionItem from './option-item.vue'
      
      export default defineComponent({
        setup(props, { slots, expose }) {
          const Item = (itemProps: ItemProps<any>) => {
            const { index, data, style } = itemProps
            const sized = unref(isSized)
            const { itemSize, estimatedSize } = unref(listProps)
            const { modelValue } = select.props
            const { onSelect, onHover } = select
            const item = data[index]
            return (
              <OptionItem
                {...itemProps}
                selected={isSelected}
                disabled={item.disabled || isDisabled}
                created={!!item.created}
                hovering={isHovering}
                item={item}
                onSelect={onSelect}
                onHover={onHover}>
                {{
                  default: (props: OptionItemProps) => slots.default?.(props) || <span>{item.label}</span>,
                }}
              </OptionItem>
            )
          }
      
          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}
                onKeydown={onKeydown}>
                {{
                  default: (props: ItemProps<any>) => <Item {...props} />,
                }}
              </List>
            </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
      47
      48
      49
      50
      51

      这段代码中,List 就是虚拟列表组件了,默认情况下列表项高度是固定的,使用的是 FixedSizeList 组件;Item 就是列表项,实际是 OptionItem 组件,渲染了 label 属性, 并接收传入的 style 作为行内样式。

    4. FixedSizeList 组件

      FixedSizeList 组件位于packages\components\virtual-list\src\components\fixed-size-list.ts,element-plus 新增的几个虚拟列表相关的组件如 el-select-v2、el-tree-v2 等都使用了虚拟列表组件。

      import buildList from '../builders/build-list'
      
      const FixedSizeList = buildList({
        name: 'ElFixedSizeList',
        // ...
      })
      export default FixedSizeList
      
      1
      2
      3
      4
      5
      6
      7

      FixedSizeList 是使用 buildList 函数生成的组件,它包含虚拟列表的核心代码。

    5. build-list.ts

      const createList = ({
        name,
        getOffset,
        getItemSize,
        getItemOffset,
        getEstimatedTotalSize,
        getStartIndexForOffset,
        getStopIndexForStartIndex,
        initCache,
        clearCache,
        validateProps,
      }: ListConstructorProps<VirtualizedListProps>) => {
        return defineComponent({
          setup(props, { emit, expose }) {},
          render(ctx: any) {},
        })
      }
      export default createList
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18

      从两部分来解析,setup和render。

      5.1 setup 主要完成几个属性的计算和 scroll 事件的处理

      • states

        const states = ref({
          isScrolling: false, // 是否正在滚动
          scrollDir: 'forward', // 滚动方向 forward-向前 backward-向后
          scrollOffset: isNumber(props.initScrollOffset) ? props.initScrollOffset : 0, // 滚动偏移量
          updateRequested: false,
          isScrollbarDragging: false,
          scrollbarAlwaysOn: props.scrollbarAlwaysOn,
        })
        
        1
        2
        3
        4
        5
        6
        7
        8
      • itemsToRender

        const itemsToRender = computed(() => {
          // total:全部数据条数 cache:前后缓冲数据条数,防止滑动过程中出现空白
          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
        
          // 前2个是主要索引,分别表示列表项的第一条和最后一条数据的索引
          return [Math.max(0, startIndex - cacheBackward), Math.max(0, Math.min(total! - 1, stopIndex + cacheForward)), startIndex, stopIndex]
        })
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20

        getStartIndexForOffset和getStopIndexForStartIndex方法如下:

        // offset / itemSize,即滚动区偏移量/列表项高度
        const getStartIndexForOffset = ({ total, itemSize }, offset) => Math.max(0, Math.min(total - 1, Math.floor(offset / (itemSize as number))))
        
        // startIndex + numVisibleItems,即可视区的第一条数据的索引加上可视区内的数据条数
        // numVisibleItems = height/itemSize
        const 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
            )
          )
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
      • windowStyle

        const windowStyle = computed(() => [
          {
            position: 'relative',
            [`overflow-${_isHorizontal.value ? 'x' : 'y'}`]: 'scroll',
            WebkitOverflowScrolling: 'touch',
            willChange: 'transform',
          },
          {
            direction: props.direction,
            height: isNumber(props.height) ? `${props.height}px` : props.height,
            width: isNumber(props.width) ? `${props.width}px` : props.width,
          },
          props.style,
        ])
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14

        即可视区的样式,这里 height 默认为 170

      • innerStyle

        const innerStyle = computed(() => {
          const size = unref(estimatedTotalSize)
          const horizontal = unref(_isHorizontal)
          return {
            height: horizontal ? '100%' : `${size}px`,
            pointerEvents: unref(states).isScrolling ? 'none' : undefined,
            width: horizontal ? `${size}px` : '100%',
          }
        })
        
        1
        2
        3
        4
        5
        6
        7
        8
        9

        即可滚动区的样式,主要是计算高度。可滚动区的高度=数据条数*列表项的高度。

        const estimatedTotalSize = computed(() => getEstimatedTotalSize(props, unref(dynamicSizeCache)))
        
        const getEstimatedTotalSize = ({ total, itemSize }) => (itemSize as number) * total
        
        1
        2
        3
      • onScroll

        const onScroll = (e: Event) => {
          unref(_isHorizontal) ? scrollHorizontally(e) : scrollVertically(e)
          emitEvents()
        }
        const scrollVertically = (e: Event) => {
          const { clientHeight, scrollHeight, scrollTop } = e.currentTarget as HTMLElement
          const _states = unref(states)
          if (_states.scrollOffset === scrollTop) {
            return
          }
        
          const scrollOffset = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight))
        
          states.value = {
            ..._states,
            isScrolling: true,
            scrollDir: getScrollDir(_states.scrollOffset, scrollOffset),
            scrollOffset,
            updateRequested: false,
          }
        
          nextTick(resetIsScrolling)
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23

        监听可视区的 scroll 事件,将可滚动区域滚动的距离(scrollTop)更新给偏移量(scrollOffset)。

        5.2 render 则通过已计算好的数据,实时更新视图

      const {
        $slots,
        className,
        clientSize,
        containerElement,
        data,
        getItemStyle,
        innerElement,
        itemsToRender,
        innerStyle,
        layout,
        total,
        onScroll,
        onScrollbarScroll,
        onWheel,
        states,
        useIsScrolling,
        windowStyle,
        ns,
      } = ctx
      
      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),
            })
          )
        }
      }
      
      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

      这里 getItemStyle 方法定义在 setup 中,如下:

      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
      }
      
      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

      其中,列表项样式的 top 值计算如下:

      const getItemOffset = ({ itemSize }, index) => index * (itemSize as number)
      
      1

      如此,所有列表项所需的属性已计算完毕。接下来创建 vnodes,将数据更新到视图。

      const InnerNode = [
        h(
          Inner as VNode, // div
          {
            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,
      })
      
      const listContainer = h(
        Container as VNode, // div
        {
          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]
      )
      
      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

    # 总结原理

    源码到此分析完了,简单总结下:
    
    1. 容器结构从上到下依次为可视区、可滚动区和列表项,列表项高度固定,绝对定位。用户看到的内容是可视区中的列表项,它只渲染用户可见的一部分数据。
    2. 根据数据条数和列表项高度得到可滚动区高度。
    3. 监听可视区的 scroll 事件,根据偏移量分别计算列表项起始和结束数据的索引
    4. 索引更新后,列表项内容重新计算。从数据列表中取出索引对应的数据,列表项的样式(top)和内容随之发生变化,重新渲染视图。
    

    # 手写简版虚拟列表

    mini-virtual-list (opens new window)

    上次更新: 2022/09/18, 21:08:37
    前端路由原理及实践
    mysql教程

    ← 前端路由原理及实践 mysql教程→

    最近更新
    01
    提交代码时修改commit消息
    04-09
    02
    如何快速定位bug
    02-20
    03
    云端web项目开发踩坑
    08-25
    更多文章>
    Theme by Vdoing | Copyright © 2021-2025 dwfrost | 粤ICP备2021118995号
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式
    ×