2017年2月2日,Ann Taylor在CodeInWP上发表了文章《2017年WordPress页面构建器需要具备的12点特性 + 谁已经做到了》(12 Things We Need to See From WordPress Page Builders in 2017 + Who Already Gets It Right)。一年多来,我们一直在持续改进Page Builder Sandwich:我们深入调研,倾听用户心声,因此我们曾以为Page Builder Sandwich能够登上CodeInWP的“谁做到了”榜单。然而我们并未入选,原因出在性能上。
于是我们向Ann询问了原因,她回复道:
……我试用了一下免费版。它看起来很有前景:模块丰富、用户界面直观、出色的所见即所得体验、所有元素都能轻松拖拽。但是,当我在页面上添加了大约10个模块时,由于编辑速度变慢,继续操作就变得非常困难了……
因此据她所说,Page Builder Sandwich存在性能问题。既然我们始终重视用户反馈,我们便对此进行了排查。
测试性能
我们需要一种方法来检测PBS的性能;由于PBS主要通过Javascript在浏览器端运行,Chrome的Timeline性能分析器(Profiler)正是胜任这项任务的最佳工具。Timeline分析器可以识别并测量网页在运行时发生的进程,非常适合用来寻找速度瓶颈。
我们认为首先应该测试最简单的步骤——用鼠标滑过多处元素和行。很简单对吧?我们很有信心,像这样简单的操作绝不会遇到性能问题。不幸的是,我们错了。
点击录制按钮后,我们在屏幕上移动了大约2秒鼠标,然后点击了停止按钮。以下是分析器生成的时间线:
果不其然,到处都是性能瓶颈!
图中发生了很多事情,但最明显的问题是里面有大量的紫色区域(这本不应该出现)。此外,这些紫色区域明显非常宽,而且似乎都达到了图表顶峰。您还会注意到图表始终处于占满状态,紫色区域几乎从未降为零。浏览器似乎总在忙碌地处理着某些事情,几乎从未歇息。
但这些紫色区域到底意味着什么?
分析器很贴心地告诉我们,它们被称为重排(reflows)。以下是来自Google开发者网站对重排的定义:
重排(Reflow)是Web浏览器用于重新计算文档中元素位置和几何结构的过程……重排是浏览器中的用户阻塞操作。
这意味着在PBS中随意滑动鼠标会触发大量重排。更糟糕的是,每次重排都会短暂暂停浏览器以完成计算。重排越多,意味着浏览器暂停越频繁,体验也会变得更加卡顿(janky)。简单来说,紫色区域多 = 体验糟糕。
事实证明Ann是对的:我们确实存在巨大的性能瓶颈,因此我们的团队立即着手解决这一问题。
解决重排/卡顿问题
在着手解决之前,我们需要先进行一些研究,弄清楚究竟是什么引发了重排。我们发现,显然Javascript中的许多常见操作都会引发浏览器重排。
几乎任何用于获取DOM元素宽度、高度或位置的函数都会触发重排。Paul Irish整理了一份详尽的列表,列出了Javascript中所有会导致重排的属性和函数。坏消息是,Page Builder Sandwich使用了大量这些属性和函数,而它们都会导致重排:
- elem.getBoundingClientRect()
- window.getComputedStyle()
- window.scrollY
- 以及更多……
修复方案1:缓存开销巨大的重排过程
最显而易见的解决方案是不使用这些属性和函数。然而,PBS严重依赖它们,我们无法完全不用。
我们剩下的解决方案是尽可能减少对这些函数的调用。难点在于,我们在PBS的不同部分多次使用了这些属性和函数,而且大多数时候,我们是在同一个DOM元素上多次调用它们。为了解决这个问题,我们为每个有问题的属性/函数创建了自己的缓存机制。
例如,getBoundingClientRect 函数可以为我们提供DOM元素当前的上、左、宽、高、右、下的像素测量值。我们在PBS中频繁使用此函数来计算绘制元素轮廓的位置。以下是我们为此创建的缓存函数:
// 缓存:客户端矩形保存在这里,稍后可用于清理。
var elemsWithBoundingRects = [];
window.pbsGetBoundingClientRect = function( element ) {
// 检查之前是否已获取过客户端矩形。
if ( ! element._boundingClientRect ) {
// 如果没有,获取并存储以备后用。
element._boundingClientRect = element.getBoundingClientRect();
elemsWithBoundingRects.push( element );
}
return element._boundingClientRect;
};
现在我们不再直接调用 getBoundingClientRect ,而是使用 pbsGetBoundingClientRect 。现在每次查询客户端矩形时,我们都会存储该值以备后续使用,从而限制了重排触发的次数。
当存储的客户端矩形失效时,我们还需要一种清理缓存的方法。当用户滚动页面或在屏幕中添加/修改/删除元素时,存储的客户端矩形值会发生变化。因此我们创建了一个缓存清理函数,每当我们需要获取最新的客户端矩形值时就可以调用它:
var clearClientRects = function() {
var i;
for ( i = 0; i < elemsWithBoundingRects.length; i++ ) {
if ( elemsWithBoundingRects[ i ] ) {
elemsWithBoundingRects[ i ]._boundingClientRect = null;
}
}
elemsWithBoundingRects = [];
};
我们对平时常用到的其他高开销函数调用也采用了同样的缓存机制。
修复方案2:使用FastDom将读写操作集中归类
实施修复方案1提升了PBS的性能,但这还远远不够。
在进一步搜索后,我们发现了Wilson Page撰写的《防止“布局抖动”》(Preventing ‘layout thrashing’)一文。Wilson指出:
当JavaScript频繁对DOM进行写入然后读取,多次引起文档重排时,就会发生布局抖动(重排)。
显然,仅限制对特定触发重排的属性和函数的调用是不够的。当您连续对DOM执行多次“读”和“写”操作时,同样会触发浏览器重排。以下是直接摘自Wilson网站的典型场景:
// 读 var h1 = element1.clientHeight; // 写(使布局失效) element1.style.height = (h1 * 2) + 'px'; // 读(触发重排) var h2 = element2.clientHeight; // 写(使布局失效) element2.style.height = (h2 * 2) + 'px'; // 读(触发重排) var h3 = element3.clientHeight; // 写(使布局失效) element3.style.height = (h3 * 2) + 'px'; // 帧结束时文档重排
上面的例子触发了3次重排,因为在写入后整个页面的尺寸都失效了。针对此问题的建议修复方法是重新调整代码顺序,把所有读取和写入操作分别集中归类到一起,这样整个过程就只会触发1次浏览器重排:
// 读 var h1 = element1.clientHeight; var h2 = element2.clientHeight; var h3 = element3.clientHeight; // 写(使布局失效) element1.style.height = (h1 * 2) + 'px'; element2.style.height = (h2 * 2) + 'px'; element3.style.height = (h3 * 2) + 'px'; // 帧结束时文档重排
上面的代码片段让问题的解决看起来很简单。但在现实中,在PBS的整个生命周期中,读取和写入触发重排的操作发生在许多不同的地方和时间。重新组织成千上万行Javascript代码并不可行。
幸运的是,Wilson在他的文章中也提供了解决方案。他创建了一个小巧的实用脚本来帮助将这些读写操作集中归类,名为FastDom。
以下是Page Builder Sandwich中使用FastDom并结合了上述修复方案1的部分代码:
// 将来自其他线程的读取操作集中在一起。
fastdom.measure( function() {
// 使用我们缓存的客户端矩形。
var rect = window.pbsGetBoundingClientRect( elem );
// 将来自其他线程的写入操作集中在一起。
fastdom.mutate( function() {
elem.style.top = ( rect.top + window.pbsScrollY() ) + 'px';
elem.style.height = rect.height + 'px';
elem.style.left = rect.left + 'px';
elem.style.width = rect.width + 'px';
}.bind( this ) );
}.bind( this ) );
FastDom的作用是将所有的 fastdom.measure 调用和所有的 fastdom.mutate 调用分组归类并异步执行,从而有效地将所有读取和写入集中在一起,减少浏览器中的重排。
修复方案3:访问Style对象的开销很大
在jsPerf的帮助下,我们还发现某些获取样式的函数比其他函数更快。
在PBS中,我们通常需要获取元素特定样式的当前值。例如,我们通常这样获取上外边距(top margin):
var styles = window.getComputedStyle( elem ); var marginTop = styles.marginTop;
但是,我们发现这样做要快得多:
var marginTop = elem.style.marginTop;
然而,elem.style.marginTop 和 getComputedStyle 大不相同。前者只提供元素的内联样式,不考虑任何样式表;而后者同时兼顾内联样式和样式表。
更重要的是,即使缓存了 getComputedStyle 的结果,它仍然非常慢。事实证明,访问style对象本身就是一个缓慢的过程。
幸运的是,PBS会将内联样式附加到元素上,并且由于内联样式(在大多数情况下)优先于样式表,因此在某些情况下仅获取内联样式值就足够了。所以如果我们将上述两种方法结合起来,就可以得到运行速度更快的代码:
var marginTop = elem.style.marginTop;
if ( ! marginTop ) {
var styles = window.getComputedStyle( elem );
marginTop = styles.marginTop;
}
代码改进的效果
上述3项修复耗费了大量的研究、测试和实际编码(甚至推迟了字体功能的发布),但最终一切都是值得的。
回到我们最初在屏幕上移动鼠标2秒的测试。以下是之前糟糕/卡顿的时间线:
这是性能改进后我们全新的时间线效果:
正如您所见,图中的紫色部分减少了。浏览器执行的进程数量也有了显著差异。底部的矩形块也更窄了,这意味着进程运行速度比以前更快。
如果对比新旧图表中高/宽紫色区域的占比,我们看到了高达200%的性能提升!我们在各个方面也都看到了性能的改善。效果相当不错!
总结与收获
坦率地说,在Ann提出之前,我们并未意识到PBS存在性能问题。主要是因为我们使用的是开发专用机器,性能瓶颈并不明显。回想起来,性能检测本应是最高优先级的事项之一。但至少现在,截至4.3版本,该问题已得到解决。
对于想要提升自身WordPress插件性能的开发者,特别是同样大量使用Javascript的朋友,以下是我从这次经历中总结的心得:
- 限制/缓存对触发重排的属性/函数的调用。可参考此指南:哪些操作会强制触发布局/重排:全面清单(What forces layout/reflow. The comprehensive list.)
- 将针对DOM样式的“读”与“写”集中归类。使用FastDom来替您完成这项工作。
- 使用Chrome的Timeline分析器,并确保重排(紫色区域)尽可能少。