封装一款通用的表格合并工具

801 阅读14分钟

需求分析

起初是这样的:我们的业务核心可以分成三块(A、B、C),而它们存在层级关系,就比如省市区(县)这样。所以当两个或两个以上业务模块数据需要在一起展示(其实就是在同一个表格中)时,这种父级数据单元格合并的需求就比较多。就比如这样:

区(县)
广东省深圳市宝安区
广东省深圳市南山区
广东省深圳市福田区
广东省广州市天河区
湖南省长沙市岳麓区
湖南省湘潭市岳塘区
。。。。。。。。。

我们很容易就提出这样一个需求:把相同的省份合并一下呗,相同的市也要。因为这样子看起来才比较舒服,数据更加直观。

抽象一下就是:当存在层级关系的数据需要放在一起展示时,我们很自然的就有了父级相同数据合并展示的需求,因为这样的UI更方便用户直观地感受数据间的所属依赖关系

而正因为我们的三大核心模块存在层级关系,所以当它们需要一起展示的时候,这种合并的需求就特别多,不仅涉及多个页面,也涉及多个xlsx导出。所以,花一点时间去做一个通用的表格合并工具对于我们来说性价比是非常高的。

思路分析

第一步:确定接口,编写 matrix 辅助工具

首先我们知道我们的通用表格合并需要用在页面(antd table)和 xlsx 导出,而它们接收的数据格式是不一样的,比如 antd table 接收的是 columns + dataSource,而xlsx导出接收的是一个二维数组 + merges。

那其实就是一个跨平台渲染的需求,对于跨平台,我们一般有两种开发思路:

  • 其一:先在一个平台上开发好,再在其他平台上做适配(比如先在 windows 下开发完测好没问题,再编写对应的 Linux 版适配代码)。
  • 其二:先定义好接口,然后在不同平台上做实现。

第一种情况是复杂陌生不好抽象的领域下的一种探索式开发;而第二种则适用于简单、熟悉、有规律可循的领域。

我们肯定是选择第二种。首先表格数据的最小粒度是每一个单元格,那单元格数据的承载其实很简单,一个变量就够了。但是光有数据显然是不够的,还缺少合并信息。那么怎么表示单元格的合并信息呢?其实也好说,就是再存储两个变量:rowSpan 和 colSpan,也就是保存这个单元格占了几行几列的信息。所以很自然地我们就设计出了以下这个接口:

export default class Cell {
  public value: any
  public rowSpan?: number
  public colSpan?: number
  constructor(value: any, rowSpan: number = 1, colSpan: number = 1) {
    if (value instanceof Cell) {
      return new Cell(value.value, value.rowSpan, value.colSpan)
    }
    this.value = value
    this.rowSpan = rowSpan
    this.colSpan = colSpan
  }
}

我们知道表格是二维的,但是表格数据不一定是二维数组这种数据结构,比如dataSource,它是一个数组对象。而其实我们知道,对于二维表格的数据操作,比如存取,遍历等,使用二维数组这种数据结构,我们编写的算法是最简单的。所以我们先做一步适配工作,也就是做一个二维表格数据操作工具,屏蔽二维数据与数组对象的差异。代码如下:

import { isObject } from "./utils"

interface IndexMap {
  x?: Array<string>
  y?: Array<string>
}

// 遍历方式:行优先或者列优先
export const FOREACH_MODE = {
  ROW: 1,
  COLUMN: 2
}
/**
 * 虚拟二维数组
 *
 * 我们知道一般的表格都可以使用二维坐标来表示(行列),但是我们知道表格的数据一般是一个对象数组([{..}, {...}, {...}, ...])。
 * 所以我们希望封装一个 util,实现我们可以使用行列坐标来访问表格数据。除此之外,我们的方法更加通用,支持以下这两种类型:
 * 1. 对象数组:[{a: 1, b: 2}, {a: 3, b: 4}, {a: 5, b: 6}, ...]
 * 2. 二维对象:{ x: {a: 1, b: 2}, y: {a: 3, b: 4}, z: {a: 5, b: 6}, ...}
 */
export default class Matrix {
  public data: any = []
  public x: Array<string> = []
  public y: Array<string> = []
  public indexMap: IndexMap = {}
  constructor(data: any, options = {}) {
    if (data instanceof Matrix) {
      return data
    }
    this.data = data
    this.indexMap = (options as any).indexMap
    const { x, y } = this.init()
    this.x = x
    this.y = y
  }

