R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。 MUGer, hacker, developer, amateur UI designer, punster, Japanese learner.

当表格排版遇到了合并单元格

我负责的面单平台的需求复杂度已经越来越高了。自从寒假接了一个复杂表格排版的需求后,各类业务对于表格排版的使用场景也开始变多。毕竟,一个可以自动换页排版,并保持每一页都带有首部区域和尾部区域的表格,哪个业务不喜欢呢?(被打)

表格的排版

先回顾一下之前的需求:

  1. 有一类专门的面单类型叫“拣货单”,用于给卖家拣货
  2. 拣货单有三个部分:头部区域(展示 Logo、买家地址等)、表格(展示商品信息)、尾部区域(展示备注和页码)
  3. 表格可能非常长,需要分页打印(纸张类型分为 A4、A5、A6 三种)
  4. 第一页的表格之前一定会有一个头部区域
  5. 每一页的表格之后都要接一个尾部区域,可以配置非第一页的表格之前是否也接一个头部区域
  6. 表格的数据内容未知,列宽、字体、字号均可以自定义,行高不确定

大概的效果嘛,类似于下图(A6,横向,非第一页的表格之前也接一个头部区域):

可以用来画拣货单的表格

由于这种排版方式已经超出了 Chrome 的能力,因此与后端商量了一下,决定将一些排版代码注入到生成的 HTML 面单中,然后在服务器上用 Chrome Headless 运行,将最终的结果打印为 PDF。

这种排版代码写起来还是比较开心(划掉)的,因为我跟业务之间达成了一些约定:页面的布局固定,头部区域和尾部区域可以自由发挥(加起来别超过页面高度就行),表格只能用配置的方式生成而非拖拽组件。这样我就可以一行一行来计算高度了,甚至对于后续巨大数据量的排版,我也做了一定的优化(参见 这篇文章)。

合并单元格

接下来就是这个新需求了。使用代码合并单元格一直是个非常复杂的问题,不管是原生 HTML 还是各类组件库。我觉得对于我目前的能力来说,一个人短时间内写出一个支持任意合并单元格的表格(而且是在数据未知、需要手写分页的排版代码的情况下)不太现实,因此我仔细研究了一下需求,发现可以做一些合理的简化。

首先,HTML 中合并单元格的做法通常是使用 <table> 元素,如果想将一个单元格 A 与它上方的单元格 B 合并,就为 B 设置 rowspan,然后删掉 A,就像下面这样:

<table>
    <thead>
        <tr>
            <th>1</th>
            <th>2</th>
            <th>3</th>
            <th>4</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td rowspan="3">合并-1</td>
            <td>拆分-2</td>
            <td>拆分-3</td>
            <td rowspan="3">合并-4</td>
        </tr>
        <tr>
            <td>拆分-2</td>
            <td rowspan="2">小合并-3</td>
        </tr>
        <tr>
            <td>拆分-2</td>
        </tr>
    </tbody>
</table>

效果是这样的:

1 2 3 4
合并-1 拆分-2 拆分-3 合并-4
拆分-2 小合并-3
拆分-2

可以认为,<tbody> 中有三行,第一行是一个完整行,因为它有着跟表头一样完整的四列;第二行是一个非整行,它只有中间的两列,首尾两列为了跟上方的单元格合并,而被删掉了;第三行只有一列(2),其它三列都被合并掉了。同理,如果要跟左方的单元格合并,则需要为左方的单元格设置 colspan 并删掉当前的单元格。

其次,需求中描述的“合并单元格”其实长得是这样:

分组名 分组图片 元素名 元素属性
组 1 [图片 1] 元素 1-1
元素 1-2
元素 1-3
组 2 [图片 2] 元素 2-1
元素 2-2

