提高 HTML5 应用的性能

Malte Ubl
Malte Ubl

简介

HTML5 为我们提供了强大的工具来提升 Web 应用的外观。这在动画领域尤其如此。不过,这种新能力也带来了新挑战。事实上,这些挑战并不新鲜,有时不妨向您友好的同事 Flash 程序员询问她过去是如何克服类似问题的。

无论如何,在处理动画时,让用户感受到这些动画的流畅非常重要。我们需要认识到,仅仅将每秒帧数提高到超出任何认知阈值的水平,并不能真正实现流畅的动画。但遗憾的是,我们的大脑比这更聪明。您将了解到,真正意义上的每秒 30 帧 (fps) 动画效果远远优于中间仅丢失几帧的 60 fps。人们讨厌锯齿状边缘。

本文将尝试为您提供相关工具和技巧,帮助您改进您自己应用的体验。

策略

我们绝不想阻止您使用 HTML5 构建出令人惊叹的视觉效果出色的应用。

然后,如果您发现性能可以再提升一点,请返回此处,了解如何改进应用的各个元素。当然,从一开始就正确地做一些事情有助于提高效率,但切勿让这妨碍您提高效率。

使用 HTML5 实现视觉保真度++

硬件加速

硬件加速是浏览器整体渲染性能的重要里程碑。一般方案是将原本由主 CPU 计算的任务分流到计算机显卡适配器中的图形处理单元 (GPU)。这可以显著提升性能,还可以减少移动设备上的资源消耗。

GPU 可以加速文档的以下方面

  • 常规布局合成
  • CSS3 过渡
  • CSS3 3D 转换
  • 画布绘制
  • WebGL 3D 绘制

虽然 Canvas 和 WebGL 加速是专用功能,可能不适用于您的特定应用,但前三点几乎可以帮助每款应用加快速度。

哪些方面可以加速?

GPU 加速的工作原理是将定义明确的特定任务分流到专用硬件。一般方案是将文档拆分为多个“层”,这些层不受网页加速的方面影响。这些图层使用传统渲染流水线进行渲染。然后,GPU 会将层合成到单个页面上,并应用可动态加速的“效果”。一个可能的结果是,在动画播放期间,屏幕上进行动画处理的对象不需要对网页进行一次“重新布局”。

您需要从中得出的结论是,您需要让渲染引擎能够轻松确定何时可以应用其 GPU 加速魔法。请参考以下示例:

虽然这种方法可行,但浏览器实际上并不知道您执行的操作应该被人视为流畅的动画。不妨考虑改用 CSS3 过渡来实现相同的外观效果时会出现什么情况:

浏览器如何实现此动画完全不对开发者显示。这反过来意味着,浏览器能够应用 GPU 加速等技巧来实现指定的目标。

Chrome 有两个实用的命令行标志,可帮助调试 GPU 加速:

  1. --show-composited-layer-borders 会在 GPU 级别被操控的元素周围显示红色边框。适合确认您的操作是否在 GPU 层内进行。
  2. --show-paint-rects 绘制所有非 GPU 更改,这会在重新绘制的所有区域周围投射一个浅色边框。您可以看到浏览器优化绘制区域的实际效果。

Safari 具有类似的运行时标志,详见此处

CSS3 过渡

CSS 转场效果让每个人都能轻松实现样式动画,但它们也是一项智能的性能功能。由于 CSS 转换由浏览器管理,因此其动画的保真度可以得到显著提升,并且在许多情况下可以实现硬件加速。目前,WebKit(Chrome、Safari、iOS)支持硬件加速 CSS 转换,但其他浏览器和平台很快也将支持此功能。

您可以使用 transitionEnd 事件将此脚本编写为强大的组合,不过目前,捕获所有受支持的转换结束事件意味着监控 webkitTransitionEnd transitionend oTransitionEnd

许多库现在都引入了动画 API,这些 API 会在有转场效果时利用转场效果,否则会回退到标准 DOM 样式动画。scripty2YUI 转场jQuery 增强型动画

CSS3 转换

我相信您之前一定动画化过页面上元素的 x/y 位置。您可能操控了内嵌样式的 left 和 top 属性。对于 2D 转换,我们可以使用 translate() 功能来重现此行为。

我们可以将其与 DOM 动画结合使用,以便尽可能使用最佳效果

<div style="position:relative; height:120px;" class="hwaccel">

  <div style="padding:5px; width:100px; height:100px; background:papayaWhip;
              position:absolute;" id="box">
  </div>
</div>

<script>
document.querySelector('#box').addEventListener('click', moveIt, false);

