提高 HTML5 应用的性能

Malte Ubl
Malte Ubl

简介

HTML5 为我们提供了强大的工具,可以增强网络应用的视觉外观。在动画领域尤其如此。然而,随着这种新功能的出现,也带来了新的挑战。实际上,这些挑战并没有那么新,有时我们可能需要问问身边的同伴 Flash 编程人员,了解她以前是如何克服类似问题的。

无论如何,当您处理动画时,让用户感觉这些动画流畅性就变得非常重要。我们需要认识到,仅仅将每秒帧数提高到任何认知阈值之外,并不能真正创造出动画的流畅性。不幸的是,我们的大脑比这还聪明。您会发现,真实的每秒 30 帧 (fps) 动画远远优于 60 帧/秒(中途丢了几帧)。大家都讨厌锯齿状。

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

策略

我们绝对不鼓励您使用 HTML5 构建令人惊叹的视觉应用。

之后,当您发现性能有待改进时,请返回此处阅读如何改进应用元素。当然,从一开始它可以帮助您做一些事情,但绝不会因为这样而妨碍您的工作效率。

HTML5 视觉保真度++

硬件加速

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

文档的这些方面都可以通过 GPU 加速

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

虽然画布和 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 位置添加了动画效果。您可能操控了内联样式的左侧和顶部属性。通过 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 转换进行功能测试,如果是,我们将使用变形来改变位置。如果这是使用过渡效果的动画,浏览器很有可能对其进行硬件加速。为了进一步推动浏览器朝正确的方向前进,我们将使用上文所述的“CSS 灵丹妙药”。

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

window.requestAnimationFrame

requestAnimationFrame 由 Mozilla 引入,经过 WebKit 的迭代,旨在为您提供用于运行动画(无论是基于 DOM/CSS,还是基于 <canvas> 或 WebGL)的原生 API。浏览器可以将多个并发动画一起优化到单个自动重排和重绘循环中,从而实现更高保真度的动画。例如,基于 JS 的动画与 CSS 过渡或 SVG SMIL 同步。此外,如果您在不可见的标签页中运行动画循环,浏览器不会使其保持运行状态,这会减少 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 分析

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

详细了解时间轴视图。您还可以使用 DynaTrace Ajax 版本在 Internet Explorer 中分析数据。

分析策略

挑选出各方面

在分析应用时,请尽可能尝试找出其功能中可能会触发运行缓慢的方面。然后,尝试运行配置文件,以便仅执行与应用的这些方面相关的部分代码。这样会使分析数据更易于解释,因为它不会与与您的实际问题无关的代码路径混合在一起。例如,可以针对您的应用的各个方面执行以下操作:

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

在 GUI 程序中,只执行正确的部分会比优化 3D 引擎的光线追踪程序更困难。例如,当您想分析点击某个按钮后发生的情况时,可能会触发不相关的鼠标悬停事件,从而降低结果的结论。请尽量避免这种情况 :)

编程接口

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

使用以下命令启动分析:

console.profile()

使用以下命令停止分析:

console.profileEnd()

可重复性

在进行分析时,请确保您可以实际重现结果。只有这样,您才能判断自己的优化措施是否确实实现了效果提升。此外,函数级别的分析是在整台计算机环境中完成的。这并不能做到完全精确。每份个人资料的运行可能会受到计算机上许多其他因素的影响:

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

此外,在一个性能分析会话中多次执行同一代码路径也是合理的。这样能减少上述因素的影响,而且速度较慢的部分可能会更加明显。

衡量、改进、衡量

如果您发现程序中存在慢点,请尝试思考改进执行行为的方法。更改代码后,请重新进行分析。如果您对结果感到满意,请继续后续操作;如果您没有发现任何改进,应该回滚更改,不要将其保留“因为这不会产生负面影响”。

优化策略

尽可能减少 DOM 交互

提高网络客户端应用速度的一个常见主题就是尽量减少 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 执行环境中发生的操作,应考虑此建议。(例如,在事件处理程序中、在间隔处理程序中或处理 Alex 响应时)。

执行上述函数 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”),应延迟注册,直到需要它们为止(相关元素上的鼠标悬停事件)。

如果您确实需要执行一些操作(例如发出 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。例如,一个将选择框转换为一组 div 的 jQuery 插件,因为这正是人们在忽略用户体验最佳实践时所希望的设计。如果您确实希望网页提速,千万别这么做。而应以最终形式从服务器端提供所有标记。这同样会带来很多问题,因此请认真考虑,在速度方面是否值得牺牲。

工具

  1. JSPerf - 对 JavaScript 的小片段进行基准测试
  2. Firebug - 在 Firefox 中用于分析
  3. Google Chrome 开发者工具(在 Safari 中以 WebInspector 的形式提供)
  4. DOM Monster - 用于优化 DOM 性能
  5. DynaTrace Ajax 版本 - 用于在 Internet Explorer 中优化性能分析和绘制

深入阅读

  1. Google 速度
  2. Paul Irish 谈 jQuery 性能
  3. 极致 JavaScript 性能(幻灯片)