vue antd 解决表格大量数据卡顿

该文章展示了一个Vue组件,用于在AntDesignVue的表格中实现虚拟滚动。组件考虑了动态高度、滚动事件处理、多选功能以及树形结构数据的支持,优化了性能,特别是在大数据量时。它通过监听滚动和窗口resize事件,动态计算渲染的数据范围,以提高页面性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

<template>
  <div>
    <a-table
      v-bind="$attrs"
      v-on="$listeners"
      :pagination="false"
      :columns="tableColumns"
      :data-source="renderData"
    >
      <template
        v-for="slot in Object.keys($scopedSlots)"
        :slot="slot"
        slot-scope="text"
      >
        <slot
          :name="slot"
          v-bind="typeof text === 'object' ? text : { text }"
        ></slot>
      </template>
    </a-table>
    <div class="ant-table-append" ref="append" v-show="!isHideAppend">
      <slot name="append"></slot>
    </div>
  </div>
</template>

<script>
import throttle from "lodash/throttle";
//   import Checkbox from 'ant-design-vue/lib/checkbox'
//   import Table from 'ant-design-vue/lib/table'

// 判断是否是滚动容器
function isScroller(el) {
  const style = window.getComputedStyle(el, null);
  const scrollValues = ["auto", "scroll"];
  return (
    scrollValues.includes(style.overflow) ||
    scrollValues.includes(style["overflow-y"])
  );
}

// 获取父层滚动容器
function getParentScroller(el) {
  let parent = el;
  while (parent) {
    if ([window, document, document.documentElement].includes(parent)) {
      return window;
    }
    if (isScroller(parent)) {
      return parent;
    }
    parent = parent.parentNode;
  }

  return parent || window;
}

// 获取容器滚动位置
function getScrollTop(el) {
  return el === window ? window.pageYOffset : el.scrollTop;
}

// 获取容器高度
function getOffsetHeight(el) {
  return el === window ? window.innerHeight : el.offsetHeight;
}

// 滚动到某个位置
function scrollToY(el, y) {
  if (el === window) {
    window.scroll(0, y);
  } else {
    el.scrollTop = y;
  }
}

// 表格body class名称
const TableBodyClassNames = [
  ".ant-table-scroll .ant-table-body",
  ".ant-table-fixed-left .ant-table-body-inner",
  ".ant-table-fixed-right .ant-table-body-inner",
];

let checkOrder = 0; // 多选:记录多选选项改变的顺序