function moveIt(evt) {
  var elem = evt.target;

  if (Modernizr.csstransforms && Modernizr.csstransitions) {
    // vendor prefixes omitted here for brevity
    elem.style.transition = 'all 3s ease-out';
    elem.style.transform = 'translateX(600px)';

  } else {
    // if an older browser, fall back to jQuery animate
    jQuery(elem).animate({ 'left': '600px'}, 3000);
  }
}
</script>

我们使用 Modernizr 对 CSS 2D 转换和 CSS 过渡进行功能测试,如果可以,我们将使用 translate 来移位。如果此元素是使用转场效果进行动画处理的,浏览器很有可能可以对其进行硬件加速。为了让浏览器朝正确的方向再前进一步,我们将使用上面的“神奇 CSS 子弹”。

如果浏览器功能较弱,我们将回退到 jQuery 来移动元素。您可以使用 Louis-Remi Babe 的 jQuery Transform polyfill 插件,让整个过程自动化。

window.requestAnimationFrame

requestAnimationFrame 由 Mozilla 引入并由 WebKit 迭代开发,旨在为您提供用于运行动画的原生 API,无论这些动画是基于 DOM/CSS 还是基于 <canvas> 或 WebGL。浏览器可以将并发动画优化为单个重新流和重新绘制周期,从而实现更高保真度的动画。例如,与 CSS 转换或 SVG SMIL 同步的基于 JS 的动画。此外,如果您在不可见的标签页中运行动画循环,浏览器将不会让其保持运行状态,这意味着 CPU、GPU 和内存用量会减少,从而延长电池续航时间。

如需详细了解如何以及为什么使用 requestAnimationFrame,请参阅 Paul Irish 的文章使用 requestAnimationFrame 实现智能动画

性能分析

当您发现应用的速度可以提升时,就需要深入研究性能分析,找出哪些方面可以通过优化带来最大的好处。优化通常会对源代码的可维护性产生负面影响,因此仅应在必要时应用。通过性能分析,您可以了解哪些代码部分在提升性能后会带来最大的好处。

JavaScript 性能分析

JavaScript 性能分析器可通过衡量每个函数从开始到结束的执行时间,让您大致了解应用在 JavaScript 函数级别的性能。

函数的总执行时间是指从上到下执行该函数所需的总时间。净执行时间是总执行时间减去从该函数调用的函数的执行时间。

有些函数的调用频率高于其他函数。性能分析器通常会显示所有调用运行所用的时间,以及平均执行时间、最短执行时间和最长执行时间。

如需了解详情,请参阅 Chrome 开发者工具文档中的性能分析部分

DOM

JavaScript 的性能对应用的流畅度和响应速度有很大影响。请务必注意,虽然 JavaScript 性能分析器会衡量 JavaScript 的执行时间,但它们也会间接衡量执行 DOM 操作所花费的时间。这些 DOM 操作通常是导致性能问题的核心原因。

function drawArray(array) {
  for(var i = 0; i < array.length; i++) {
    document.getElementById('test').innerHTML += array[i]; // No good :(
  }
}

例如,在上面的代码中,几乎没有花费任何时间来执行实际的 JavaScript。drawArray 函数仍很可能会显示在您的配置文件中,因为它以非常浪费的方式与 DOM 交互。

提示和技巧

匿名函数

匿名函数不易进行性能分析,因为它们本身没有名称,无法在性能分析器中显示。此问题有两种解决方案:

$('.stuff').each(function() { ... });

重写为:

$('.stuff').each(function workOnStuff() { ... });

众所周知,JavaScript 支持为函数表达式命名。这样一来,它们就会在性能分析器中完美显示。此解决方案存在一个问题:命名表达式实际上会将函数名称放入当前词法作用域。这可能会破坏其他符号,因此请务必小心。

对长时间运行的函数进行性能分析

假设您有一个长函数,并且怀疑其中的一小部分代码可能是导致性能问题的原因。您可以通过以下两种方式找出问题所在:

  1. 正确方法:重构代码,使其不包含任何长函数。
  2. 邪恶的“完成工作”方法:将命名的自调用函数形式的语句添加到代码中。如果您稍加注意,这不会改变语义,并且会使函数的某些部分在性能分析器中显示为单独的函数:js function myLongFunction() { ... (function doAPartOfTheWork() { ... })(); ... } 请记得在性能分析完成后移除这些额外的函数;或者甚至可以将它们用作重构代码的起点。

DOM 性能分析

