背景
有些同學對“面單工具”這個專案應該不算陌生,因為之前我已經寫過幾篇跟它相關的文章了。它的基礎功能無非就是畫面單:如果你從 Shopee 上買了點東西,那麼快遞上面貼的面單就是用這個工具畫出來的。
可能是這工具用起來比較方便,隨著業務的擴張,一些不是面單的奇奇怪怪的東西也開始切換到面單工具上,例如採購系統的合同(更像是 Word 檔案)、倉庫的條形碼(用了特殊紙張)、揀貨單(可以理解成外賣單),這就對面單工具的可擴充套件性提出了極高的要求。不過這不是本文的重點,之後再專門寫文章好了。

我之前寫過一篇文章,講的是如何最佳化編輯器的渲染效率(500 個元素從 1 s 降到 100 ms),這個結果我已經很滿意了。但是隨著大量的系統開始使用這個工具,它遇到了另外一個性能瓶頸——服務端渲染太慢了……
問題解析
一張面單,從編輯器的畫布上,到生成一個 PDF,中間究竟經歷了什麼?
- 前端將編輯器中的東西轉換為模板語言(我們選擇了 Django / pongo2);
- 第三方系統透過 API Call 發了一個請求,要求使用某一個模板,並提供了所有必要的資料(訂單號、商品詳情等);
- 後端對資料做了校驗之後,使用模板引擎生成一份面單的 HTML;
- 後端使用 Chrome Headless 開啟剛生成的 HTML,並將其列印為 PDF;
- 後端將 PDF 返回給第三方系統,第三方系統可能會將其展示在螢幕上,或直接調取特殊的印表機來列印。
可以看出來,雖然實現一個編輯器的難點全在前端,但能使這個工具真正發揮其業務作用的卻是後端。當業務量增加的時候,前端考慮的是如何實現可能的新需求,而後端則需要考慮如何應對逐漸增長的 QPS。
後端的最佳化
由於後端的同學無法接觸到具體的面單內容(或者說,接觸到了也無能為力),所以只能做一些通用的最佳化:
- 使用程序池,而不是每次新開啟一個 Chrome Headless;
- 使用 Debugging Protocol,每次複用之前開啟的頁面;
- 禁止掉外掛、沙箱等功能;
- 加機器……
其實這幾點在網上都能搜到。可能他們還做了其它的最佳化,但我瞭解的暫時只有這些了。這些最佳化在面單內容不復雜的時候還是非常有用的,直到某個業務提出了一個奇怪的需求……
業務的需求
有一天,業務提出需要一個神奇的排版規則:
- 首先,需要實現一個揀貨單:可以理解為外賣的小票,一開始是一個頭部區域(公司名、訂單號、下單時間、備註等),然後是一個表格(商品、數量、價格),最後是一個尾部區域(付款資訊、配送資訊);揀貨單是打在縱向 A6 紙上的,並且表格的行數可以任意多,原則上來說,每一頁的表格最後都要有一個尾部區域;
- 然後,需要實現一組揀貨單:若干張揀貨單被排列在 A4 紙上,它們之間的排版規則可以理解為 Word 的分欄排版規則。
這個需求因為某些原因是推不掉的,甚至連排版規則都不能修改(業務強烈要求,說是當地的賣家希望這樣,並且我們的某個競爭對手已經有這樣的功能了)。
由於表格資料的不確定性,以及分欄排版的邏輯和“每一頁的表格最後都要有一個尾部區域”的需求實在是超出了 Chrome 自身的排版能力,我跟後端同學商量了半天,不得不決定在服務端使用 JavaScript 排版,即我把排版的程式碼注入到模板中,後端在 Chrome Headless 中開啟時就會執行這段排版程式碼。我說這可能會比其它普通面單多 100 ms 的生成時間,後端同學表示:“要哭了,我到哪兒再給你擠出 100 ms 啊……”
不過吐槽歸吐槽,做還是要做的,於是經過了一週多的開發,我不光寫完了排版的程式碼,還重構了一下編輯器,使其可以方便的擴充套件其它業務場景。
然後,業務要求可以在一個組中排列多達 50 張揀貨單,並作為一個 PDF 檔案返回。我……@%$!&^

當然,作為一個專業的工程師,業務的需求總是能做的,於是又花了兩天時間搞出來了。然而 QA 小姐姐做了一下效能測試:用 50 個面單的資料來請求,結果發現需要至少 100 s。我拿到 HTML 之後在自己的外星人上跑了一下,發現也需要 16 s,這太慢了!
前端的最佳化
最原始的程式碼
拿到了生成的 HTML 檔案後,在本地的 Chrome 開啟。由於伺服器沒有 GPU,因此為了跟伺服器一致,我停用了 Chrome 的硬體加速。檢視一下 Performance 面板,是這樣的:

