本文深入解析AngularJS应用中的常见性能瓶颈,涵盖算法复杂度、DOM访问、垃圾回收及核心机制如Digest Cycle与Watchers。通过长列表缓存、聚合监听器及延迟渲染等实战案例,展示如何将页面响应从1.2秒优化至35毫秒,提供具体的代码实现与优化策略。
AngularJS性能优化概述
1. 简介
无论您是在维护遗留系统还是在大型项目中引入AngularJS,性能始终是关键考量。理解导致应用变慢的根本原因至关重要,因为在开发过程中进行权衡取舍是不可避免的。本文将探讨AngularJS中常见的性能问题,并提供相应的优化建议。
2. 性能测试工具
本文使用jsPerf(http://jsperf.com/)作为性能基准测试工具。
软件与JavaScript性能基础
3. 软件性能基础
评估软件性能主要基于两个基本因素:
首先是算法的时间复杂度。例如,线性搜索与二分查找之间存在显著的性能差异。
其次是空间复杂度,即应用程序运行所需的内存“空间”。内存占用越高,运行速度通常越慢。
4. JavaScript层面的性能考量
部分性能问题并非AngularJS特有,而是JavaScript语言本身的特性所致。
4.1 循环优化
应避免在循环内部调用函数,建议将函数调用移至循环外部执行。
var sum = 0;
for(var x = 0; x < 100; x++){
var keys = Object.keys(obj);
sum = sum + keys[x];
}
上述做法明显不如以下代码高效:
var sum = 0;
var keys = Object.keys(obj);
for(var x = 0; x < 100; x++){
sum = sum + keys[x];
}
4.2 DOM访问优化
在获取DOM元素时需格外谨慎。
angular.element('div.elementClass')
这种方式开销极大。虽然在AngularJS中这通常不会造成严重问题,但保持警惕仍有好处。应尽量减小DOM树规模,并尽可能减少DOM访问次数。
4.3 变量作用域与垃圾回收
将变量作用域限制得越紧密越好,以便垃圾回收器能更快释放空间。需注意以下问题:
function demo(){
var b = {childFunction: function(){
console.log('hi this is the child function')
};
b.childFunction();
return b;
}
当函数结束时,此处不再有对b的引用,b将被回收。但如果存在如下代码:
var cFunc = demo();
该引用将阻止垃圾回收。应尽量避免此类引用。
4.4 数组与对象处理
这里有多个关键点:
例如:
for (var x=0; x<arr.length; x++) {
i = arr[x].index;
}
比这种方式稍快(注:arr为数组,obj为JSON对象)
for (var x=0; x<100; x++) {
i = obj[x].index;
}
比这种方式更快一点
var keys = Object.keys(obj);
for (var x = 0; x < keys.length; x++){
i = obj[keys[x]].index;
}
AngularJS核心机制解析
5. 核心概念解析
我们已讨论JavaScript层面的性能,现在有必要审视AngularJS的核心概念,了解其运行机制。
5.1 作用域(Scopes)与更新周期(Digest Cycle)
Angular的作用域本质上是继承自预定义对象的JavaScript对象。一般而言,较小的作用域运行速度更快。
换言之,每创建一个新的作用域,都会增加垃圾回收器待处理的内容。
在开发AngularJS应用时,需特别关注一个核心概念及其对性能的影响:更新周期(Digest Cycle)。实际上,每个作用域都包含一个由方法组成的数组$$watchers。
每当作用域中的值(属性)或绑定的DOM元素(如ng-repeat、ng-switch和ng-if等)调用$watch时,一个函数会被添加到对应作用域的$$watchers数组队列中。
当作用域中的值发生变化时,$$watchers中的所有监听函数都会被触发调用。如果其中任何函数修改了作用域中的某个值,它们将被再次触发执行。
此过程会持续循环,直到$$watchers数组队列中不再发生任何更改或抛出异常为止。
此外,如果执行任何代码调用$scope.$apply(),都会触发更新周期。
最后一点是$scope.evalAsync()会在异步调用中执行,且在当前及下一个执行周期内不会调用其更新周期。
Angular设计准则与常见问题
6. Angular设计的一般准则
6.1 大型对象与服务器调用
这告诉我们什么?首先,我们要尽可能简化对象。当对象从服务器返回时,这一点尤为重要。
直接将数据库中的一行转换为对象只是临时方案,因此不要使用.toJson()。
只需返回Angular所需的属性值。
6.2 监听函数(Watching Functions)
另一个常见问题是为观察者绑定的函数。不要将任何内容(如ng-show、ng-repeat等)直接绑定到函数上。不要直接监视任何函数的返回值。该函数会在每个更新周期执行,可能会降低应用速度。
6.3 监听对象(Watching Objects)
同样,Angular提供了第三个可选参数来监视整个对象的改动。将调用$watch的第三个参数设为true。这是一个非常糟糕的想法。更好的解决办法是依靠服务和对象引用,监视作用域之间的变化。
列表与渲染性能优化
7. 列表相关问题
7.1 长列表(Lists)
尽可能避免长列表。ng-repeat会进行大量的DOM操作(更不用说对$$watchers的污染),因此无论是在分页还是无限滚动中,尽量使用小型数据进行渲染。
7.2 过滤器(Filters)
要尽量避免使用过滤器。它们会在每个更新周期运行两次:每当发生任何改变时运行一次,另一次是收集更深层次的改变时触发。因此,不要直接从内部列表中移除对象,使用CSS控制即可。(注:通过添加CSS类名来隐藏它们)
渲染时的$index值并不是真正的数组索引值,它毫无价值。但是排好序的数组索引,无法让你遍历到所有列表中的作用域。
7.3 更新ng-repeat
使用ng-repeat时,应尽量避免对全局列表进行刷新。ng-repeat会产生一个$$hashkey属性和一系列唯一的项。这意味着当你调用scope.listBoundToNgRepeat = serverFetch()时,会引起对整个列表的重新刷新。这会通知执行所有的watchers并触发每一个元素,这是非常消耗性能的。
这里有两种解决方案。一种是维护两个集合,并与带有过滤器(filter)的ng-repeat配合使用(基本上需要自定义同步逻辑,因此算法更复杂,可维护性更差);另一种方案是使用track by去指定你自己的key(Angular 1.2开始支持,只需要很少的同步逻辑)。
总之:
scope.arr = mockServerFetch();
比下面的这种慢
var a = mockServerFetch();
for(var i = scope.arr.length - 1; i >=0; i--){
var result = _.find(a, function(r){
return (r && r.trackingKey == scope.arr[i].trackingKey);
});
if (!result){
scope.arr.splice(i, 1);
} else {
a.splice(a.indexOf(scope.arr[i]), 1);
}
}
_.map(a, function(newItem){
scope.arr.push(newItem);
});
这种
<div ng-repeat="a in arr track by a.trackingKey">
比上面的慢些
<div ng-repeat="a in arr">
8. 渲染问题
另一个导致Angular应用变慢的原因是不正确地使用ng-hide/ng-show或ng-switch。
ng-hide和ng-show简单地切换CSS display属性。这意味着表面上看不见的东西其实还存在于作用域中,所有的$$watchers仍会被触发。
ng-if和ng-switch实际上从DOM中完全移除,相应的作用域也会被移除。性能差异显而易见。
更新周期与指令优化策略
9. 更新周期相关问题
9.1 绑定
尽量减少你的绑定。在Angular 1.3中,这里有一个新的一次绑定语法,{{::scopeValue}}。它只会被作用域执行一次,并不添加到监视器要监视列表中(watcher array)。
9.2 $digest() 和 $apply()
scope.$apply是一个强大的工具,可以让你向Angular引入外部的值。本质上它会触发Angular的所有事件(例如ng-click)。问题是scope.$apply会从根作用域$rootScope开始,遍历所有作用域链,触发每一个作用域。
scope.$digest只会执行指定作用域及其相关的作用域。两种性能差异不言自明。折中的方案是,不触发任何作用域,等到下一个更新周期再更新。
9.3 $watch()
scope.$watch()已在很多场景被讨论过。基本上,scope.$watch是不好的设计的一个标志。如果你非要创建一个观察者,记住对它尽可能地解绑。你可以用$watch的返回函数解绑。
var unbinder = scope.$watch('scopeValueToBeWatcher', function(newVal, oldVal) {
});
unbinder(); //这一行将watcher从 $$watchers 中移除。
如果你不能早点解绑,记住在$on('$destroy')中进行解绑。
9.4 $on, $broadcast 和 $emit
像$watch一样,它们都是一些很慢的事件(有可能)遍历整个作用域。它们可能像GOTO一样,让你的程序无法调试。不过幸运的是,像$watch一样,它们都可以在完全不必要的时候解绑。比如在$on('$destroy')中。
9.5 $destroy
像前面提到的那样,你应该在$on('$destroy')中解绑你所有的事件监听器,取消任何$timeout的实例,或者任何其他异步执行的交互。这不仅仅是确保安全。还可以让你的作用域更快地被垃圾回收。不这样做,它们会一直在后台运行,直接耗尽CPU和RAM。
另外,解绑DOM上的事件监听器也非常重要,不这样做很可能在老式浏览器中引起内存泄露。
9.6 $evalAsync
scope.$evalAsync是一个强大的工具。它可以在当前作用域中执行,并不触发作用域的更新。evalAsync可以极大地提高你网页的性能。
10. 指令相关问题
10.1 隔离作用域(Isolate Scope)和Transclusion
作用域隔离和Transclusion是Angular最令人激动的特性,它们是Angular的核心组件。
但是这里也有一些权衡,指令不能直接创建一个替换他们父组元素的作用域。通过隔离作用域或Transclusion我们可以创建一个新的对象去跟踪,添加新的监视器,但这也会降低应用的性能。在添加之前应该仔细想一想有没有这个必要。
10.2 编译周期
指令(Directive)的compile函数是在作用域被附加前操作DOM的完美功能(比如说绑定事件)。一个很重要的性能方面是,传入compile函数的元素和属性以原始html模板呈现。只会被运行一次,接下来会直接使用。另外一个重要的点是prelink和postlink的区别。prelink从外向内执行。postlinks从内向外执行。prelink性能稍好一些,因为它不会产生第二次更新周期。但是这时子元素的DOM还未被创建。
11. DOM事件相关问题
Angular提供了很多预定义的DOM事件指令。ng-click,ng-mouseenter,ng-mouseleave等等。当调用scope.$apply()时这些事件都会被执行。另外一种更有效率的方式是直接在DOM上面绑定addEventListener,并且尽量使用scope.$digest
实战案例:日志视图性能优化
优化实例
测试一个应用框架确实是个严峻的挑战,当用户点击日志中任何一个单词,我们就要搜索出相关信息,而页面上可以点击的元素又不计其数;我们想让日志的分页功能也瞬间得到反馈。我们其实已经预先获取到了下一页面的日志数据,所以用户接口的更新就成为了瓶颈,如果拿AngularJS直接实现日志视图的换页功能需要1.2秒,但是如果仔细优化一下的话就可以降到35毫秒。这些优化被证明在应用的其他部分也是适用的,并且对AngularJS适应性也很好。但我们必须打破一些规则来实现我们的想法,稍后讨论。

一个Github更新的日志demo
An AngularJS log viewer
本质上,日志视图就是一个日志消息的列表,每个字都可以点击。所以把Angular的指令加到DOM元素中,简单实现如下:
<span class='logLine' ng-repeat='line in logLinesToShow'>
<span class='logToken' ng-repeat='token in line'>{{token | formatToken}} </span>
</span>
在单页面应用中有数千个tokens是很正常的,在早期的测试中,我们发现进入日志的下一页会花费好几秒来执行JavaScript。更糟的是,不相关的操作(比如点击导航下拉框)延迟也不轻,AngularJS的大神说最好把数据元素绑定的数量控制在200以下。对于一个单词就是一个元素的我们来说,早已远超这个数。
分析:
用Chrome的JavaScript profiler工具,我们可以快速定位两个拖延点。首先,每次更新要花大量时间在DOM元素的创建和销毁上,如果新的view有不同的行数,或者任何一行有不同数量单词,Angular的ng-repeat指令就会创建或者销毁DOM元素,这个代价太大了。
其次,每一个单词都有自己的change watcher,AngularJS会watch这些单词,一旦鼠标点击就会触发,这个是影响不相关操作(下拉菜单导航)延迟的罪魁祸首。
优化#1:缓存DOM elements
我们创建了一个ng-repeat指令的变体,在我们的版本中,如果绑定数据的数量减少了,超出的DOM元素会隐藏而不是销毁,如果元素的数量过会儿有增加了,我们会重用这些缓存的元素。
优化#2:Aggregate watchers
用来调用change watchers的所有时间大部分都浪费了,在我们的应用中,特定单词上的数据绑定都是永远不会改变的除非整个日志消息变化,为了达成这一点,我们创建了一个指令”hides“隐藏掉了子元素的change watchers,只有等特定父元素表达式修改的时候才会调用他们。就这样,我们避免了在每一次鼠标点击或者其他微小的修改而导致的全盘change watchers(为了实现这个想法,我们稍微修改了AngularJS的抽象层,我们稍后再细说)。
优化#3:推迟元素创建
前面说了,我们为日志里的每一个单词单独创建了DOM,我们可以利用每一行的单个DOM元素得到相同的视觉呈现;其他元素都是为响应鼠标点操作而创建的,因此,我们决定推迟这部分创建,只有当鼠标移动到某行的时候我们再创建他。
为了实现这个,我们为每一行创建了两个版本,一个就是简单的文本元素来显示完整的日志信息,另外一行就是个占位符,用来显示最终为每一个单词填充后的效果。这个占位符开始是隐藏的,当鼠标移动到那一行的时候才会显示,而简单文本那一行这个时候就隐藏掉。下面会讲到,显示占位符是如何填充单词元素的。
优化#4:避开对隐藏元素的监视
我们创建了另外一个指令,用来阻止对隐藏元素的监视,这个指令支持优化#1,相较于原数据,我们多了更多的隐藏DOM节点,所以必须消除对多出来的DOM节点的监视。这也支持优化#3,让推迟单词节点的创建更加容易。因为直到这行数据的tokenized版本出现我们才会创建他 。
下面的代码就是所有的优化后的样子,我们自定义的指令是粗体显示。
<span class='logLine' sly-repeat='line in logLinesToShow' sly-evaluate-only-when='logLines'>
<div ng-mouseenter=”mouseHasEntered = true”>
<span ng-show='!mouseHasEntered'>{{logLine | formatLine }} </span>
<div ng-show='mouseHasEntered' sly-prevent-evaluation-when-hidden>
<span class='logToken' sly-repeat='tokens in line'>{{token | formatToken }}</span>
</div>
</div>
</span>
Sly-repeat是ng-repeat的变体,用来隐藏多出来的DOM元素而不是销毁他们,sly-evaluate-only-when阻止内部change watchers除非“logLines”变量修改,sly-prevent-evaluation-when-hidden主要负责当鼠标移动到指定行的上面的时候,隐藏的div才显示。
这里展示出了AngularJS对于封装和分离的控制力,我们做了复杂的优化但是并没有影响模板的结构(这里展示的代码并不是真正产品里的代码,但是他展示了所有的要点)。
结果:
我们来看一下效果,我们添加了一些代码来衡量,从鼠标点击开始,一直到Angular's $digest循环结束(意味着更新DOM结束)。
我们衡量点击”下一页“按钮的性能是通过Tomcat日志,环境用的是MacBook Pro上的Chrome,结果见下表(每个数据都是10次测试的平均值):
| 数据已经缓存 | 从服务器获取数据 | |
| 简单实现 | 1190 ms | 1300 ms |
| 优化后 | 35 ms | 201 ms |
这些数据不包括浏览器用在DOM布局和重绘(JavaScript执行完成后)的时间,每次大概30毫秒。尽管如此,效果也显而易见;下一页的响应时间从1200毫秒骤降至35毫秒(如果算上渲染是65毫秒)。
“从服务器获取数据”里的数据包括了我们使用AJAX从后端获取log数据的时间。这个跟点击下一页按钮不同,因为我们预取下一页的log数据,但是或许适用于其他的UI响应。即使这样,优化后的程序也可以做到实时更新。