Vue3 + Element-Plus 通用的表格合并功能【附源码】

背景

在做后台系统时,表格 合并单元格 几乎是高频需求:

  • 相同 type 的数据要合并
  • 相同 group 连续行要合并
  • 中间还夹着标题行
  • 某些列需要条件合并

很多人一上来就写一堆嵌套循环 + if 判断,最后逻辑混乱、难维护。

今天我给你一个可配置、可复用、强类型、支持特殊行的通用合并方案。

一、只需定义规则

js 复制代码
const mergeRules: MergedRules<TableItem> = [
  {
    col: 0,
    keys: ['type', 'group'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [1, 4];
      }
    },
  },
  {
    col: 1,
    keys: ['type', 'subType'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
  },
  {
    col: 2,
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
  },
  {
    col: 3,
    keys: ['type', 'group'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
    filter: (row) => row._canAddGroup,
  },
];

规则说明

每条规则控制一列:

字段 作用
col 第几列
keys 哪些字段相同才合并
getSpan 自定义特殊合并规则
filter 条件合并

二、规则类型设计

先看类型定义。

ts 复制代码
type BaseRule<T> = {
  col: number;
  keys?: (keyof T)[];
  getSpan?: (row: T) => [number, number] | void;
  filter?: (row: T) => boolean | void;
};

export type MergedRules<T> = BaseRule<T>[];

设计亮点

  1. keys 使用 (keyof T)[] ------ 强类型字段校验
  2. getSpan 返回 [rowspan, colspan]
  3. filter 支持条件合并
  4. 泛型 T 保证数据结构安全

三、数据结构

yaml 复制代码
export default [
  // 个人
  {
    title: '个人',
    type: 'Individual',
  },
  {
    type: 'Individual',
    subType: 'ID',
    name: '证件号',
    _addBtn: true,
    _canAddGroup: true,
    group: '1',
  },
  {
    type: 'Individual',
    subType: 'OtherInfo',
    name: '其它信息',
    _addBtn: true,
    _canAddGroup: true,
    group: '1',
  },
  {
    type: 'Individual',
    subType: 'ID',
    name: '证件号',
    _addBtn: true,
    _delBtn: true,
    _canAddGroup: true,
    group: '2',
  },
  {
    type: 'Individual',
    subType: 'OtherInfo',
    name: '其它信息',
    _addBtn: true,
    _delBtn: true,
    _canAddGroup: true,
    group: '2',
  },
  // 金融机构
  {
    title: '金融机构',
    type: 'FinOrg',
  },
  {
    type: 'FinOrg',
    subType: 'FinRemitter',
    name: '汇款行',
    _addBtn: true,
    group: '1',
  },
  {
    type: 'FinOrg',
    subType: 'FinRemitter',
    name: '汇款行',
    _addBtn: true,
    _delBtn: true,
    group: '1',
  },
  {
    type: 'FinOrg',
    subType: 'FinRecevier',
    name: '收款行',
    group: '1',
  },
];

四、核心算法实现

ts 复制代码
export default function computeMergedRows<T extends object>(
  data: T[],
  rules: MergedRules<T>
) {
  const rowSpanObj: Record<number, Record<number, [number, number]>> = {};

  // 初始化每列状态
  const state: State<T> = rules.map((rule) => ({
    ...rule,
    count: 0,
    start: null,
    prevRow: null,
  }));

  // 初始化 rowSpanObj
  rules.forEach((rule) => {
    rowSpanObj[rule.col] = {};
  });

  data.forEach((currRow, i) => {
    state.forEach((s) => {
      const colStore = rowSpanObj[s.col];
      if (!colStore) return;

      // 1️⃣ 特殊合并规则优先
      const customSpan = s.getSpan?.(currRow);
      if (customSpan) {
        if (s.count > 0 && s.start !== null) {
          colStore[s.start] = [s.count, 1];
        }

        colStore[i] = customSpan;

        // 重置状态
        s.count = 0;
        s.start = null;
        s.prevRow = null;
        return;
      }

      // 2️⃣ 常规合并逻辑
      if (!s.prevRow) {
        s.start = i;
        s.count = 1;
      } else {
        const isSame =
          s.keys && s.keys.length > 0
            ? s.keys.every((k) => currRow[k] === (s.prevRow as T)[k])
            : false;

        const filterPassed = s.filter ? s.filter(currRow) : true;

        if (isSame && filterPassed) {
          colStore[i] = [0, 0];
          s.count++;
        } else {
          if (s.start !== null) {
            colStore[s.start] = [s.count, 1];
          }
          s.start = i;
          s.count = 1;
        }
      }

      s.prevRow = currRow;
    });
  });

  // 3️⃣ 处理最后遗留分组
  state.forEach((s) => {
    if (s.count > 0 && s.start !== null) {
      rowSpanObj[s.col]![s.start] = [s.count, 1];
    }
  });

  return rowSpanObj;
}

五、算法设计思路解析

整个算法核心是:

为每一列维护一个状态机

1️⃣ 每列独立维护状态

ts 复制代码
type State<T> = Array<
  BaseRule<T> & {
    count: number;
    start: number | null;
    prevRow: T | null;
  }
>;

每一列都会维护:

状态 含义
start 当前合并组起始行
count 当前组行数
prevRow 上一行数据

这使得:

  • 每列逻辑互不影响
  • 可以自由扩展规则
  • 支持不同列不同合并逻辑

2️⃣ 优先处理特殊行

ts 复制代码
const customSpan = s.getSpan?.(currRow);

为什么要优先处理?

因为像"标题行"这种情况:

  • 它不参与普通比较
  • 它会打断前一组合并
  • 它会强制占据固定 span

所以:

  1. 先结算上一组
  2. 记录当前特殊 span
  3. 重置状态

这就是关键。

3️⃣ 常规合并逻辑

核心判断:

ts 复制代码
const isSame = s.keys?.every(...)

只要:

  • keys 全部相等
  • filter 条件满足

就:

ts 复制代码
colStore[i] = [0, 0];

否则:

  • 结算上一组
  • 开始新组

4️⃣ 为什么返回 rowSpanObj 结构?

ts 复制代码
{
  colIndex: {
    rowIndex: [rowspan, colspan]
  }
}

这种结构刚好可以用于 Element Plus:

ts 复制代码
const arraySpanMethod = ({ rowIndex, columnIndex }) => {
  return rowSpanObj[columnIndex]?.[rowIndex] ?? [1, 1];
};

六、总结

核心思想其实只有一句话:

用"状态机"去驱动每一列的合并行为。

你不需要在模板里写一堆 if。 也不需要在 span-method 里疯狂判断。

只要定义规则。

源码地址:

github.com/zm8/wechat-...

相关推荐
山雀~14 分钟前
CSP 安全漏洞修复
前端·网络协议
陌离1 小时前
Go-Gui:一套 无限接近 现代化UI的 GPU绘制界面组件框架
前端
咚咚咚小柒1 小时前
【前端】LangChain.js学习
前端·javascript·人工智能·typescript·langchain·vue·ai编程
2401_885885041 小时前
巴林语音通知接口如何对接?系统告警语音API接入教程
android·前端·网络·python·语音识别
java1234_小锋1 小时前
【技术专题】CSS3 网页技术 - CSS3 盒模型
前端·css·css3
计算机魔术师1 小时前
手握芯片、又要吞下开源分发入口,英伟达被盯上了
前端
段一凡-华北理工大学2 小时前
高炉能源系统智能化:热风炉、煤气与极致能效~系列文章07:高炉煤气:产生规律与全厂用户平衡
java·前端·能源
前端架构师-老李3 小时前
HTML与CSS基础第四课—strong、em、b、i 各表示什么
前端·css·html
king王一帅3 小时前
AI 时代一群人在卷笔记软件,我想先把 Markdown 编辑器的底层做好
前端·ai编程·markdown
用户11481867894843 小时前
盲水印:DCT-QIM 从原理到踩坑
前端