我负责的面单平台的需求复杂度已经越来越高了。自从寒假接了一个复杂表格排版的需求后,各类业务对于表格排版的使用场景也开始变多。毕竟,一个可以自动换页排版,并保持每一页都带有首部区域和尾部区域的表格,哪个业务不喜欢呢?(被打)
表格的排版
先回顾一下之前的需求:
- 有一类专门的面单类型叫“拣货单”,用于给卖家拣货
- 拣货单有三个部分:头部区域(展示 Logo、买家地址等)、表格(展示商品信息)、尾部区域(展示备注和页码)
- 表格可能非常长,需要分页打印(纸张类型分为 A4、A5、A6 三种)
- 第一页的表格之前一定会有一个头部区域
- 每一页的表格之后都要接一个尾部区域,可以配置非第一页的表格之前是否也接一个头部区域
- 表格的数据内容未知,列宽、字体、字号均可以自定义,行高不确定
大概的效果嘛,类似于下图(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 | 中 |
前两列是“大格”,后两列是“小格”,看起来也很工整,是不是一看就很好处理!于是经过与需求方的沟通,我们最终做了如下的约定:
- 表格的第一条数据一定是一个完整行(毕竟第一行的单元格没有“上方元素”)
- 所有的非整行,缺失列的 Key 一定是完全相同的(“小格”列中的单元格不会被合并)
- 单元格高度不超过一页减掉头尾部区域的高度(无需考虑拆分单元格内部的内容)
- 不会有横向的合并操作(可以简化
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;
}
}
}
修改之前的分页排版算法
如果没有分页功能的话,那么这个需求已经完成了,但是事情就是没有那么简单……
很快我就发现:如果还跟之前一样,为了不拆单元格中的内容,强制当空间不够时将下一行直接扔到下一页,势必会造成极大的空间浪费,因为有可能“大格”里面的内容并不多,但每个“大格”对应的“小格”特别多,这样一个“大格”的高度很有可能超过一页,并且这在业务上是绝对无法避免的:
不过有一个值得注意的地方——业务要求的“大格”里面的内容确实不算多,一般就是一个商品名称或者图片,内容本身不会超过一页。如果我可以将这个单元格拆成有内容和没内容两部分,也能在一定程度上缓解空间浪费的问题。
之前对的排版思路是这样的:
- 令当前高度
current = headAreaHeight + thHeight
从头开始枚举每一行
- 获取当前行的高度
height = tr.offsetHeight
如果
current + height + tailAreaHeight
大于一页高度:- 在当前页的最后插入一个尾部区域
- 将表头和当前行放到新页,更新
current
- 否则,将当前行插入到当前页的最后,并更新
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
中,供排版时调用。