export default {
  inheritAttrs: false,
  name: "vt",
  props: {
    dataSource: {
      type: Array,
      default: () => [],
    },
    columns: {
      type: Array,
      default: () => [],
    },
    // key值,data数据中的唯一id
    keyProp: {
      type: String,
      default: "name",
    },
    // 每一行的预估高度
    itemSize: {
      type: Number,
      default: 60,
    },
    // 指定滚动容器
    scrollBox: {
      type: String,
    },
    // 顶部和底部缓冲区域,值越大显示表格的行数越多
    buffer: {
      type: Number,
      default: 100,
    },
    // 滚动事件的节流时间
    throttleTime: {
      type: Number,
      default: 10,
    },
    // 是否获取表格行动态高度
    dynamic: {
      type: Boolean,
      default: true,
    },
    // 是否开启虚拟滚动
    virtualized: {
      type: Boolean,
      default: true,
    },
    // 是否是树形结构
    isTree: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      start: 0,
      end: undefined,
      sizes: {}, // 尺寸映射(依赖响应式)
      renderData: [],
      // 兼容多选
      isCheckedAll: false, // 全选
      isCheckedImn: false, // 控制半选样式
      isHideAppend: false,
    };
  },
  computed: {
    tableColumns() {
      return this.columns.map((column) => {
        // 兼容多选
        if (column.type === "selection") {
          return {
            title: () => {
              return (
                <a-checkbox
                  checked={this.isCheckedAll}
                  indeterminate={this.isCheckedImn}
                  onchange={() => this.onCheckAllRows(!this.isCheckedAll)}
                ></a-checkbox>
              );
            },
            customRender: (text, row) => {
              return (
                <a-checkbox
                  checked={row.$v_checked}
                  onchange={() => this.onCheckRow(row, !row.$v_checked)}
                ></a-checkbox>
              );
            },
            width: 60,
            ...column,
          };
        } else if (column.index) {
          // 兼容索引
          return {
            customRender: (text, row, index) => {
              const curIndex = this.start + index;
              return typeof column.index === "function"
                ? column.index(curIndex)
                : curIndex + 1;
            },
            ...column,
          };
        }
        return column;
      });
    },
    // 计算出每个item(的key值)到滚动容器顶部的距离
    offsetMap({ keyProp, itemSize, sizes, dataSource }) {
      if (!this.dynamic) return {};

      const res = {};
      let total = 0;
      for (let i = 0; i < dataSource.length; i++) {
        const key = dataSource[i][keyProp];
        res[key] = total;

        const curSize = sizes[key];
        const size = typeof curSize === "number" ? curSize : itemSize;
        total += size;
      }
      return res;
    },
  },
  methods: {
    // 初始化数据
    initData() {
      // 是否是表格内部滚动
      this.isInnerScroll = false;
      this.scroller = this.getScroller();
      this.setToTop();

      // 首次需要执行2次handleScroll:因为第一次计算renderData时表格高度未确认导致计算不准确;第二次执行时,表格高度确认后,计算renderData是准确的
      this.handleScroll();
      this.$nextTick(() => {
        this.handleScroll();
      });
      // 监听事件
      this.onScroll = throttle(this.handleScroll, this.throttleTime);
      this.scroller.addEventListener("scroll", this.onScroll);
      window.addEventListener("resize", this.onScroll);
    },

    // 设置表格到滚动容器的距离
    setToTop() {
      if (this.isInnerScroll) {
        this.toTop = 0;
      } else {
        this.toTop =
          this.$el.getBoundingClientRect().top -
          (this.scroller === window
            ? 0
            : this.scroller.getBoundingClientRect().top) +
          getScrollTop(this.scroller);
      }
    },

    // 获取滚动元素
    getScroller() {
      let el;
      if (this.scrollBox) {
        if (this.scrollBox === "window" || this.scrollBox === window)
          return window;

        el = document.querySelector(this.scrollBox);
        if (!el)
          throw new Error(
            ` scrollBox prop: '${this.scrollBox}' is not a valid selector`
          );
        if (!isScroller(el))
          console.warn(
            `Warning! scrollBox prop: '${this.scrollBox}' is not a scroll element`
          );
        return el;
      }
      // 如果表格是固定高度,则获取表格内的滚动节点,否则获取父层滚动节点
      if (this.$attrs.scroll && this.$attrs.scroll.y) {
        this.isInnerScroll = true;
        return this.$el.querySelector(".ant-table-body");
      } else {
        return getParentScroller(this.$el);
      }
    },

    // 处理滚动事件
    handleScroll() {
      if (!this.virtualized) return;

      // 更新当前尺寸(高度)
      this.updateSizes();
      // 计算renderData
      this.calcRenderData();
      // 计算位置
      this.calcPosition();
    },

    // 更新尺寸(高度)
    updateSizes() {
      if (!this.dynamic) return;

      let rows = [];
      if (this.isTree) {
        // 处理树形表格,筛选出一级树形结构
        rows = this.$el.querySelectorAll(
          ".ant-table-body .ant-table-row-level-0"
        );
      } else {
        rows = this.$el.querySelectorAll(
          ".ant-table-body .ant-table-tbody .ant-table-row"
        );
      }

      Array.from(rows).forEach((row, index) => {
        const item = this.renderData[index];
        if (!item) return;

        // 计算表格行的高度
        let offsetHeight = row.offsetHeight;
        // 表格行如果有扩展行,需要加上扩展内容的高度
        const nextEl = row.nextSibling;
        if (
          nextEl &&
          nextEl.classList &&
          nextEl.classList.contains("ant-table-expanded-row")
        ) {
          offsetHeight += row.nextSibling.offsetHeight;
        }

        // 表格行如果有子孙节点,需要加上子孙节点的高度
        if (this.isTree) {
          let next = row.nextSibling;
          while (
            next &&
            next.tagName === "TR" &&
            !next.classList.contains("ant-table-row-level-0")
          ) {
            offsetHeight += next.offsetHeight;
            next = next.nextSibling;
          }
        }

        const key = item[this.keyProp];
        if (this.sizes[key] !== offsetHeight) {
          this.$set(this.sizes, key, offsetHeight);
          row._offsetHeight = offsetHeight;
        }
      });
    },

    // 计算只在视图上渲染的数据
    calcRenderData() {
      const { scroller, buffer, dataSource: data } = this;
      // 计算可视范围顶部、底部
      const top = getScrollTop(scroller) - buffer - this.toTop;
      const scrollerHeight = this.isInnerScroll
        ? this.$attrs.scroll.y
        : getOffsetHeight(scroller);
      const bottom =
        getScrollTop(scroller) + scrollerHeight + buffer - this.toTop;

      let start;
      let end;
      if (!this.dynamic) {
        start = top <= 0 ? 0 : Math.floor(top / this.itemSize);
        end = bottom <= 0 ? 0 : Math.ceil(bottom / this.itemSize);
      } else {
        // 二分法计算可视范围内的开始的第一个内容
        let l = 0;
        let r = data.length - 1;
        let mid = 0;
        while (l <= r) {
          mid = Math.floor((l + r) / 2);
          const midVal = this.getItemOffsetTop(mid);
          if (midVal < top) {
            const midNextVal = this.getItemOffsetTop(mid + 1);
            if (midNextVal > top) break;
            l = mid + 1;
          } else {
            r = mid - 1;
          }
        }

        // 计算渲染内容的开始、结束索引
        start = mid;
        end = data.length - 1;
        for (let i = start + 1; i < data.length; i++) {
          const offsetTop = this.getItemOffsetTop(i);
          if (offsetTop >= bottom) {
            end = i;
            break;
          }
        }
      }

      // 开始索引始终保持偶数,如果为奇数,则加1使其保持偶数【确保表格行的偶数数一致,不会导致斑马纹乱序显示】
      if (start % 2) {
        start = start - 1;
      }
      this.top = top;
      this.bottom = bottom;
      this.start = start;
      this.end = end;
      this.renderData = data.slice(start, end + 1);
      this.$emit("change", this.renderData, this.start, this.end);
    },

    // 计算位置
    calcPosition() {
      const last = this.dataSource.length - 1;
      // 计算内容总高度
      const wrapHeight = this.getItemOffsetTop(last) + this.getItemSize(last);
      // 计算当前滚动位置需要撑起的高度
      const offsetTop = this.getItemOffsetTop(this.start);

      // 设置dom位置
      TableBodyClassNames.forEach((className) => {
        const el = this.$el.querySelector(className);
        if (!el) return;

        // 创建wrapEl、innerEl
        if (!el.wrapEl) {
          const wrapEl = document.createElement("div");
          const innerEl = document.createElement("div");
          // 此处设置display为'inline-block',是让div宽度等于表格的宽度,修复x轴滚动时右边固定列没有阴影的bug
          wrapEl.style.display = "inline-block";
          innerEl.style.display = "inline-block";
          wrapEl.appendChild(innerEl);
          innerEl.appendChild(el.children[0]);
          el.insertBefore(wrapEl, el.firstChild);
          el.wrapEl = wrapEl;
          el.innerEl = innerEl;
        }

        if (el.wrapEl) {
          // 设置高度
          el.wrapEl.style.height = wrapHeight + "px";
          // 设置transform撑起高度
          el.innerEl.style.transform = `translateY(${offsetTop}px)`;
          // 设置paddingTop撑起高度
          // el.innerEl.style.paddingTop = `${offsetTop}px`
        }
      });
    },

    // 获取某条数据offsetTop
    getItemOffsetTop(index) {
      if (!this.dynamic) {
        return this.itemSize * index;
      }

      const item = this.dataSource[index];
      if (item) {
        return this.offsetMap[item[this.keyProp]] || 0;
      }
      return 0;
    },

    // 获取某条数据的尺寸
    getItemSize(index) {
      if (index <= -1) return 0;
      const item = this.dataSource[index];
      if (item) {
        const key = item[this.keyProp];
        return this.sizes[key] || this.itemSize;
      }
      return this.itemSize;
    },

    // 【外部调用】更新
    update() {
      this.setToTop();
      this.handleScroll();
    },

    // 【外部调用】滚动到第几行
    // (不太精确:滚动到第n行时,如果周围的表格行计算出真实高度后会更新高度,导致内容坍塌或撑起)
    scrollTo(index, stop = false) {
      const item = this.dataSource[index];
      if (item && this.scroller) {
        this.updateSizes();
        this.calcRenderData();

        this.$nextTick(() => {
          const offsetTop = this.getItemOffsetTop(index);
          scrollToY(this.scroller, offsetTop);

          // 调用两次scrollTo,第一次滚动时,如果表格行初次渲染高度发生变化时,会导致滚动位置有偏差,此时需要第二次执行滚动,确保滚动位置无误
          if (!stop) {
            setTimeout(() => {
              this.scrollTo(index, true);
            }, 50);
          }
        });
      }
    },

    // 渲染全部数据
    renderAllData() {
      this.renderData = this.dataSource;
      this.$emit("change", this.dataSource, 0, this.dataSource.length - 1);

      this.$nextTick(() => {
        // 清除撑起的高度和位置
        TableBodyClassNames.forEach((className) => {
          const el = this.$el.querySelector(className);
          if (!el) return;

          if (el.wrapEl) {
            // 设置高度
            el.wrapEl.style.height = "auto";
            // 设置transform撑起高度
            el.innerEl.style.transform = `translateY(${0}px)`;
          }
        });
      });
    },

    // 执行update方法更新虚拟滚动,且每次nextTick只能执行一次【在数据大于100条开启虚拟滚动时,由于监听了data、virtualized会连续触发两次update方法:第一次update时,(updateSize)计算尺寸里的渲染数据(renderData)与表格行的dom是一一对应,之后会改变渲染数据(renderData)的值;而第二次执行update时,renderData改变了,而表格行dom未改变,导致renderData与dom不一一对应,从而位置计算错误,最终渲染的数据对应不上。因此使用每次nextTick只能执行一次来避免bug发生】
    doUpdate() {
      if (this.hasDoUpdate) return; // nextTick内已经执行过一次就不执行
      if (!this.scroller) return; // scroller不存在说明未初始化完成,不执行

      // 启动虚拟滚动的瞬间,需要暂时隐藏el-table__append-wrapper里的内容,不然会导致滚动位置一直到append的内容处
      this.isHideAppend = true;
      this.update();
      this.hasDoUpdate = true;
      this.$nextTick(() => {
        this.hasDoUpdate = false;
        this.isHideAppend = false;
      });
    },

    // 兼容多选:选择表格所有行
    onCheckAllRows(val) {
      val = this.isCheckedImn ? true : val;
      this.dataSource.forEach((row) => {
        if (row.$v_checked === val) return;

        this.$set(row, "$v_checked", val);
        this.$set(row, "$v_checkedOrder", val ? checkOrder++ : undefined);
      });
      this.isCheckedAll = val;
      this.isCheckedImn = false;
      this.emitSelectionChange();
      // 取消全选,则重置checkOrder
      if (val === false) checkOrder = 0;
    },

    // 兼容多选:选择表格某行
    onCheckRow(row, val) {
      if (row.$v_checked === val) return;

      this.$set(row, "$v_checked", val);
      this.$set(row, "$v_checkedOrder", val ? checkOrder++ : undefined);

      const checkedLen = this.dataSource.filter(
        (row) => row.$v_checked === true
      ).length;
      if (checkedLen === 0) {
        this.isCheckedAll = false;
        this.isCheckedImn = false;
      } else if (checkedLen === this.dataSource.length) {
        this.isCheckedAll = true;
        this.isCheckedImn = false;
      } else {
        this.isCheckedAll = false;
        this.isCheckedImn = true;
      }
      this.emitSelectionChange();
    },

    // 多选:兼容表格selection-change事件
    emitSelectionChange() {
      const selection = this.dataSource
        .filter((row) => row.$v_checked)
        .sort((a, b) => a.$v_checkedOrder - b.$v_checkedOrder);
      this.$emit("selection-change", selection);
    },

    // 多选:兼容表格toggleRowSelection方法
    toggleRowSelection(row, selected) {
      const val = typeof selected === "boolean" ? selected : !row.$v_checked;
      this.onCheckRow(row, val);
    },

    // 多选:兼容表格clearSelection方法
    clearSelection() {
      this.isCheckedImn = false;
      this.onCheckAllRows(false);
    },
  },
  watch: {
    dataSource() {
      if (!this.virtualized) {
        this.renderAllData();
      } else {
        this.doUpdate();
      }
    },
    virtualized: {
      immediate: true,
      handler(val) {
        if (!val) {
          this.renderAllData();
        } else {
          this.doUpdate();
        }
      },
    },
  },
  created() {
    this.$nextTick(() => {
      this.initData();
    });
  },
  mounted() {
    const appendEl = this.$refs.append;
    this.$el.querySelector(".ant-table-body").appendChild(appendEl);
  },
  beforeDestroy() {
    if (this.scroller) {
      this.scroller.removeEventListener("scroll", this.onScroll);
      window.removeEventListener("resize", this.onScroll);
    }
  },
};
</script>

