我負責的面單平臺的需求複雜度已經越來越高了。自從寒假接了一個複雜表格排版的需求後,各類業務對於表格排版的使用場景也開始變多。畢竟,一個可以自動換頁排版,並保持每一頁都帶有首部區域和尾部區域的表格,哪個業務不喜歡呢?(被打)
表格的排版
先回顧一下之前的需求:
- 有一類專門的面單型別叫“揀貨單”,用於給賣家揀貨
- 揀貨單有三個部分:頭部區域(展示 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 | 中 |
前兩列是“大格”,後兩列是“小格”,看起來也很工整,是不是一看就很好處理!於是經過與需求方的溝通,我們最終做了如下的約定:
- 表格的第一條資料一定是一個完整行(畢竟第一行的單元格沒有“上方元素”)
- 所有的非整行,缺失列一定是完全相同的(“小格”列中的單元格不會被合併)
- 單元格高度不超過一頁減掉頭尾部區域的高度(無需考慮拆分單元格內部的內容)
- 不會有橫向的合併操作(可以簡化
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 | 元素 1 | 組 2 | 元素 1 |
| 元素 2 | 元素 2 | ||
| 元素 3 | 元素 3 | ||
| 元素 4 | 元素 4 |
| 元素 5 | 元素 5 | ||
| 元素 6 | 元素 6 | ||
| 元素 7 | 元素 7 | ||
| 元素 8 | 元素 8 |
不過有一個值得注意的地方——業務要求的“大格”裡面的內容確實不算多,一般就是一個商品名稱或者圖片,內容本身不會超過一頁。如果我可以將這個單元格拆成有內容和沒內容兩部分,也能在一定程度上緩解空間浪費的問題。
之前對的排版思路是這樣的:
- 令當前高度
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 中,供排版時呼叫。