  init() {
    const { data, indexMap: { x: xIndex = [], y: yIndex = [] } = {} } = this
    if (!isObject(data)) {
      throw new Error("传入数据不符合要求")
    }
    const hasObject = Object.values(data).some((v) => isObject(v))
    if (!hasObject) {
      throw new Error("传入数据不符合要求")
    }

    let x = Object.keys(data)
    const values: any = Object.values(data).reduce((pre: any, v: any) => ({ ...pre, ...v }), {})
    let y = Object.keys(values)

    // 如果配置了xIndex、yIndex的顺序,那么就调整其顺序
    // 设计这一层的目的主要是因为keys的结果是无序的,所以对于对象的情况我们有时候遍历时对于属性有顺序控制的需求
    if (xIndex.length) {
      // 这样就可以支持部分有序,部分无序的情况
      x = [...xIndex, ...x.filter((v) => !xIndex.includes(v))]
    }
    if (yIndex.length) {
      y = [...yIndex, ...y.filter((v) => !yIndex.includes(v))]
    }
    return { x, y }
  }

  get length() {
    return [this.x.length, this.y.length]
  }

  getData() {
    return this.data
  }

  // 将虚拟游标转化成真实游标
  getXKey(i: number) {
    return this.x[i]
  }
  getYKey(j: number) {
    return this.y[j]
  }

  // 这里有两种情况,可能传入的是虚拟游标(index),也可能传入的是真实游标
  // 对于数组虚拟游标就是真实游标;而对于对象,虚拟游标是index,真实游标是key
  getItem(_i: string | number, _j: string | number) {
    // 如果转成number变成了NaN,那么说明传入的是真实游标;否则需要将虚拟游标转化成真实游标
    const i = Number.isNaN(Number(_i)) ? _i : this.x[Number(_i)]
    const j = Number.isNaN(Number(_j)) ? _j : this.y[Number(_j)]
    return this.data[i]?.[j]
  }

  setItem(_i: string | number, _j: string | number, value: any) {
    const i = Number.isNaN(Number(_i)) ? _i : this.x[Number(_i)]
    const j = Number.isNaN(Number(_j)) ? _j : this.y[Number(_j)]
    const inner = this.data[i]

    // 因为前面为了兼容forEach,允许了第二层不是对象的数据传进来运行;所以这里赋值可能会报错
    if (isObject(inner)) {
      inner[j] = value
    }
  }

  forEach(
    fn: (item: any, index: Array<number>, realIndex: Array<number | string>, data: any) => void,
    mode = FOREACH_MODE.ROW
  ) {
    const { x, y, data } = this
    const xLen = x.length
    const yLen = y.length
    const iLen = mode === FOREACH_MODE.ROW ? xLen : yLen
    const jLen = mode === FOREACH_MODE.ROW ? yLen : xLen
    // ROW:先遍历完第一行再遍历第二行,以此类推
    // COLUMN:先遍历第一列,再遍历第二列,以此类推
    for (let _i = 0; _i < iLen; _i++) {
      for (let _j = 0; _j < jLen; _j++) {
        let item
        if (mode === FOREACH_MODE.ROW) {
          item = this.getItem(_i, _j)
        } else {
          item = this.getItem(_j, _i)
        }
        const index = mode === FOREACH_MODE.ROW ? [_i, _j] : [_j, _i]
        const realIndex = mode === FOREACH_MODE.ROW ? [x[_i], y[_j]] : [y[_j], x[_i]]
        fn(item, index, realIndex, data)
      }
    }
  }
}

第二步:拆解实现步骤,确定核心算法

其实这个需求的实现我们可以拆成核心的两步:先排序,再合并。有的朋友可能会问,为啥要先排序呢?

其实排序的工作主要是将相同的父类聚在一起,这样再进行合并时,合并算法就十分简单了,就是一个遍历+统计的事。但如果是乱序的情况下,合并算法会十分复杂,至少我还没有想到怎么去实现,哈哈哈。由于排序算法比较复杂,而且优化版的涉及到动态规划,我打算等下抽一个标题出来详细讲一下,这里就先跳过。

经过前面的排序算法将数据准备好之后,合并方法就是十分简单了,就是遍历加统计。我就直接贴代码了:

import Matrix, { FOREACH_MODE } from "./matrix"
import { ColumnItem } from "./type"
import Cell from "./cell"

