element-plus虚拟表格的实现逻辑

使用table-v2

官方文档: element-plus.gitee.io/zh-CN/compo...

  1. 在根目录下的main.ts文件中引入element-plus的样式
js 复制代码
import 'element-plus/dist/index.css'
  1. 在组件文件中的实现代码如下
js 复制代码
<template>
    <el-table-v2
      :columns="columns"
      :data="data"
      :width="700"
      :height="400"
      fixed
    />
  </template>
  
  <script lang="ts" setup>
  import { ElTableV2 } from 'element-plus';
  
  const generateColumns = (length = 10, prefix = 'column-', props?: any) =>
    Array.from({ length }).map((_, columnIndex) => ({
      ...props,
      key: `${prefix}${columnIndex}`,
      dataKey: `${prefix}${columnIndex}`,
      title: `Column ${columnIndex}`,
      width: 150,
    }))
  
  // 此方法用于构造1000条测试数据
  const generateData = (
    columns: ReturnType<typeof generateColumns>,
    length = 200,
    prefix = 'row-'
  ) =>
    Array.from({ length }).map((_, rowIndex) => {
      return columns.reduce(
        (rowData, column, columnIndex) => {
          rowData[column.dataKey] = `Row ${rowIndex} - Col ${columnIndex}`
          return rowData
        },
        {
          id: `${prefix}${rowIndex}`,
          parentId: null,
        }
      )
    })
  
  const columns = generateColumns(10)
  const data = generateData(columns, 1000)
  </script>
  
  1. 页面最终的呈现

如上图,我们来分析下这个虚拟表格的组成

  • 最外第一层是一个高350px,宽700px的相对定位容器,

    will-change: transform; 通常被用作合成层提示。在给定特定的 CSS 属性标识时,Chrome 目前会执行两个操作:建立新的合成层或新的层叠上下文

  • 第二层是一个高50000px(1000 * 50px)的相对定位容器

  • 第三层是展示10个高50px的列表项

    总共有1000个列表项, 但每次只展示10个

    每一项是绝对定位容器, 每一项都计算出top值, 第一行的top=0,第二行top=50px,第三行top=100px...

element-plus实现虚拟表格源码解析

js 复制代码
 const renderWindow = () => {
        const Container2 = resolveDynamicComponent(props.containerElement);
        const { horizontalScrollbar, verticalScrollbar } = renderScrollbars();
        const Inner = renderInner();
        return h("div", {
          key: 0,
          class: ns.e("wrapper"),
          role: props.role
        }, [
          h(Container2, {
            class: props.className,
            style: unref(windowStyle),
            onScroll, // scroll事件
            onWheel, // wheel事件
            ref: windowRef
          }, !isString(Container2) ? { default: () => Inner } : Inner),
          horizontalScrollbar, // 在第一层容器,overflow:hidden禁止了滚动条,element-plus自己实现的垂直滚动条
          verticalScrollbar // 水平滚动条
        ]);
      };
      return renderWindow;
    }

如上所示,在第一层容器上监听wheel和scroll事件,因为第一层容器将overflow设置为hidden, 应该不会触发scroll事件,只会触发wheel事件。但wheel事件可能会触发scroll事件, 在源码中scroll事件的毁掉函数中已经做了去重处理。

onwheel 是鼠标滚轮旋转,而 onscroll 处理的是对象内部内容区的滚动事件。

js 复制代码
  const onWheel = (e) => {
    cAF(frameHandle); // 取消请求动画帧或者定时器
    let x2 = e.deltaX; // 水平方向滚动量
    let y = e.deltaY; // 垂直方向滚动量
    // 只保留一个方向的滚动
    if (Math.abs(x2) > Math.abs(y)) {
      y = 0;
    } else {
      x2 = 0;
    }
    if (e.shiftKey && y !== 0) {
      x2 = y;
      y = 0;
    }
    if (hasReachedEdge(xOffset, yOffset) && hasReachedEdge(xOffset + x2, yOffset + y))
      return;
      // 累加多次的滚动量
    xOffset += x2;
    yOffset += y;
    e.preventDefault(); // 阻止weel事件的默认行为
    frameHandle = rAF(() => { 
      onWheelDelta(xOffset, yOffset);
      xOffset = 0; // 重置滚动量
      yOffset = 0;
    });
  };
  return {
    hasReachedEdge,
    onWheel
  };
};