最新的 Chrome 网页检查器开发工具包含新的“时间轴视图”,该视图会显示浏览器执行的低级操作的时间轴。您可以使用这些信息优化 DOM 操作。您应力求减少浏览器在代码执行期间必须执行的“操作”数量。

时间轴视图可以生成大量信息。因此,您应尽量创建可独立执行的测试用例。

DOM 性能分析

上图显示了非常简单的脚本的时间轴视图输出。左侧窗格会按时间顺序显示浏览器执行的操作,而右侧窗格中的时间轴会显示各个操作实际所用的时间。

详细了解时间轴视图。在 Internet Explorer 中进行性能分析的替代工具是 DynaTrace Ajax Edition

配置文件策略

突出显示各个方面

当您想要分析应用的性能时,请尽可能找出可能导致应用运行缓慢的功能方面。然后,尝试运行配置文件,仅执行与应用的这些方面相关的代码部分。这样,性能分析数据就不会与与实际问题无关的代码路径混杂在一起,从而更易于解读。应用各个方面的良好示例可能包括:

  1. 启动时间(激活性能分析器、重新加载应用、等待初始化完成、停止性能分析器)。
  2. 点击按钮和随后的动画(启动性能分析器、点击按钮、等待动画完成、停止性能分析器)。
GUI 性能分析

与优化 3D 引擎的光线追踪器等相比,在 GUI 程序中仅执行应用的正确部分可能更难。例如,如果您想分析点击按钮时发生的情况,可能会触发不相关的鼠标悬停事件,导致结果不那么确定。请尽量避免这种情况 :)

程序化接口

此外,还有一个程序化接口可用于启用调试程序。这样一来,您就可以精确控制性能分析的开始时间和结束时间。

使用以下命令启动性能分析:

console.profile()

使用以下命令停止性能分析:

console.profileEnd()

重复性

进行性能分析时,请确保您能够实际重现结果。只有这样,您才能确定优化措施是否确实有所助益。此外,函数级性能分析是在整个计算机环境中完成的。这并不是一门精确科学。单次配置文件运行可能会受到计算机上发生的许多其他因素的影响:

  1. 您自己应用中的一个不相关的计时器,会在您测量其他内容时触发。
  2. 垃圾回收器正在工作。
  3. 浏览器中的另一个标签页在同一操作线程中执行繁重工作。
  4. 计算机上的其他程序占用了 CPU,导致应用运行速度变慢。
  5. 地球重力场的突然变化。

在一次性能分析会话中多次执行同一代码路径也是有意义的。这样可以降低上述因素的影响,并且运行缓慢的部分可能会更加明显。

衡量、改进、再衡量

在确定程序中存在运行缓慢的问题后,请尝试想出改进执行行为的方法。更改代码后,请重新进行配置。如果您对结果满意,请继续操作;如果没有看到任何改进,您可能应该还原更改,而不是因为“没关系”而保留更改。

优化策略

尽量减少 DOM 互动

提高 Web 客户端应用速度的常见做法是尽量减少 DOM 互动。虽然 JavaScript 引擎的速度提高了数个数量级,但访问 DOM 的速度并未以相同的速度加快。从实际角度来看,这种情况也永远不会发生(在屏幕上排版和绘制内容需要时间)。

缓存 DOM 节点

每当您从 DOM 检索节点或节点列表时,请尝试考虑是否可以在后续计算(甚至只是下一次循环迭代)中重复使用它们。只要您实际上没有在相关区域添加或删除节点,通常就会出现这种情况。

Before:

function getElements() {
  return $('.my-class');
}

之后:

var cachedElements;
function getElements() {
  if (cachedElements) {
    return cachedElements;
  }
  cachedElements = $('.my-class');
  return cachedElements;
}

缓存属性值

您可以通过与缓存 DOM 节点相同的方式缓存属性的值。假设您要为节点的样式属性添加动画效果。如果您知道自己(在代码的该部分中)是唯一会触及该属性的代码,则可以在每次迭代时缓存上次值,这样就不必反复读取该值。

Before:

setInterval(function() {
  var ele = $('#element');
  var left = parseInt(ele.css('left'), 10);
  ele.css('left', (left + 5) + 'px');
}, 1000 / 30);

之后:js var ele = $('#element'); var left = parseInt(ele.css('left'), 10); setInterval(function() { left += 5; ele.css('left', left + 'px'); }, 1000 / 30);

将 DOM 操作移出循环

循环通常是优化的重点。尝试想出方法来将实际数字运算与 DOM 处理分离。通常,您可以执行计算,然后在计算完成后一次性应用所有结果。

Before:

document.getElementById('target').innerHTML = '';
for(var i = 0; i < array.length; i++) {
  var val = doSomething(array[i]);
  document.getElementById('target').innerHTML += val;
}