前两列是“大格”,后两列是“小格”,看起来也很工整,是不是一看就很好处理!于是经过与需求方的沟通,我们最终做了如下的约定:

  1. 表格的第一条数据一定是一个完整行(毕竟第一行的单元格没有“上方元素”)
  2. 所有的非整行,缺失列的 Key 一定是完全相同的(“小格”列中的单元格不会被合并)
  3. 单元格高度不超过一页减掉头尾部区域的高度(无需考虑拆分单元格内部的内容)
  4. 不会有横向的合并操作(可以简化 rowspan 的计算,也无需计算 colspan

在这种约定下,“合并单元格”的需求被极大的简化了,变得不再那么难实现了。

用于计算 rowspan 的简单算法

我们与需求方约定:我们可以为表格组件设置一个“Allow empty cells merging up”的选项(默认关闭,需手动开启),如果开启,那么他们传数据过来时,所有的空字符串("")或缺失的字段对应的单元格将会被向上合并。对于上面那个工整的表格来说,需求方应当这样传数据:

{
    "table_data": [
        // 第一个整行
        {
            "group_name": "组 1",
            "group_image": "[图片 1]",
            "item_name": "元素 1-1",
            "item_attr": "高"
        },
        {
            "item_name": "元素 1-2",
            "item_attr": "中"
        },
        {
            "item_name": "元素 1-3",
            "item_attr": "低"
        },
        // 第二个整行
        {
            "group_name": "组 2",
            "group_image": "[图片 2]",
            "item_name": "元素 2-1",
            "item_attr": "高"
        },
        {
            "item_name": "元素 2-2",
            "item_attr": "中"
        },
    ]
}

虽然通过后端的渲染之后我们无法拿到原始的 JSON 数据,但可以通过对表格做一个简单的遍历来实现相同的功能:

// 假设我们已经获取到了 `table`、`tbody` 等元素,并存到了同名的变量中

// 表格只有一行,无需合并
if (tbody.childElementCount <= 1) {
    return;
}

// 第一行(一定是整行)没有元素,无需合并
const firstLine = tbody.firstElementChild;
if (firstLine.childElementCount <= 0) {
    return;
}

// `lastTds` 用于方便的查找每一列当前的最后一个元素,便于设置 `rowspan`
const lastTds = [...firstLine.children];
// `lastRowSpans` 则是这些元素的 `rowspan` 值
const lastRowSpans = Array(firstLine.childElementCount).fill(1);

// 对于表格的每一行
for (
    let tr = firstLine.nextElementSibling;
    tr.nextElementSibling;
    tr = tr.nextElementSibling
) {
    // 枚举每一列,看是否有单元格需要被向上合并
    for (
        let td = tr.firstElementChild, i = 0, next = (td || {}).nextElementSibling;
        td;
        td = next, i++, next = (td || {}).nextElementSibling
    ) {
        if (
            // 这个单元格完全是空的,显然要被合并
            !td.firstElementChild.innerHTML
            || (
                // 目前每个单元格里面要么是纯文字,要么是一个 <img>,取决于该列的数据格式
                // 如果是 <img>,那么需求方应当传一个 URL 或者 Base64 字符串过来
                // 如果该列的数据格式配置为“图片”,且数据为空,那么后端会渲染一个 src 为空的图片
                // 所以只要出现 if 的这种情况,也可以认为这个单元格是空的,可以向上合并
                td.firstElementChild.firstElementChild
                && td.firstElementChild.firstElementChild.tagName === 'IMG'
                && td.firstElementChild.firstElementChild.getAttribute('src') === ''
            )
        ) {
            // 将当前列的最后一个元素(不一定是跟当前单元格紧挨着的那个单元格)的 rowspan += 1
            lastRowSpans[i]++;
            lastTds[i].setAttribute('rowspan', lastRowSpans[i]);
            // 将当前的单元格删除
            tr.removeChild(td);
        } else {
            // 如果不需要合并,那么当前列的最后一个元素就是当前单元格,并且 rowspan = 1
            lastRowSpans[i] = 1;
            td.setAttribute('data-index', i);
            lastTds[i] = td;
        }
    }
}

修改之前的分页排版算法

如果没有分页功能的话,那么这个需求已经完成了,但是事情就是没有那么简单……

很快我就发现:如果还跟之前一样,为了不拆单元格中的内容,强制当空间不够时将下一行直接扔到下一页,势必会造成极大的空间浪费,因为有可能“大格”里面的内容并不多,但每个“大格”对应的“小格”特别多,这样一个“大格”的高度很有可能超过一页,并且这在业务上是绝对无法避免的:

一种无法避免的需要拆分单元格的场景

不过有一个值得注意的地方——业务要求的“大格”里面的内容确实不算多,一般就是一个商品名称或者图片,内容本身不会超过一页。如果我可以将这个单元格拆成有内容和没内容两部分,也能在一定程度上缓解空间浪费的问题。

之前对的排版思路是这样的:

  1. 令当前高度 current = headAreaHeight + thHeight
  2. 从头开始枚举每一行

    1. 获取当前行的高度 height = tr.offsetHeight
    2. 如果 current + height + tailAreaHeight 大于一页高度:

      1. 在当前页的最后插入一个尾部区域
      2. 将表头和当前行放到新页,更新 current
    3. 否则,将当前行插入到当前页的最后,并更新 current

需要改动的地方有两个。

重新定义“当前行”

如果依旧按照以前那样直接获取 tr.offsetHeight,可能会在这种组合场景下出现问题:

  • 当前行中有一个“大格”,高度介于一行和两行“小格”高度之间
  • 当前页可以放下当前行,但无法放下两行“小格”的高度

如果出现了这种场景,那么我是在当前页放一行“小格”还是强行放两行呢?如果是放一行,那么这个“小格”会被纵向拉伸以适应“大格”高度,不是很美观;强行放两行又可能会把尾部区域挤到下一页。

最终我决定用这样的方法:如果当前行包含“大格”,说明一定是整行,那么我先找到所有“大格”中内容高度最高的那个,假设高度为 x,接下来从当前行往下找非整行,根据之前的约定,在遍历到下一个整行之前,我一定能找到某个非整行,从当前行到它的所有行,加起来的高度恰好大于等于 x。如果是大于,说明“大格”的内容确实不多;如果是等于,说明“大格”的内容比较多,甚至会把“小格”撑开。

当前行高度的计算方法

在上图中,左边表格中的“大格”内容很少,于是我们先取红框的高度,再从上往下扫,看有多少“小格”加起来高度恰好比它高,答案是蓝框的部分,于是我们就用蓝框的高度作为当前行的高度;右边表格中的“大格”内容很多,按照这种方法蓝框会取到所有的三个“小格”,也符合预期。

如果按照这个方法计算出的“当前行高度”加上尾部区域高度已经无法放到当前页,我们就按照之前的思路,先在当前页插入一个尾部区域,然后将当前行以及蓝框中的“小格”所在的行一起放到下一页,并令 current 等于蓝框高度;否则,将当前行以及蓝框中的“小格”所在的行一起插入到当前页,并为 current 加上蓝框的高度。

对跨页后“大格”的处理

由于表头要在每一页的表格中展示,因此“大格”被拆分后,属于下一页的单元格必须要被显式的绘制出来。下图中的绿框就是这样一个单元格:

一个必须被显式绘制出来的单元格

但是还好,我们在排版时,可以记录一下“自从上一个整行过后,已经用了多少个非整行。”在上图的例子中,“内容很少”和“元素 1”是最近一个整行,然后一直到“元素 5”都是非整行(一共 4 个)。由于在计算合并单元格时,我们知道“内容很少”这一格的 rowspan = 8,那么可以确定绿框的 rowspan = 8 - 4 - 1 = 3。只要发现“元素 6”所在的行是个非整行,我们就在每个缺失的地方各插入一个 rowspan = 3、内容为空的“大格”,将其补充为整行。

于是,这个需求终于做完了。

Don't Break The Web

上文提到,我为排版代码做过一次性能优化,那么这次新增的功能是否会影响到之前的优化效果呢?答案是不会的。先来回顾一下之前我做的优化:

只需要在一开始先将最外层元素设置为 display: none,这样可以避免每加载一块就渲染;然后在全部加载完之后一下子全部渲染出来。

只要能保证给每个表格设置好 <col> 的宽度,那么这一行不管被排到哪个表格块中,高度都是不变的。也就是说,所有元素的高度,在渲染之后排版之前,就已经完全确定下来了!那么我完全可以在排版的一开始,将所有必要元素的 offsetHeight 保存在一个 Map 中,之后直接调用就可以了。

对于第一个优化,控制是否显示只会影响面单自身,与每个面单内部的元素无关。只需要保证我的合并单元格代码和排版代码可以在“一下子全部渲染出来”之后执行即可;对于第二个优化,因为将一个单元格拆到两页并不会影响总体高度,每个“大格”的内容高度也不会变,因此可以将行高与内容高度提前缓存在之前的 Map 中,供排版时调用。

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。
上一篇: 编写状态驱动的业务代码(2)
下一篇: 使用 OpenResty 无痛优化图片体积(AVIF / WebP)

这是我们共同度过的

第 1801 天