var rAF = (fn2) => isClient ? window.requestAnimationFrame(fn2) : setTimeout(fn2, 16);
var cAF = (handle) => isClient ? window.cancelAnimationFrame(handle) : clearTimeout(handle);

window.requestAnimationFrame() 告诉浏览器------你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

使用window.requestAnimationFrame()可以优化性能,将多次滚动合并为一次

js 复制代码
 const { onWheel } = useGridWheel({
        atXStartEdge: computed2(() => states.value.scrollLeft <= 0),
        atXEndEdge: computed2(() => states.value.scrollLeft >= estimatedTotalWidth.value - unref(parsedWidth)),
        atYStartEdge: computed2(() => states.value.scrollTop <= 0),
        atYEndEdge: computed2(() => states.value.scrollTop >= estimatedTotalHeight.value - unref(parsedHeight))
      }, (x2, y) => {
        var _a2, _b, _c, _d;
        (_b = (_a2 = hScrollbar.value) == null ? void 0 : _a2.onMouseUp) == null ? void 0 : _b.call(_a2);
        (_d = (_c = vScrollbar.value) == null ? void 0 : _c.onMouseUp) == null ? void 0 : _d.call(_c);
        const width = unref(parsedWidth); // 第一层容器的宽度
        const height = unref(parsedHeight); // 第一层容器的高度
        scrollTo({
          scrollLeft: Math.min(states.value.scrollLeft + x2, estimatedTotalWidth.value - width), // estimatedTotalWidth 第二层容器的宽度
          scrollTop: Math.min(states.value.scrollTop + y, estimatedTotalHeight.value - height)  // estimatedTotalHeight 第二层容器的宽度
        });
      });
js 复制代码
const scrollTo = ({
        scrollLeft = states.value.scrollLeft,
        scrollTop = states.value.scrollTop
      }) => {
        scrollLeft = Math.max(scrollLeft, 0);
        scrollTop = Math.max(scrollTop, 0);
        const _states = unref(states);
        if (scrollTop === _states.scrollTop && scrollLeft === _states.scrollLeft) {
          return;
        }
        states.value = {
          ..._states,
          xAxisScrollDir: getScrollDir(_states.scrollLeft, scrollLeft),
          yAxisScrollDir: getScrollDir(_states.scrollTop, scrollTop),
          scrollLeft,
          scrollTop,
          updateRequested: true
        };
        nextTick(() => resetIsScrolling());
        onUpdated2();
        emitEvents();
      };
js 复制代码
const onUpdated2 = () => {
        const { direction: direction2 } = props;
        const { scrollLeft, scrollTop, updateRequested } = unref(states);
        const windowElement = unref(windowRef);
        if (updateRequested && windowElement) {
          if (direction2 === RTL) {
            switch (getRTLOffsetType()) {
              case RTL_OFFSET_NAG: {
                windowElement.scrollLeft = -scrollLeft;
                break;
              }
              case RTL_OFFSET_POS_ASC: {
                windowElement.scrollLeft = scrollLeft;
                break;
              }
              default: {
                const { clientWidth, scrollWidth } = windowElement;
                windowElement.scrollLeft = scrollWidth - clientWidth - scrollLeft;
                break;
              }
            }
          } else {
            windowElement.scrollLeft = Math.max(0, scrollLeft);
          }
          windowElement.scrollTop = Math.max(0, scrollTop);
        }
      };

给第一层容器的scrollTop和scrollLeft重新赋值,从而实现滚动的效果