之后:

var stringBuilder = [];
for(var i = 0; i < array.length; i++) {
  var val = doSomething(array[i]);
  stringBuilder.push(val);
}
document.getElementById('target').innerHTML = stringBuilder.join('');

重新绘制和重新流布局

如前所述,访问 DOM 的速度相对较慢。如果您的代码最近修改了 DOM 中的相关内容,那么当代码读取必须重新计算的值时,速度会变得很慢。因此,应避免混合使用对 DOM 的读写访问权限。理想情况下,您的代码应始终分为两个阶段:

  • 第 1 阶段:读取代码所需的 DOM 值
  • 第 2 阶段:修改 DOM

请勿编写以下模式:

  • 第 1 阶段:读取 DOM 值
  • 第 2 阶段:修改 DOM
  • 第 3 阶段:阅读更多
  • 第 4 阶段:在其他位置修改 DOM。

Before:

function paintSlow() {
  var left1 = $('#thing1').css('left');
  $('#otherThing1').css('left', left);
  var left2 = $('#thing2').css('left');
  $('#otherThing2').css('left', left);
}

之后:

function paintFast() {
  var left1 = $('#thing1').css('left');
  var left2 = $('#thing2').css('left');
  $('#otherThing1').css('left', left);
  $('#otherThing2').css('left', left);
}

对于在一个 JavaScript 执行上下文中发生的操作,应考虑此建议。(例如,在事件处理程序内、在间隔处理程序内或处理 ajax 响应时)。

执行上述 paintSlow() 函数会创建以下图片:

paintSlow()

改用更快的实现会生成以下图片:

加快实现速度

这些图片显示,重新排列代码访问 DOM 的方式可以显著提升渲染性能。在这种情况下,原始代码必须重新计算样式并两次布局页面,才能生成相同的结果。类似的优化基本上可以应用于所有“实际”代码,并会取得非常显著的成效。

了解详情:Stoyan Stefanov 撰写的渲染:重绘、重新流布局/重新布局、重新设置样式

重新绘制和事件循环

浏览器中的 JavaScript 执行遵循“事件循环”模型。默认情况下,浏览器处于“空闲”状态。此状态可能会因用户互动事件或 JavaScript 计时器或 Ajax 回调等事件而中断。每当一段 JavaScript 代码在这样的中断点运行时,浏览器通常都会等待其完成,然后再重新绘制屏幕(对于运行时间极长的 JavaScript 代码或有效中断 JavaScript 执行的警报框等情况,可能存在例外情况)。

后果

  1. 如果 JavaScript 动画周期的执行时间超过 1/30 秒,您将无法创建流畅的动画,因为浏览器不会在 JS 执行期间重新绘制。如果您还希望处理用户事件,则需要更快地处理。
  2. 有时,延迟执行某些 JavaScript 操作会很有用。例如 setTimeout(function() { ... }, 0) 这实际上会告知浏览器在事件循环再次空闲后立即执行回调(实际上,某些浏览器会至少等待 10 毫秒)。请注意,这将创建两个时间非常接近的 JavaScript 执行周期。这两种情况都可能会触发屏幕重绘,这可能会使绘制所花费的总时间翻倍。这是否会实际触发两次绘制取决于浏览器中的启发词语。

常规版本:

function paintFast() {
  var height1 = $('#thing1').css('height');
  var height2 = $('#thing2').css('height');
  $('#otherThing1').css('height', '20px');
  $('#otherThing2').css('height', '20px');
}
重新绘制和事件循环

我们来添加一些延迟:

function paintALittleLater() {
  var height1 = $('#thing1').css('height');
  var height2 = $('#thing2').css('height');
  $('#otherThing1').css('height', '20px');
  setTimeout(function() {
    $('#otherThing2').css('height', '20px');
  }, 10)
}
延迟

延迟版本显示,浏览器绘制了两次,尽管页面上的这两次更改仅相隔 1/100 秒。

延迟初始化

用户希望 Web 应用能够快速加载并具有良好的响应能力。不过,用户对“慢”的认知阈值因所执行的操作而异。例如,应用绝不应对鼠标悬停事件执行大量计算,因为这可能会在用户继续移动鼠标时造成糟糕的用户体验。不过,用户习惯于在点击按钮后接受一点延迟。

因此,您不妨将初始化代码移至尽可能晚的时间(例如,在用户点击用于激活应用的特定组件的按钮时)执行。

之前:js var things = $('.ele > .other * div.className'); $('#button').click(function() { things.show() });