// 默认使用Object.is判断两个数值是否相等,进而确定是否需要进行合并
// 不过表格中基本上都是 string 和 number 类型的数据,基本判断相等就可以了.
const isEqual = (a: any, b: any) => Object.is(a, b)

/**
 * 基本的算法思路是这样的(目前只实现行合并,如果后面有列合并的需求,稍加改造即可实现):
 * 1、通过排序(group),可以将相同的元素紧挨在一起
 * 2、再通过从上到下,一列一列的遍历,就可以统计到相同元素的在某一列上的个数(使用累加的方式)
 * @param {Array} list
 */
export default (list: Array<object>, columns: Array<ColumnItem>, isEqualFn = isEqual) => {
  // 由于列的排列顺序不同会影响合并的结果,所以这里需要将columns信息传进来
  // isMerge 字段控制每个字段是否需要合并,默认是需要合并
  const yIndexMap = columns.filter((item) => !(item.isMerge === false)).map((item) => item.dataIndex)
  // 把我们的数据转化成二维数组
  const matrix = new Matrix(list, { indexMap: { y: yIndexMap } })
  const [xLen, yLen] = matrix.length
  const dp = Array.from(Array(xLen), () => Array(yLen)) // 用于记录相同情况

  // 动态规划,遍历填表,累积状态
  matrix.forEach((value, [i, j]) => {
    // 接收的虚拟游标
    // 状态转移方程
    const topValue = matrix.getItem(i - 1, j)
    const leftSomeSum = dp[i][j - 1] ? dp[i][j - 1] : 1
    const topSomeSum = dp[i - 1]?.[j] ? dp[i - 1][j] : 1
    // 考虑边界
    if (i === 0) {
      // 第一行
      dp[i][j] = 1
      return
    } else if (j === 0 && isEqualFn(topValue, value)) {
      // 第一列
      dp[i][j] = topSomeSum + 1
      return
    }

    // 一般情况
    if (isEqualFn(topValue, value) && leftSomeSum >= topSomeSum + 1) {
      dp[i][j] = topSomeSum + 1
    } else {
      dp[i][j] = 1
    }
  })

  // 整理someMatrix
  const someMatrix = new Matrix(dp)
  someMatrix.forEach((value, [i, j]) => {
    if (value > 1) {
      // 需要将上一项合并
      someMatrix.setItem(i - 1, j, 0)
    }
  }, FOREACH_MODE.COLUMN)

  // 把合并之后的结果生成我们的合并数据
  someMatrix.forEach((value, [i, j]) => {
    const rowSpan = value
    const colSpan = 1
    // 使用我们这种带有合并信息的数据去包装原数据
    const cell = new Cell(matrix.getItem(i, j), rowSpan, colSpan)
    matrix.setItem(i, j, cell)
  })

  return matrix.getData()
}

第三步:编写适配器

我们知道 antd table 的 columns 每一项支持一个 render 方法(antd@3.x),通过这个 render 方法我们可以返回 rowSpan 和colSpan 来控制单元格的合并。所以对于antd table的适配器来说,需要给render加一层代理,插入我们的合并信息。代码如下:

import getMergeList from "../core"
import { suffixToPrefix } from "../core/utils"

/**
 *  这一块内容与table进行一个强绑定,负责将合并信息渲染到table视图上
 */
export default (dataSource: Array<object>, columns: Array<any>) => {
  dataSource = getMergeList(
    dataSource,
    columns.map((v: any) => ({ dataIndex: v.dataIndex, isMerge: v.isMerge }))
  )
  dataSource = suffixToPrefix(dataSource)

  // 解析渲染合并信息
  columns = columns.map((v: any) => {
    return {
      ...v,
      render(text: any, record: any, index: number) {
        let { value, rowSpan, colSpan } = text || {}
        const children = v.render?.(value, record, index) || value
        return {
          children,
          props: { rowSpan, colSpan }
        }
      }
    }
  }) as any

  return {
    columns,
    dataSource
  }
}

而xlsx则是需要将合并信息转化收集到merges中,代码如下:

import getMergeList from "../core"
import Matrix from "../core/matrix"
import { isArray, suffixToPrefix } from "../core/utils"

interface MergeItem {
  s: { r: number; c: number }
  e: { r: number; c: number }
}

