背景
有些同学对“面单工具”这个项目应该不算陌生,因为之前我已经写过几篇跟它相关的文章了。它的基础功能无非就是画面单:如果你从 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 所述:
That means:
- Siblings are stored as a linked list. It takes O(N) to access a parent's n-th child.
- Parent can't tell how many children it has in O(1).
因此别说是 node.querySelectorAll
了,即使是 node.children
也要先花费 O(N)
的时间生成一个 JavaScript 的类数组,然后很可能通过 Array.from
或者 ...
运算符转换成真正的数组,最后从中找到需要的元素,这样会消耗很多时间。既然我能知道表格元素的位置,那么接下来的元素都通过 firstElementChild
和 nextElementSibling
来找,会更快一点。
做完了这些修改之后,Performance 变成了这样:
可以发现,一开始 40 s 的渲染时间减少到了 3 s,并且后面的排版代码运行速度也快了 8 s 左右。
极致的优化
之前我本来已经想不到继续优化的办法了,因为我看了一下 Sources 中每行代码对应的时间,发现大头在这里:
每次获取 tr.offsetHeight
时都需要进行一遍 Layout 操作,而且还是 Forced reflow,但是我又必须要获取表格每一行的高度,因为表格中的数据可能会有换行,每行的高度是不确定的。
但是在写这篇文章之前,我突然又意识到:只要能保证给每个表格设置好 <col>
的宽度,那么这一行不管被排到哪个表格块中,高度都是不变的。也就是说,所有元素的高度,在渲染之后排版之前,就已经完全确定下来了!那么我完全可以在排版的一开始,将所有必要元素的 offsetHeight
保存在一个 Map
中,之后直接调用就可以了。
在优化前,获取高度的时机是在排版时,这意味着每次修改了 DOM 结构后,再在下一轮循环中获取另一个元素的高度,因此每次获取都会有 Layout;优化后,获取高度的时机是在排版之前,而排版时只会获取元素高度,不会修改样式和 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);
/* other codes */
});
看一下 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 的绘制时间)。因此我觉得,这可以算是非常极致的优化了。
参考资料(部分图源)
版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。