R·ex / Zeng


音遊狗、安全狗、攻城獅、業餘設計師、段子手、苦學日語的少年。

當表格排版遇到了合併單元格

我負責的面單平臺的需求複雜度已經越來越高了。自從寒假接了一個複雜表格排版的需求後,各類業務對於表格排版的使用場景也開始變多。畢竟,一個可以自動換頁排版,並保持每一頁都帶有首部區域和尾部區域的表格,哪個業務不喜歡呢?(被打)

表格的排版

先回顧一下之前的需求:

  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. 所有的非整行,缺失列一定是完全相同的(“小格”列中的單元格不會被合併)
  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 元素 1 組 2 元素 1
元素 2元素 2
元素 3元素 3
元素 4元素 4
元素 5 元素 5
元素 6元素 6
元素 7元素 7
元素 8元素 8

不過有一個值得注意的地方——業務要求的“大格”裡面的內容確實不算多,一般就是一個商品名稱或者圖片,內容本身不會超過一頁。如果我可以將這個單元格拆成有內容和沒內容兩部分,也能在一定程度上緩解空間浪費的問題。

之前對的排版思路是這樣的:

  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 中,供排版時呼叫。

Disqus 載入中……如未能載入,請將 disqus.com 和 disquscdn.com 加入白名單。

這是我們共同度過的

第 3847 天