提升 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 有两个实用的命令行 flag,可帮助调试 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。

现在,许多库都引入了 Animation API,这些 API 会利用过渡(如果存在),否则会回退到标准 DOM 样式动画。scripty2、YUI 过渡、jQuery 动画增强。

CSS3 translate

相信您之前一定遇到过需要为元素在页面上的 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 Web Inspector 开发者工具包含新的“时间轴视图”,该视图会显示浏览器执行的底层操作的时间轴。您可以使用这些信息来优化 DOM 操作。您应尽量减少浏览器在执行代码时必须执行的“操作”数量。

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

DOM 分析

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

详细了解时间轴视图。在 Internet Explorer 中,DynaTrace Ajax Edition 是另一种性能分析工具。

性能剖析策略

挑出切面

在对应用进行性能分析时,请尽量找出可能导致应用运行缓慢的功能。然后,尝试进行性能分析运行,仅执行与应用的这些方面相关的代码部分。这样一来,性能分析数据将更易于解读,因为它不会与和实际问题无关的代码路径混杂在一起。以下是应用各个方面的正面示例:

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

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

程序化界面

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

开始分析:

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 请求以获取实际数据),然后显示一个漂亮的动画,那么您可能需要将该动画作为数据 URI 包含在内(如果它是 GIF 动画或类似动画)。

自从我在网页中添加了 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 性能(演示文稿)