R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

面单工具:对 Chrome Headless 渲染的极致优化

注意:本文发布于 1467 天前,文章中的一些内容可能已经过时。

背景

有些同学对“面单工具”这个项目应该不算陌生,因为之前我已经写过几篇跟它相关的文章了。它的基础功能无非就是画面单:如果你从 Shopee 上买了点东西,那么快递上面贴的面单就是用这个工具画出来的。

可能是这工具用起来比较方便,随着业务的扩张,一些不是面单的奇奇怪怪的东西也开始切换到面单工具上,例如采购系统的合同(更像是 Word 文件)、仓库的条形码(用了特殊纸张)、拣货单(可以理解成外卖单),这就对面单工具的可扩展性提出了极高的要求。不过这不是本文的重点,之后再专门写文章好了。

用来画拣货单的界面

我之前写过一篇文章,讲的是如何优化编辑器的渲染效率(500 个元素从 1 s 降到 100 ms),这个结果我已经很满意了。但是随着大量的系统开始使用这个工具,它遇到了另外一个性能瓶颈——服务端渲染太慢了……

问题解析

一张面单,从编辑器的画布上,到生成一个 PDF,中间究竟经历了什么?

  1. 前端将编辑器中的东西转换为模板语言(我们选择了 Django / pongo2);
  2. 第三方系统通过 API Call 发了一个请求,要求使用某一个模板,并提供了所有必要的数据(订单号、商品详情等);
  3. 后端对数据做了校验之后,使用模板引擎生成一份面单的 HTML;
  4. 后端使用 Chrome Headless 打开刚生成的 HTML,并将其打印为 PDF;
  5. 后端将 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'),而这个函数其实是相当慢的。如果能改用 firstElementChildnextElementSibling 则会快很多,因为在 Chrome 中,Node Tree 的存储结构比较神奇,每个节点只有五个指针:

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 或者 ... 运算符转换成真正的数组,最后从中找到需要的元素,这样会消耗很多时间。既然我能知道表格元素的位置,那么接下来的元素都通过 firstElementChildnextElementSibling 来找,会更快一点。

做完了这些修改之后,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,是这样的:

优化了 Layout 后的性能

这效果也太好了吧!分析一下这个图的含义:

  • 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 的绘制时间)。因此我觉得,这可以算是非常极致的优化了。

参考资料(部分图源)

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3091 天