<style lang="less"></style>

<think>嗯,用户问的是vxe-table卡顿的问题。首先我得回忆一下vxe-table这个库,它是一个基于Vue表格组件,功能挺强大的,支持很多复杂操作,比如大数据量、虚拟滚动、编辑等等。但有时候用起来可能会有性能问题,导致卡顿。那用户遇到卡顿,可能的原因是什么呢? 首先想到的是数据量过大。虽然vxe-table有虚拟滚动的功能,但如果数据量真的非常大,比如几万行,就算用虚拟滚动,渲染和操作也可能变慢。这时候可能需要分页或者动态加载数据。 然后是配置问题。vxe-table有很多配置项,比如复选框、行展开、复杂表头,这些功能如果同时开启,可能会增加渲染负担。特别是如果同时启用了多个特性,比如编辑、合并单元格等,可能会导致性能下降。 另外,自定义渲染也是一个可能的问题点。用户可能在表格里用了很多自定义的模板或者组件,比如复杂的DOM结构、图片、图表等,这些会增加每个单元格的渲染时间,从而影响整体性能。 还有,数据监听的问题。Vue是响应式的,如果表格数据过于庞大,或者有深层次的嵌套,Vue的响应式系统可能会需要处理太多的依赖追踪,导致卡顿。这时候可能需要用Object.freeze或者手动优化数据来减少响应式开销。 事件监听不当也可能导致问题。比如,如果给每个单元格绑定了大量的事件,或者频繁触发的事件(比如scroll),没有正确防抖的话,可能会导致频繁的重绘或重排,从而卡顿。 虚拟滚动配置不正确的话,即使启用了虚拟滚动,如果参数设置不合理,比如每屏渲染的行数过多,或者预估行高不准确,也会影响性能,导致滚动时卡顿。 还有,表格的重新渲染问题。当数据变化时,整个表格可能会重新渲染,这时候如果没有使用合适的key或者优化手段,会导致不必要的DOM操作,影响性能。 那解决方案的话,应该针对这些可能的原因逐一处理。比如优化数据量,合理分页;简化配置,按需加载功能;优化自定义渲染,减少复杂DOM;冻结数据减少响应式开销;合理使用事件防抖;调整虚拟滚动参数;避免不必要的重新渲染等。 另外,可能还需要检查是否有其他因素,比如浏览器性能问题,或者同时运行的其他脚本影响了整体性能。使用Chrome的Performance工具进行分析,找出具体的瓶颈在哪里,这样更有针对性。 最后,如果用户已经尝试了这些优化措施,但问题依然存在,可能需要联系vxe-table的官方支持,或者查看社区里有没有类似的问题和解决方案。可能有一些特定的使用场景需要特别的处理方式,或者库本身存在某些已知的问题,可以通过升级版本来解决。</think>以下针对vxe-table卡顿问题的分步排查与解决方案: --- ### 一、常见卡顿原因分析 1. **数据量过大** - 超过5000行数据时,即使开启虚拟滚动,DOM节点仍会占用较多内存 - 特殊场景:多级表头、单元格合并、行展开等复杂结构会加剧性能问题 2. **配置不合理** ```javascript // 错误示例:同时开启过多耗能特性 { border: true, showHeaderOverflow: true, showOverflow: true, keepSource: true, editConfig: { mode: 'cell' }, checkboxConfig: { checkField: 'checked' } } ``` 3. **自定义渲染滥用** - 在`cell-renderer`中使用复杂DOM结构 - 未优化的图片加载/图表渲染 - 频繁执行的计算属性 --- ### 二、针对性优化方案 #### 方案1:数据分片处理 ```javascript // 动态加载数据示例 { data: [], async loadData(page) { const res = await fetchData(page); this.data = this.data.concat(res.list); } } ``` - 推荐结合`pagination`分页器使用 - 虚拟滚动场景建议保持单页数据量 ≤ 5000条 #### 方案2:精简表格配置 ```javascript // 优化后的配置 { border: false, // 关闭边框线 resizable: false, // 禁用列宽调整 showOverflow: null, // 关闭溢出提示 editConfig: { mode: 'row', // 使用行编辑模式替代单元格编辑 activeMethod: () => !!editStatus // 控制可编辑状态 } } ``` #### 方案3:渲染优化技巧 ```vue <!-- 使用静态内容缓存 --> <vxe-column field="content" :formatter="formatter" :cell-render="{ name: 'MyCell', props: { static: true } }"> </vxe-column> <!-- 冻结非滚动区域 --> <vxe-table :scroll-y="{ enabled: true, gt: 50, oSize: 10 }> </vxe-table> ``` --- ### 三、进阶调试方法 1. **性能检测工具** - Chrome Performance面板录制操作过程 - 重点关注`Scripting`和`Rendering`耗时 2. **内存泄漏排查** ```javascript // 在组件销毁时手动清理 beforeDestroy() { this.$refs.xTable.destroy(); this.tableData = null; } ``` 3. **版本适配检查** - v3.x版本建议升级到v4+(性能提升30%+) - 注意ElementUI/ANTD等UI框架的版本兼容性 --- ### 四、特殊场景处理 **大数据量导出卡顿**: ```javascript // 使用Web Worker处理数据 const worker = new Worker('export.worker.js'); worker.postMessage(tableData); ``` **复杂单元格合并**: ```javascript // 预处理合并数据 mergeCells(data) { return data.map(item => ({ ...item, _span: this.calcSpan(item) // 预先计算合并规则 })); } ``` --- ### 五、官方推荐配置 ```javascript // 最佳实践配置模板 { keepSource: false, autoResize: true, showHeader: true, highlightHoverRow: false, scrollY: { enabled: true, gt: 100, oSize: 5, // 行高预估值 scrollToTopOnChange: true }, columnConfig: { useKey: true, // 启用列Key优化 resizable: false } } ``` --- **如问题仍未解决**: 1. 提供可复现的在线示例(推荐CodeSandbox) 2. 提交issue时附带性能分析截图 3. 考虑替代方案:`ag-grid`或`handsontable`(需评估功能需求)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值