export default function (data: Array<object>, columns: Array<any>) {
  data = getMergeList(
    data,
    columns.map((v: any) => ({ dataIndex: v.dataIndex, isMerge: v.isMerge }))
  )
  data = suffixToPrefix(data)
  const header = columns.map((v: any) => v.title)
  const body: Array<Array<any>> = []
  let merges: Array<MergeItem> = []
  const yIndexMap = columns.map((item) => item.dataIndex)
  const matrix = new Matrix(data, { indexMap: { y: yIndexMap } })
  matrix.forEach((v: any, [i, j]) => {
    if (!isArray(body[i])) {
      body[i] = []
    }
    body[i][j] = v.value
    if (v.rowSpan > 1) {
      // 需要合并的行
      merges.push({ s: { r: i, c: j }, e: { r: i + (v.rowSpan - 1), c: j } })
    }
    if (v.rowSpan === 0) {
      body[i][j] = ""
    }
  })

  // 由于表头的缘故,需要整体下移一格
  merges = merges.map((v) => {
    return {
      s: { ...v.s, r: v.s.r + 1 },
      e: { ...v.e, r: v.e.r + 1 }
    }
  })

  return {
    data: [header, ...body],
    merges
  }
}

排序算法

其实排序算法的大体思路也好说,就先排省份、再在省份小组内对市进行排序,再在市小组内对区(县)进行排序,以此类推。

使用 lodash

使用 lodash 的 sortBy 方法实现如下:

image.png

动态规划

首先我们借鉴选择排序的思想,先排序号,再根据序号去调整元素位置,所以我们思路的出发点就是序号分配

const data = [
    ["广东省", "深圳市", "宝安区"],
    ["湖南省", "湘潭市", "岳塘区"],
    ["广东省", "广州市", "天河区"],
    ["广东省", "深圳市", "福田区"],
    ["湖南省", "长沙市", "岳麓区"],
    ["广东省", "深圳市", "南山区"],
];

首先我们肯定是一列一列地遍历,然后从左到右依次遍历每一列。我们可以先构建一个 map,用于存储在某一列中,不同种类的 value 的统计数量数量,比如第一列遍历结束后,可以得到 colValueCountMap = { "广东省": 4, "湖南省": 2 }。根据这个信息,我们就可以得到第一列的 dp 数据:

const data = [
    [0, "深圳市", "宝安区"],
    [4, "湘潭市", "岳塘区"],
    [0, "广州市", "天河区"],
    [0, "深圳市", "福田区"],
    [4, "长沙市", "岳麓区"],
    [0, "深圳市", "南山区"],
];

在第二列的遍历时,我们可以得到 colValueCountMap = { "深圳市": 3, "湘潭市": 1, "长沙市": 1, "广州市": 1 },然后根据这个信息,我们可以得到第二列的 dp 数据:

const data = [
    [0, 0, "宝安区"],
    [4, 4, "岳塘区"],
    [0, 3, "天河区"],
    [0, 0, "福田区"],
    [4, 5, "岳麓区"],
    [0, 0, "南山区"],
];

我们以湖南为例,我们这里统计到的湖南有 colValueCountMap = { "湘潭市": 1, "长沙市": 1 },再结合前面湖南的起始序号是 4,所以我们分配给湘潭的序号是 4,长沙是 5。

它们的起点都是 4,这是第一列(省份)累积的结果。而第二列在 4 的基础上得到了 4(湘潭的序号) 和 5(长沙的序号),这是市结合省累积的结果。接下来再在市的序号下去划分区(县)即可。

所以最后一列的 dp 结果如下:

const data = [
    [0, 0, 0],
    [4, 4, 4],
    [0, 3, 3],
    [0, 0, 1],
    [4, 5, 5],
    [0, 0, 2],
];

这样我们再根据最后一列 [0, 4, 3, 1, 5, 2] 的结果,去调整元素位置即可。完整代码如下:

import { isArray } from "./utils"
import Matrix, { FOREACH_MODE } from "./matrix"
import { ColumnItem } from "./type"

/**
 * 对一个对象数组使用动态规划进行快速的分组
 * 该算法的核心就是找到当前这一列在分组后的index,有了这个信息后,我们最后只需要将这一列移动到对应的index,便可完成分组
 * 所以这里有两个很重要的概念:原始游标、分组游标
 * @param {Array} list
 */