之后:js $('#button').click(function() { $('.ele > .other * div.className').show() });

事件委托

将事件处理脚本分散到整个页面上可能需要相对较长的时间,而且在元素被动态替换后,还需要将事件处理脚本重新附加到新元素,这可能很繁琐。

在这种情况下,解决方案是使用一种称为事件委托的技术。您可以通过实际将事件处理程序附加到父节点并检查事件的目标节点,来利用许多浏览器事件的冒泡特性,而不是将单独的事件处理程序附加到元素。这样,您就可以确定事件是否符合您的需求。

在 jQuery 中,这可以轻松表示为:

$('#parentNode').delegate('.button', 'click', function() { ... });

何时不应使用事件委托

有时情况恰恰相反:您使用的是事件委托,但却遇到了性能问题。从本质上讲,事件委托可确保初始化时间的复杂性保持不变。不过,每次调用事件时,都必须付出检查事件是否感兴趣的代价。这可能会导致开销很高,尤其是对于频繁发生的事件(例如“mouseover”或“mousemove”)。

典型问题和解决方案

我在 $(document).ready 中执行的操作需要很长时间

Malte 的个人建议:切勿在 $(document).ready 中执行任何操作。请尽量提交最终版本的文件。好的,您可以注册事件监听器,但只能使用 id 选择器和/或事件委托。对于“mousemove”等开销较大的事件,请延迟注册,直到需要时再注册(在相关元素上发生 mouseover 事件)。

如果您确实需要执行操作(例如发出 Ajax 请求以获取实际数据),则可以显示精美的动画;如果动画是 GIF 动画或类似内容,您可能需要将其作为数据 URI 包含在内。

自从我在网页中添加了 Flash 影片后,所有内容都非常缓慢

向网页添加 Flash 始终会略微减慢渲染速度,因为窗口的最终布局必须在浏览器和 Flash 插件之间进行“协商”。如果您无法完全避免在网页上使用 Flash,请务必将“wmode”Flash 参数设置为“window”(默认值)。这会停用合成 HTML 和 Flash 元素的功能(您将无法看到位于 Flash 影片上方的 HTML 元素,并且 Flash 影片无法透明显示)。这可能会给您带来不便,但会显著提升您的广告效果。例如,请查看 youtube.com 如何谨慎避免将层放置在主电影播放器上方。

我将内容保存到 localStorage,现在我的应用出现卡顿

写入 localStorage 是一项涉及启动硬盘的同步操作。切勿在执行动画时执行“长时间运行”同步操作。将对 localStorage 的访问权限移至您确信用户处于空闲状态且没有任何动画正在运行的代码位置。

性能分析指向 jQuery 选择器运行缓慢

首先,您需要确保您的选择器可以通过 document.querySelectorAll 运行。您可以在 JavaScript 控制台中进行测试。如果存在异常,请重写选择器,以免使用 JavaScript 框架的任何特殊扩展。这会使选择器在现代浏览器中的速度提高一个数量级。

如果这不起作用,或者您还希望在新型浏览器中获得快速的加载速度,请遵循以下准则:

  • 请尽可能在选择器右侧提供具体信息。
  • 将不常用的标记名称用作最右侧的选择器部分。
  • 如果所有方法都行不通,请考虑重写代码,以便使用 id 选择器

所有这些 DOM 操作都需要很长时间

大量 DOM 节点插入、移除和更新操作可能会非常缓慢。通常,可以通过生成大量的 HTML 字符串并使用 domNode.innerHTML = newHTML 替换旧内容来优化此问题。请注意,这可能会严重影响可维护性,并可能会在 IE 中创建内存链接,因此请务必小心。

另一个常见问题是,初始化代码可能会创建大量 HTML。例如,一个 jQuery 插件会将一个选择框转换为一堆 div,因为设计人员希望这样做,却不了解用户体验最佳实践。如果您真的希望网页加载速度快,请勿这样做。而是以最终形式从服务器端提交所有标记。这同样会带来很多问题,因此请仔细考虑速度是否值得付出代价。

工具

  1. JSPerf - 对小段 JavaScript 代码进行基准测试
  2. Firebug - 用于在 Firefox 中进行性能分析
  3. Google Chrome 开发者工具(在 Safari 中可用作 WebInspector)
  4. DOM Monster - 用于优化 DOM 性能
  5. DynaTrace Ajax Edition - 用于在 Internet Explorer 中进行性能分析和绘制优化

深入阅读

  1. Google Speed
  2. Paul Irish 讲解 jQuery 性能
  3. 极致 JavaScript 性能(幻灯片)