js 复制代码
 const rowsToRender = computed(() => {
        const { totalColumn, totalRow, rowCache } = props
        const { isScrolling, yAxisScrollDir, scrollTop } = unref(states)

        if (totalColumn === 0 || totalRow === 0) {
          return [0, 0, 0, 0]
        }
        // 计算开始展示行
        const startIndex = getRowStartIndexForOffset(
          props,
          scrollTop,
          unref(cache)
        )
        // 计算结束展示行
        const stopIndex = getRowStopIndexForStartIndex(
          props,
          startIndex,
          scrollTop,
          unref(cache)
        )

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

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

使用计算属性计算展示的起始行和结束行。

js 复制代码
  getRowStartIndexForOffset: ({ rowHeight, totalRow }, scrollTop) => Math.max(0, Math.min(totalRow - 1, Math.floor(scrollTop / rowHeight))),
  getRowStopIndexForStartIndex: ({ rowHeight, totalRow, height }, startIndex, scrollTop) => {
    const top = startIndex * rowHeight;
    const numVisibleRows = Math.ceil((height + scrollTop - top) / rowHeight);
    return Math.max(0, Math.min(totalRow - 1, startIndex + numVisibleRows - 1));
  },

参考element-plus实现虚拟表格的原理,自己简单实现一个虚拟列表

js 复制代码
<template>
   <div style="position: relative;height: 300px; width: 700px; border: 1px solid #ddd; overflow: auto; will-change: transform;padding: 10px;"  
   @scroll="onScroll" @wheel="onWheel" ref="scrollRef" id="div1">
    <div :style="'height: ' + (totalRow *  rowHeight) + 'px;'" id="div2">
      <div v-for="row in showData" style="display: flex; position: absolute;  " 
      :class="row.rowIndex"
      :style="'top:' + row.rowIndex * rowHeight + 'px;height:' + rowHeight + 'px;'">
       <div v-for="i in 10" style="width: 150px;">{{  row['column-' + (i - 1)] }} </div>
      </div>
    </div>
   </div>
  </template>
  
  <script lang="ts" setup>
  import { reactive, computed, ref, unref } from 'vue';
  import type { Ref } from 'vue'

  const totalRow = 10000

  const generateColumns = (length = 10, prefix = 'column-', props?: any) =>
    Array.from({ length }).map((_, columnIndex) => ({
      ...props,
      key: `${prefix}${columnIndex}`,
      dataKey: `${prefix}${columnIndex}`,
      title: `Column ${columnIndex}`,
      width: 150,
    }))
  
  const generateData = (
    columns: ReturnType<typeof generateColumns>,
    length = 200,
    prefix = 'row-'
  ) =>
    Array.from({ length }).map((_, rowIndex) => {
      return columns.reduce(
        (rowData, column, columnIndex) => {
          rowData[column.dataKey] = `Row ${rowIndex} - Col ${columnIndex}`
          return rowData
        },
        {
          id: `${prefix}${rowIndex}`,
          rowIndex: rowIndex,
          parentId: null,
        }
      )
    })
  
  const columns = generateColumns(10)
  const data = generateData(columns, totalRow)
  const showRow = reactive({
    start: 0,
    end: 0
  })
  let rowHeight = 50
  // 根据scrollTop计算开始展示行,每行高50px,则开始展示行为scrollTop / 50
  const showData = computed(() => {
    let scrollTop = states.value.scrollTop
    let num = Math.floor(scrollTop / rowHeight)
    console.log("scrollTop=", scrollTop, num)
    if (num > totalRow - 10) num = totalRow - 10 // 特殊处理, 触底时保底处理
    showRow.start =  num > 0 ? num : 0
    showRow.end = showRow.start + 10
    return data.slice(showRow.start, showRow.end)
  })
  
  const states = ref({
    scrollTop: 0,
    scrollLeft: 0,
  })
  //每次滚动量
  let xOffset = 0
  let yOffset = 0
  let frameHandle : any = null
  const onWheel = (e: WheelEvent) => {
    e.preventDefault()// 阻止默认的weel事件执行
    console.log("wheel 事件")
    window.cancelAnimationFrame(frameHandle);
    let x = e.deltaX; //水平方向的滚动量
    let y = e.deltaY // 垂直方向的滚动量
    // 只保留一个方向
    if (Math.abs(x) > Math.abs(y)) { 
      y = 0
    } else {
      x = 0
    }
    if (y === 0 && x === 0) return // 没有滚动量
    if (x === 0 && y < 0 && states.value.scrollTop <= 0) {
      console.log("已经触顶了")
      return //已经触顶了
    }
    if (x === 0 && y > 0 && states.value.scrollTop >= totalRow * rowHeight - 500 + 300) {
      states.value.scrollTop = scrollRef.value.scrollTop
      console.log('已经触底了')
      return //已经触底了
    }
    if (y === 0 && x < 0 && states.value.scrollLeft <= 0 ) {
      console.log("水平方向已经滚动到最左侧了")
      states.value.scrollLeft = 0;
      x = 0
      return
    }
    if (y === 0 && x > 0 && states.value.scrollLeft > 150 * 10 - 700) {
      console.log('水平方向已经滚动到最右侧了')
      x = 0
      states.value.scrollLeft = scrollRef.value.scrollLeft
      return //已经触底了
    }

    console.log("水平滚动量", x, states.value.scrollLeft)
    console.log("垂直滚动量", y)
    
    // 累加多次的滚动量
    xOffset += x
    yOffset += y
    //  优化性能
    frameHandle = window.requestAnimationFrame(() => {
      onWheelDelta(xOffset, yOffset)
      xOffset = 0
      yOffset = 0
     })
  }

  const onWheelDelta  =  (x: any, y: any) => {
    states.value.scrollLeft += x
    states.value.scrollTop += y
    if (states.value.scrollTop < 0) states.value.scrollTop = 0; // 这里要注意, 
    scrollTo()
  }
  
  const scrollRef : Ref<any> = ref({
    scrollLeft: Number,
    scrollTop: Number
  })
  const scrollTo = () => {
    scrollRef.value.scrollLeft = states.value.scrollLeft
    scrollRef.value.scrollTop = states.value.scrollTop
  }

  
  const onScroll = (e : any) => {
    e.preventDefault() //  阻止触发默认的scroll事件
    const {
          scrollLeft,
          scrollTop,
        } = e.currentTarget;
        const _states = unref(states);
        if (_states.scrollTop === scrollTop && _states.scrollLeft === scrollLeft) {
          return; //  滚轮事件已经触发,不再执行滚动事件
        }
    console.log("滚动事件")
  
   states.value = {
    ...states,
    scrollLeft: scrollLeft,
    scrollTop: Math.max(0, scrollTop)
   }
  }

  </script>
  
相关推荐
清幽竹客38 分钟前
vue-37(模拟依赖项进行隔离测试)
前端·vue.js
vvilkim38 分钟前
Nuxt.js 页面与布局系统深度解析:构建高效 Vue 应用的关键
前端·javascript·vue.js
滿42 分钟前
Vue3 父子组件表单滚动到校验错误的位置实现方法
前端·javascript·vue.js
专注VB编程开发20年43 分钟前
javascript的类,ES6模块写法在VSCODE中智能提示
开发语言·javascript·vscode
夏梦春蝉2 小时前
ES6从入门到精通:模块化
前端·ecmascript·es6
拓端研究室3 小时前
视频讲解:门槛效应模型Threshold Effect分析数字金融指数与消费结构数据
前端·算法
工一木子4 小时前
URL时间戳参数深度解析:缓存破坏与前端优化的前世今生
前端·缓存
半点寒12W5 小时前
微信小程序实现路由拦截的方法
前端
某公司摸鱼前端6 小时前
uniapp socket 封装 (可拿去直接用)
前端·javascript·websocket·uni-app
要加油哦~6 小时前
vue | 插件 | 移动文件的插件 —— move-file-cli 插件 的安装与使用
前端·javascript·vue.js