export default (list: Array<object>, columns: Array<ColumnItem> = []) => {
  if (!isArray(list)) {
    throw new Error("list must be an array")
  }
  // 由于我们做的就是合并功能,所以我们默认进行合并,只有配置false才不合并
  const yIndexMap = columns.filter((item) => !(item.isMerge === false)).map((item) => item.dataIndex)
  const matrix = new Matrix(list, { indexMap: { y: yIndexMap } })
  const [xLen, yLen] = matrix.length
  const dp = Array.from(Array(xLen), () => Array(yLen))
  const colValueCountMap = new Map()

  /**
   * 这里就是 group 算法的核心,该算法的核心思想是这样的(其实就是分配序号法):
   * 举个例子:[[1, 2, 3], [1, 1, 4], [2, 1, 2]]
   * 第一列排序的结果是:[0, 0, 2],这个结果表示 [2, 1, 2] 这条数据的起点是 2。
   * 第二列排序的结果是:[0, 1, 2],当 [1, 2, 3] 中的 2 与 [1, 1, 4] 中的 1 进行比较时,发现两者不一样,此时 1 的序号还需要参考前面的 1,也就是 0 + 1。
   * 依次类推,等到最后一列排序结果被计算出来时,那么合并情况下的序号就基本上确定了(当然,需要注意处理重复的情况)。
   */
  matrix.forEach((value, [i, j]) => {
    // 先考虑边界
    if (j === 0) {
      if (!colValueCountMap.has(value)) {
        colValueCountMap.set(value, 0)
      }
      colValueCountMap.set(value, colValueCountMap.get(value) + 1)

      // 第一列遍历到了末尾(取出之前的index,加上数量信息)
      if (i === xLen - 1) {
        let count = 0
        const groupIndexMap = new Map()

        ;[...colValueCountMap.entries()].forEach(([value, _count]) => {
          const key = value
          if (!groupIndexMap.has(key)) {
            groupIndexMap.set(key, count)
            count += _count
          }
        })

        for (let _i = 0; _i < xLen; _i++) {
          const key = matrix.getItem(_i, j)
          dp[_i][j] = groupIndexMap.get(key)
        }

        // 清除中间变量
        colValueCountMap.clear()
      }
    } else {
      // 以前一列的key作为基础(这里面的虚拟index也具有key的作用)
      const preIndex = dp[i][j - 1]
      if (!colValueCountMap.has(preIndex)) {
        colValueCountMap.set(preIndex, new Map())
      }
      const innerGroupCountMap = colValueCountMap.get(preIndex)
      if (!innerGroupCountMap.has(value)) {
        innerGroupCountMap.set(value, 0)
      }
      innerGroupCountMap.set(value, innerGroupCountMap.get(value) + 1)

      // 遍历到了末尾
      if (i === xLen - 1) {
        const groupIndexMap = new Map()
        // 将count累积起来
        ;[...colValueCountMap.entries()].forEach(([preIndex, innerMap]) => {
          if (!groupIndexMap.has(preIndex)) {
            groupIndexMap.set(preIndex, {
              map: new Map(),
              count: 0
            })
          }
          ;[...innerMap.entries()].forEach(([item, count]) => {
            const { map, count: _count } = groupIndexMap.get(preIndex)
            if (!map.has(item)) {
              map.set(item, _count)
              groupIndexMap.set(preIndex, { map, count: _count + count })
            }
          })
        })

        for (let _i = 0; _i < xLen; _i++) {
          const preIndex = dp[_i][j - 1]
          const item = matrix.getItem(_i, j)
          dp[_i][j] = preIndex + groupIndexMap.get(preIndex).map.get(item)
        }

        // 清除中间变量
        colValueCountMap.clear()
      }
    }
  }, FOREACH_MODE.COLUMN)

  // 根据分组index调整顺序
  const result: any = isArray(list) ? [] : {}
  const indexSet = new Set()
  for (let i = 0; i < xLen; i++) {
    const item = list[i]
    let index = dp[i][yLen - 1]

    // 直到找到一个不存在,解决冲突
    while (indexSet.has(index)) {
      index++
    }

    indexSet.add(index)
    result[index] = item
  }

  return result
}

总结

其实动态规划算法说难,确实有些难理解,但其实说简单,也可以说简单,就是遍历,积累,记录结果,表一填完结果就出来了。利用动态规划算法解决问题的核心就是怎么去设计数据表示(数据结构),以及确定好结果的积累方式

写在最后

由于本人水平有限,那么动态规划算法还是没法讲的特别浅显易懂,只是讲了一下大概过程以及提供了一下中间运行结果,希望大家见谅。最后下面是代码仓库地址以及npm包地址,如果觉得有帮助,欢迎star。