JavaScript动画的性能并不亚于CSS动画。因此,如果使用了现代的动画库,例如Velocity,那么动画引擎的性能将不再是app的瓶颈,构成瓶颈的只有代码。
网络性能相关
动画是浏览器运行中资源非常密集的进程,但是有很多技术能够帮助浏览器尽可能高效地运行。下面会提到这些技术。
性能影响一切。
但是对于用户来说,他们的设备配置参差不齐,不可能都像开发人员一样用最新版iPhone。所以,要考虑的就是在低端设备上提供较为流畅的体验。还有,有时只考虑设备处于理想负载下的情况,但实际上,用户的浏览器可能开了很多应用和选项卡,这在一定程度上也需要流畅体验。
一.去除布局颠簸
布局颠簸,就是DOM操作缺乏同步性,是拖垮动画性能的很主要的因素。对它虽没有轻松的解决办法,但却有最佳实践。可以继续来看。
看一下网页操作是如何进行设置(setting)和获取(getting)这两项任务的:可以设置(更新)或获取一个元素的CSS属性。同理,可以往页面里插入新元素或者从页面里查询一组已存在元素。获取和设置是引发性能开销的两个核心浏览器进程(另外还有图形渲染)。可以这样来想这个问题:在为元素设置了新属性以后,浏览器必须计算这次更改所产生的后续影响。例如,改变一个元素的宽度会导致一系列连锁反应;它的父级元素、兄弟元素和子元素的宽度根据各自的CSS属性也要调整。
由设置和获取的交替而导致的UI性能降低被称为布局颠簸。尽管浏览器已经为页面布局的重新计算进行了高度优化,但由于布局颠簸,这些优化的效果大打折扣。例如,浏览器可以轻易地将同一时间的一系列获取操作优化成一个单一的、流畅的操作,这是因为浏览器在第一次获取之后可以缓存页面的状态,然后在后续每次获取操作时,参考那个状态。但是,如果反复执行了获取之后又执行设置,就会让浏览器去做许多繁重的工作,因为设置所做的更改会不断地使其缓存失效。
当布局颠簸在动画循环中出现的时候,对性能的影响更为厉害。假设一个动画循环力求达到60帧每秒,这是人眼感知平滑运动的最低值。这意味着在动画循环中,每一个tick都必须在16.7毫秒(1秒/60tick≈16.67毫秒)内完成。布局颠簸很容易导致每个tick超过这个时限。最终结果当然就是动画变得卡顿。尽管有些动画引擎,例如Velocity.js,在其动画循环中为减少布局颠簸进行了优化,但还要当心在你自己的循环中避免出现布局颠簸,例如在setInterval()或自调用的setTimeout()代码里面。
解决:
方法就是把DOM的设置和获取的操作分别集合在一起。以下代码会导致布局颠簸:
// 糟糕的做法
var currentTop = $("element").css("top"); // 获取
$("element").style.top = currentTop + 1; // 设置
var currentLeft = $("element").css("left"); // 获取
$("element").style.left = currentLeft + 1; // 设置
如果重写上述代码,把查询放在一起,把设置放在一起,那么浏览器就可以打包相应的操作,从而减少代码造成的布局颠簸的影响:
var currentTop = $("element").css("top"); // 获取
var currentLeft = $("element").css("left"); // 获取
$("element").css("top", currentTop + 1); // 设置
$("element").css("left", currentLeft + 1); // 设置
或者:
var currentTop = $("element").css("top"); // 获取
var currentLeft = $("element").css("left"); // 获取
$("element").css({
"top": currentTop + 1,
"left": currentLeft + 1
}); // 设置
以上所说明的问题经常会在生产代码中看到,尤其是当UI操作依赖于元素当前CSS属性值的时候。
比如你的目的是在单击按钮的时候,切换侧边菜单的可见性。要想达到这一效果,你可能会先检查侧边菜单的display属性是设置成"none"还是"block",然后再相应地进行值的替换。检查display属性的过程构成一次“获取”;后续不论是将侧边菜单显示出来还是隐藏起来都构成了一次“设置”。
要想优化这种代码就必须在内存中保留一个变量,每当按钮点击时,这个变量跟着更新,然后在切换可见性之前,通过查询这个变量得知侧边菜单的当前状态。这样,“获取”的过程就完全省掉了,从而有助于减少设置和获取交替出现的可能性。另外,除了降低布局颠簸发生的可能性以外,UI现在还得益于减少了一次页面查询。记住:每次设置和获取对于浏览器操作来说都比较消耗性能;设置和获取次数越少,UI的速度就会越快。许许多多的小改进最终会积累成相当可观的好处,而这正是本文的潜在主题:尽可能多地遵循性能最佳实践,就可以尽可能少地为了性能而妥协自己心中理想的动效设计目标,从而实现满意的页面。
Jquery元素对象
如果网页使用了Jquery,实例化Jquery对象也是造成DOM获取操作的一个因素。
比如:
$("#element").css("opacity", 1);
或者等效的原生JavaScript:
document.getElementById("element").style.opacity = 1;
在jQuery代码中,由$("#element")返回的值就是一个JEO,即一个包装了所查询的原生DOM元素的对象。JEO提供了所有你欢喜的jQuery功能,包括.css()、.animate()等。
原生代码中,getElementById()返回的是一个没有包装过的DOM元素,上面两种写法都要求浏览器搜索DOM树,找到想要的元素。这种操作,如果重复多次,就会影响页面的性能。
当未被缓存的元素在重复使用的代码片段中出现,例如在循环代码中,对性能的影响就更严重了。下面这个例子:
$elements.each(function(i, element) {
$("body").append(element);
});
each中反复访问$(body),会影响性能。再者每次循环都会append()一个元素,导致一次重排版,也会影响性能。
解决这两个问题的方法,分别是缓存Jquery包装对象和批量操作DOM:
// 糟糕做法:未缓存JEO
$("#element").css("opacity", 1);
// …… 一些中间代码……
// 我们再次将JEO实例化
$("#element").css("opacity", 0);
缓存Jquery包装对象:
// 缓存jQuery元素对象,在变量前面加个前缀$用来表示这是个JEO
var $element = $("#element");
$element.css("opacity", 1);
// …… 一些中间代码 ……
// 我们复用了缓存的JEO,避免了一次DOM查询
$element.css("opacity”, 0);
在后面的代码里面可以同样使用$element.
强制给值:动画引擎的传统做法是在动画的一开始查询一遍DOM来确定每个被设置动画的CSS属性的初始值是多少。Velocity通过一种称为“强制给值”的功能可以绕过这一页面查询事件。这也是避免布局颠簸的另一项技术。通过强制给值,可以明确地为动画设置初始值,从而彻底免去了一开始就对页面进行获取的操作。强制给定的值作为第二项被传入一个数组中,而这个数组替代了原本动画属性映射中属性值的位置。数组中的第一项是你想要设置动画变动到的最终值。
批量添加DOM
有一种常见的页面设置操作是在页面运行时插入新DOM元素。为页面添加新元素有很多用途,不过其中最流行的也许就是无限滚动了,它在用户向下滚动的时候,不断让新元素在页面底部以动画方式进入视图。在前面已经知道,每当有一个新元素添加进来,浏览器就必须针对所有受到影响的元素进行计算。这是一个相对较慢的过程。因此,当每秒要进行多次DOM插入时,页面的性能就会受到显著影响。幸运的是,当处理多个元素时,如果所有元素是同时插入的,那么浏览器可以对这个设置的操作进行优化。但不幸的是,作为开发人员的我们经常无意识地放弃了这种优化做法,给DOM单独添加元素。请看下面未优化的DOM插入做法:
// 糟糕的做法
var $body = $("body");
var $newElements = [ "<div>Div 1</div>", "<div>Div 2</div>", "<div>Div 3</div>" ];
$newElements.each(function(i, element) {
$(element).appendTo($body);
// 其他代码
});
以上代码遍历了一组元素字符串,这组元素字符串被实例化到jQuery元素对象中。(这么做没有什么性能损失,因为你没有针对每个JEO去查询DOM。)然后,使用jQuery的appendTo()函数将每个元素插入到页面中。
问题是这样的:即使在appendTo()语句后面还有其他代码,浏览器也不会把这些DOM设置操作压缩成一个单一的插入操作,因为浏览器不能确定循环以外的异步代码操作不会在插入操作之间修改DOM状态。例如,想象这样一个场景:在每次插入之后都查询DOM,想要搞清楚究竟有多少元素在页面上存在:
// 糟糕的做法
$newElements.each(function(i, element) {
$(element).appendTo($body);
// 输出body元素有多少个子元素
console.log($body.children().size());
});
浏览器无法将上面的DOM插入优化成一次操作,这是因为代码明确要求浏览器告诉我们,在下次循环开始之前,究竟存在多少元素。因为浏览器每次都要返回正确数值,因此它无法批量处理后面所有的插入操作。
总之,在循环内部进行DOM元素插入时,每一次插入的操作与其他都是互相独立的,因此会造成明显的性能损失
解决方法就是,不要将一个元素直接插入DOM中,先构建一个完整的DOM集合,然后一次性插入到页面中去。前面举的例子可以优化成:
// 优化后
var $body = $("body");
var $newElements = [ "<div>Div 1</div>", "<div>Div 2</div>", "<div>Div 3</div>" ];
var html = "";
$newElements.each(function(i, element) {
html += element;
});
$(html).appendTo($body);
上面代码还有可以优化的地方,字符串拼接可以继续优化。
以上代码将代表每个HTML元素的字符串连在一起形成一个主字符串,然后把这个主字符串转成JEO并一次性添加到DOM上。通过这种做法,浏览器得到明确指示,将所有元素一次性插入,相应的性能也得到了优化。
避免影响临近的元素:
提升性能很重要的一点就是要考虑一个元素的动画对其临近元素的影响。
例如,如果夹在两个兄弟元素之间的一个元素宽度缩小,那么它的兄弟元素的绝对定位就会动态改变,从而保持在动画元素的旁边。另一个例子可能是设置嵌入在父元素中的子元素的动画,而这个父元素并没有明确定义的width和height属性。相应地,设置子元素的动画时,父元素的尺寸也会改变,从而确保将子元素完全包裹在内。实际上,子元素并不是唯一被设置动画的元素,因为它的父元素的尺寸也被设置了动画。如果这发生在动画循环里面,那么浏览器在每次循环时要做的工作就更多了!
有很多CSS属性,一经改变,就会造成临近元素尺寸或位置的调整,其中包括:top、right、bottom和left,所有的margin和padding属性,border厚度,以及width和height尺寸。作为关心性能的开发人员,需要了解设置这些属性的动画会给页面带来什么影响。时刻问自己,设置每个属性的动画会怎样影响临近元素。如果重写代码能够让你避免元素变化带来的互相影响,那么请考虑重写。事实上,要这么做有一种简便方法,继续看后面的解决办法!
解决方法:
这种可以避免影响到临近元素的解决办法是尽可能设置CSS的transform属性(translateX、translateY、scaleX、scaleY、rotateZ、rotateX和rotateY)的动画。transform属性的特殊之处在于它们将目标元素提升至一个单独的层,这个层可以独立于页面其他内容单独渲染(通过GPU加速提升性能),因此相邻的元素不会受到影响。例如,在设置一个元素的translateX变动到"500px"的动画时,元素会向右移动500像素,覆盖在任何动画路径上已经存在的元素的上面。如果在动画路径上没有任何元素(也就是没有相邻的元素),那么使用translateX的效果与设置更慢的left属性的动画的效果,在页面上看起来是一样的。
所以浏览器支持的情况下,原本这样:
// 将元素自左侧移动500像素
$element.velocity({ left: "500px" });
就可以写成这样:
// 更快:使用translateX
$element.velocity({ translateX: "500px" });
top也是类似的:
$element.velocity({ top: "100px" });
// 更快:使用translateY
$element.velocity({ translateY: "100px" });
减少并发加载:
当页面首次加载时,浏览器会尽可能快地处理HTML、CSS、JavaScript和图片。因此不出意外,这时候发生的动画容易发生延迟,它们在努力抢夺浏览器有限的资源。所以,尽管在页面加载序列中添加动画是显摆动效设计技巧的好时机,但如果想避免用户产生网站很慢的第一印象,那么克制自己不要这么做。同理,当许多动画同时在页面上发生时,也会出现一个类似的并发性瓶颈,不论它是出现在页面生命周期中的哪个阶段。在这些情况下,浏览器在同时处理众多样式变化的重压下会喘不过气来,然后卡顿就发生了。
错开动画:
减少并发动画加载的一个方式是使用Velocity的UI pack中的stagger功能,它会相继在一组元素的动画开始前添加指定的延迟时间。例如,要设置一组元素中每个元素的opacity值变动至1的动画,并且在动画开始时间之间相继添加300毫秒的延迟,代码可能会是这样:
$elements.velocity({ opacity: 1 }, { stagger: 300 });
这时候,这些元素不再是完全同步执行动画的,而是在整个动画序列的开头,只有第一个元素在执行动画。然后,在整个序列的结尾,只有最后一个元素执行动画。你很高效地分散了动画序列的总工作量,使浏览器总是在每一刻做更少的工作,而不是同时执行每个元素的动画,让浏览器累得喘不过气来。另外,在动效设计中使用错开动画,通常会得到较好的审美效果。
多动画序列:
减少并发加载还有另一个方法:将多个属性的动画拆成多动画序列。以设置元素的opacity值的动画为例。这通常是一个相对轻松的操作。但是,如果同时还要设置元素的width和box-shadow属性的动画,那么就会给浏览器带来更多可观的工作量:会影响更多像素,也要进行更多计算。因此,如果原本动画像这样子:
$images.velocity({ opacity: 1, boxShadowBlur: "50px" });
可以改写成:
$images
.velocity({ opacity: 1 })
.velocity({ boxShadowBlur: "50px" });
这样浏览器就有更少的并发工作要做,因为这些都是一个接一个发生的单独属性动画。注意此处要进行权衡,因为整个动画序列的持续时间变长了。这对于最终的应用场景而言,也许是好事,也许是坏事。
既然这种优化需要改变你原本对动效设计的想法,那么这一技巧并非总是要使用。把它作为最后的手段吧。如果需要在低端设备上挤出额外的性能,那么用这种技巧或许合适。其他情况下,不要用这种技巧预先优化网站上的代码,否则的话,最终得到的将是不必要的臃肿且晦涩的代码。
不用持续响应滚动(scroll)和调整大小(resize)事件
对于高频事件,最好使用防抖动控制发生频率。浏览器的滚动(scroll)和调整大小(resize)是两个触发频率非常频繁的事件类型:每当用户调整或滚动浏览器窗口时,浏览器都会在每秒内触发多次与这些事件相关的回调函数。因此,如果你注册的回调函数与DOM有交互的话,或者更糟,包含布局颠簸的话,那么它们会在滚动或调整大小时带来巨大的浏览器负担。请看下面的代码:
// 当滚动浏览器窗口时,执行一个行为
$(window).scroll(function() {
// 这里写的任何行为都会在用户滚动时,每秒钟触发多次
});
// 当浏览器窗口的大小改变时,执行一个行为
$(window).resize(function() {
// 这里写的任何行为都会在用户调整窗口大小时,每秒钟触发多次
});
解决方式就是加防抖动。防抖动就是,定义一个时间间隔,在此时间间隔期间,事件句柄回调将仅会被调用一次。例如,假设你定义了一个250毫秒的反跳间隔,而用户滚动页面的总持续时间为1000毫秒。这时候,进行了防抖动的事件句柄代码就会相应地仅触发四次(1000毫秒/250毫秒)。
如果不想自己写原生js来防抖动,很多库可以用,比如的Underscore.js(UnderscoreJS.org),它是一个与jQuery很相近、也提供用于简化编程的辅助函数的JavaScript库,这个库含有debounce函数,你可以轻松地在事件句柄上反复使用它。
一些代码是这样:
// MooTools
Function.implement({
debounce: function(wait, immediate) {
var timeout,
func = this;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
}); // Use it!
window.addEvent("resize", myFn.debounce(500))
不过现在部分浏览器,如Chrome的最新版本已经自动反跳滚动事件了。
减少图片渲染:
视频和图片是多媒体元素类型,浏览器必须要加倍努力渲染才行。要计算非多媒体元素的尺寸属性是很轻松的,但是多媒体元素包含成千上万的像素数据,要改变它们的大小、尺寸或是重新合成,对浏览器而言计算开销是很大的。设置这些元素的动画的性能总是比不上设置标准HTML元素(如div、p和table)的动画的性能,来得理想。另外,鉴于滚动页面几乎可以视为设置整个页面的动画(可以把滚动页面视为设置页面的top属性的动画),在CPU不高的移动设备上,多媒体元素也会造成滚动性能的巨幅下降。
另外,鉴于滚动页面几乎可以视为设置整个页面的动画(可以把滚动页面视为设置页面的top属性的动画),在CPU不高的移动设备上,多媒体元素也会造成滚动性能的巨幅下降。
解决:
不幸的是,除了尽可能把简单的、基于图形的图片转成SVG元素以外,就没有其他任何办法可以将多媒体内容重构成更快的元素类型。因此,唯一可行的性能优化做法就是减少在页面上同时显示和同时设置动画的多媒体元素总数。注意这里用到的同时一词是在强调浏览器渲染的客观情况:浏览器只渲染可以看到的东西。页面上看不到的部分(包括包含额外图片的部分)是不会被渲染的,而且也不会对浏览器进程造成额外压力。因此,有两种最佳实践可以遵循:第一种,如果原本感觉在页面上添不添额外图片都无所谓的话,那么选择不添。要渲染的图片越少,UI性能就越好。(更不用说更少的图片给页面网络加载时间带来的正面影响。)
第二种,如果你的UI在同时加载很多图片到视图(比如,8幅或以上,根据设备硬件性能而定),考虑不要设置这些图片的动画,或者只是简单地切换每幅图片的可见性从不可见到可见。这种视觉效果可能并不优雅,要弥补这一点,可以考虑错开切换可见性的动画时间,使图片一个接一个显示而不是同时显示出来,这样做通常会产生出更精致的动效设计。
除了img元素,还有其他的形式,图片显示到页面上的形式。
CSS渐变:渐变实际上是图片的一种。它们不是用图片编辑器事先生成的,而是根据CSS的样式定义,在运行时生成的,例如在一个元素的background-image属性上用了linear-gradient()作为值。这里的解决办法是尽量选择纯色而非渐变背景。浏览器可以轻松优化纯色色块的渲染,但是就像对待图片一样,浏览器渲染渐变也格外费力,因为渐变的色彩是逐像素变化的。
阴影属性:渐变有个坏坏的双胞胎,那就是box-shadow和text-shadow这两个CSS属性。它们的渲染跟渐变的渲染大同小异,只不过不是在background-color上,而是在border-color上罢了。更糟糕的是,它们的不透明度还逐渐减少,这要求浏览器进行额外的合成工作,因为渐变的半透明部分必须依据动画元素下面的元素来渲染。这里的解决办法跟之前的差不多:如果从样式表上移除这些CSS属性后,UI的视觉效果跟之前差不多优秀,那么宽慰一下自己,放弃之前的方案吧。网站的高性能会反过来回报你的。
这些建议只是建议而已。它们并非性能最佳实践,因为你要为了提高性能而牺牲设计本意。只有当网站性能很糟糕的时候,才考虑使用这些没有办法的办法。
在旧浏览器上降级动画:
IE系列浏览器在本国还在广泛被使用,低版本的IE也会占到一定份额。另外,运行着Android 2.3.x及更早系统的老安卓智能手机比最新一代的Android和iOS设备要慢,但它们依然被广泛使用。相应地,如果你的网站有丰富的动画和其他UI互动,那么就可以推断对于这块用户来说,网站的运行很糟糕。
解决:
要解决低端设备造成的性能问题有两种方式:要么不管三七二十一减少整个网站的动画;要么只针对低端设备减少动画。前者说到底是一种产品决策,而后者则只是一种可以轻松实施的技术决策,只要使用了全局动画乘数技术(或Velocity中对应的mock功能)。全
局乘数技术使你能够通过一个变量改变整个网站的动画时间。因此,这里的诀窍就是:每当检测出用户正在使用性能较弱的浏览器,那么就将乘数设置为0(或者将$.Velocity.mock设置为true)。这样做就能让整个页面的动画都在一个动画tick(少于16毫秒)中完成:
// 使所有动画立即完成
$.Velocity.mock = true;
这样,在性能较差的浏览器中,原本的动画渐变变成样式的立即修改,会流畅一些。
另外,找到性能门限,在参考设备上测试性能,也至关重要,
参考:《javascript网页动画设计》