媽耶,要被嚇死了……也不禁感慨道,現代瀏覽器沒有 GPU 加速是萬萬不能的。
QA 給的檔案中,每個面單都有 30 個“商品”,每個“商品”都有四列,也就是說,我要對 50*30*4=6000 個元素做現場排版。
樸素的最佳化
仔細觀察了一下,如果說 40 s 之後是現場排版的情況,那麼前 40 s 難道只是在渲染最初的 HTML 結構?為什麼 Recalculate Style 的時間越來越長?
仔細想一下也不奇怪,因為此時尚未開始執行我的排版程式碼,由於我最初使用了絕對定位,所有的區域和表格都會堆積在 (0, 0) 的位置。而 Chrome 的渲染是一邊 Parse HTML 一邊渲染,每渲染一次就會 Recalculate Style 一次。這意味著:每次渲染一個新的區域或表格,都需要利用之前所有區域和表格的資料(因為要覆蓋在它們上面),所以每次需要計算的元素就越來越多。學過演算法的同學可能會意識到,這個方法的時間複雜度高達 O(N^2)!
能不能只 Recalculate Style 一次呢?可以的,只需要在一開始先將最外層元素設定為 display: none,這樣可以避免每載入一塊就渲染;然後在全部載入完之後一下子全部渲染出來,思路大概是這樣的:
/* 不要用 *,只要隱藏最外層元素就足夠了 */
.template {
display: none;
}/* 在 body 最後的 script 中加上這段 */
const newStyle = document.createElement('style');
newStyle.innerHTML = '.template{display:initial !important}';
document.body.appendChild(newStyle);以及我突然意識到,之前的程式碼在獲取元素的時候使用了 document.querySelectorAll('tr'),而這個函式其實是相當慢的。如果能改用 firstElementChild 和 nextElementSibling 則會快很多,因為在 Chrome 中,Node Tree 的儲存結構比較神奇,每個節點只有五個指標:
根據 Chrome 原始碼的 README 所述:
這意味著:
- 兄弟節點被儲存為一個連結串列。訪問父節點的第 n 個子節點需要 O(N) 的時間。
- 父節點無法在 O(1) 的時間內知道它有多少個子節點。
因此別說是 node.querySelectorAll 了,即使是 node.children 也要先花費 O(N) 的時間生成一個 JavaScript 的類陣列,然後很可能透過 Array.from 或者 ... 運算子轉換成真正的陣列,最後從中找到需要的元素,這樣會消耗很多時間。既然我能知道表格元素的位置,那麼接下來的元素都透過 firstElementChild 和 nextElementSibling 來找,會更快一點。
做完了這些修改之後,Performance 變成了這樣:

可以發現,一開始 40 s 的渲染時間減少到了 3 s,並且後面的排版程式碼執行速度也快了 8 s 左右。

極致的最佳化
之前我本來已經想不到繼續最佳化的辦法了,因為我看了一下 Sources 中每行程式碼對應的時間,發現大頭在這裡:

每次獲取 tr.offsetHeight 時都需要進行一遍 Layout 操作,而且還是強制重排,但是我又必須要獲取表格每一行的高度,因為表格中的資料可能會有換行,每行的高度是不確定的。
但是在寫這篇文章之前,我突然又意識到:只要能保證給每個表格設定好 <col> 的寬度,那麼這一行不管被排到哪個表格塊中,高度都是不變的。也就是說,所有元素的高度,在渲染之後排版之前,就已經完全確定下來了!那麼我完全可以在排版的一開始,將所有必要元素的 offsetHeight 儲存在一個 Map 中,之後直接呼叫就可以了。
在最佳化前,獲取高度的時機是在排版時,這意味著每次修改了 DOM 結構後,再在下一輪迴圈中獲取另一個元素的高度,因此每次獲取都會強制重排;最佳化後,獲取高度的時機是在排版之前,而排版時只會獲取已快取的元素高度,不會導致強制重排,因此只會 Layout 一次。
思路大概是這樣:
const trHeights = new Map();
const headAreaHeights = new Map();
const tailAreaHeights = new Map();
const theadHeights = new Map();
const tbodyHeights = new Map();
Array.from(document.querySelectorAll('tbody > tr')).forEach(el => {
trHeights.set(el, el.offsetHeight);
});
Array.from(document.querySelectorAll('.__area_head')).forEach(el => {
headAreaHeights.set(el, el.offsetHeight);
});
Array.from(document.querySelectorAll('.__area_tail')).forEach(el => {
tailAreaHeights.set(el, el.offsetHeight);
});
Array.from(document.querySelectorAll('thead')).forEach(el => {
theadHeights.set(el, el.offsetHeight);
});
Array.from(document.querySelectorAll('tbody')).forEach(el => {
tbodyHeights.set(el, el.offsetHeight);
});這段程式碼是一次性執行的,因此對效率的影響可以忽略。接下來把後面的 offsetHeight 都替換成這樣:
trs.forEach((tr, index) => {
// const rowHeight = tr.offsetHeight;
const rowHeight = trHeights.get(tr);
/* 其它程式碼 */
});看一下 Performance,是這樣的:

這效果也太好了吧!分析一下這個圖的含義:
- 0 ms ~ 400 ms 是在載入頁面,這個理論上是沒法再最佳化的;
- 400 ms ~ 2300 ms 是一開始將所有元素顯示出來,於是 Chrome 做了一遍 Layout;
- 2300 ms ~ 2500 ms 是我的排版程式碼,此時由於沒有任何獲取樣式的操作,瀏覽器會將所有的樣式計算集中到下一個事件迴圈中統一處理;
- 2500 ms ~ 4900 ms 是統一處理的樣式計算,這需要一次 Layout。
理論上講,Layout 的次數已經不可能再少了,兩次 Layout 的時間取決於面單的複雜程度,可能可以透過最佳化 CSS 的方式來提速,但不確定效果如何(畢竟現在平均每個元素只有不到 0.5 ms 的繪製時間)。因此我覺得,這可以算是非常極致的優化了。