我们如何将插件性能提升200%

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的朋友,以下是我从这次经历中总结的心得:

  1. 限制/缓存对触发重排的属性/函数的调用。可参考此指南:哪些操作会强制触发布局/重排:全面清单(What forces layout/reflow. The comprehensive list.)
  2. 将针对DOM样式的“读”与“写”集中归类。使用FastDom来替您完成这项工作。
  3. 使用Chrome的Timeline分析器,并确保重排(紫色区域)尽可能少。