作者:Jiang, Jilin
AngularJS中,通过数据绑定。可以十分方便的构建页面。但是当面对复杂的循环嵌套结构时,渲染会遇到性能瓶颈。今天,我们将通过一些列实验,来测试AngularJS的渲染性能,对比ng-show,ng-if的使用场景。并对优化进行简要分析。
不过在此之前,我们需要先简单过一遍AngularJS相关的代码:
$apply: function(expr) {
try {
beginPhase('$apply');
try {
return this.$eval(expr);
} finally {
clearPhase();
}
} catch (e) {
$exceptionHandler(e);
} finally {
try {
$rootScope.$digest();
} catch (e) {
$exceptionHandler(e);
throw e;
}
}
},
beginPhase和clearPhase用于对$rootScope.$$phase进行锁定。如果发现重复进入$apply阶段则抛出异常,以免出现死循环。
$eval: function(expr, locals) {
return $parse(expr)(this, locals);
},
$parse调用的是$ParseProvider。由于之后的实验expr不传值,所以$ParseProvider会直接返回空函数noop() {}。因此我们就不做具体的$ParseProvider内容分析了。
在执行完$eval后,会调用$digest方法。让我们看看$digest里有些什么:
$digest: function() {
var watch, value, last,
watchers,
length,
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
logIdx, logMsg, asyncTask;
beginPhase('$digest');
// Check for changes to browser url that happened in sync before the call to $digest
$browser.$$checkUrlChange();
if (this === $rootScope && applyAsyncId !== null) {
// If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then
// cancel the scheduled $apply and flush the queue of expressions to be evaluated.
$browser.defer.cancel(applyAsyncId);
flushApplyAsync();
}
lastDirtyWatch = null;
do { // "while dirty" loop
dirty = false;
current = target;
while (asyncQueue.length) {
try {
asyncTask = asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals);
} catch (e) {
$exceptionHandler(e);
}
lastDirtyWatch = null;
}
traverseScopesLoop:
do { // "traverse the scopes" loop
if ((watchers = current.$$watchers)) {
// process our watches
length = watchers.length;
while (length--) {
try {
watch = watchers[length];
// Most common watches are on primitives, in which case we can short
// circuit it with === operator, only when === fails do we use .equals
if (watch) {
if ((value = watch.get(current)) !== (last = watch.last) &&
!(watch.eq
? equals(value, last)
: (typeof value === 'number' && typeof last === 'number'
&& isNaN(value) && isNaN(last)))) {
dirty = true;
lastDirtyWatch = watch;
watch.last = watch.eq ? copy(value, null) : value;
watch.fn(value, ((last === initWatchVal) ? value : last), current);
if (ttl < 5) {
logIdx = 4 - ttl;
if (!watchLog[logIdx]) watchLog[logIdx] = [];
watchLog[logIdx].push({
msg: isFunction(watch.exp) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp,
newVal: value,
oldVal: last
});
}
} else if (watch === lastDirtyWatch) {
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
// have already been tested.
dirty = false;
break traverseScopesLoop;
}
}
} catch (e) {
$exceptionHandler(e);
}
}
}
// Insanity Warning: scope depth-first traversal
// yes, this code is a bit crazy, but it works and we have tests to prove it!
// this piece should be kept in sync with the traversal in $broadcast
if (!(next = ((current.$$watchersCount && current.$$childHead) ||
(current !== target && current.$$nextSibling)))) {
while (current !== target && !(next = current.$$nextSibling)) {
current = current.$parent;
}
}
} while ((current = next));
// `break traverseScopesLoop;` takes us to here
if ((dirty || asyncQueue.length) && !(ttl--)) {
clearPhase();
throw $rootScopeMinErr('infdig',
'{0} $digest() iterations reached. Aborting!\n' +
'Watchers fired in the last 5 iterations: {1}',
TTL, watchLog);
}
} while (dirty || asyncQueue.length);
clearPhase();
while (postDigestQueue.length) {
try {
postDigestQueue.shift()();
} catch (e) {
$exceptionHandler(e);
}
}
},
同样的,调用beginPhase改变阶段。
$browser.$$checkUrlChange()用于检测url是否变更,这次我们也用不到:
function fireUrlChange() {
if (lastBrowserUrl === self.url() && lastHistoryState === cachedState) {
return;
}
lastBrowserUrl = self.url();
lastHistoryState = cachedState;
forEach(urlChangeListeners, function(listener) {
listener(self.url(), cachedState);
});
}
接着进行$rootScope和applyAsyncId判断。如果是根Scope并且存在异步apply请求,则调用$eval并把队列清空。也不是本次需要用到的部分。
进入循环,asyncQueue保存了$evalAsync方法的数据。用不到。
之后设置了一个断点,用于跳出内部循环:
traverseScopesLoop:
循环内判断是否存在$$watchers列表,然后对watch单元进行变更匹配。每个页面的数据绑定都会对应到一个watch单元。此处会检查是否watch是深匹配,如果为真会调用equals方法进行递归检查,如果watch了一个巨大的对象,那么equals会十分消耗性能。反之,则会检查是否是NaN,js中NaN != NaN,然而如果原值和现值都是NaN,其实是没有变更过的。
if (watch) {
if ((value = watch.get(current)) !== (last = watch.last) &&
!(watch.eq
? equals(value, last)
: (typeof value === 'number' && typeof last === 'number'
&& isNaN(value) && isNaN(last)))) {
如果循环后已经发现watch单元原值和现值相等,会跳出循环。再次重新验证,目的是为了防止某个watch调用回调函数后,使得之前的watch现值发生变化。而其中也设置了ttl循环计数,以免出现watch不断改变产生死循环的问题。
接着,就是著名的crazy注释了:
// Insanity Warning: scope depth-first traversal
// yes, this code is a bit crazy, but it works and we have tests to prove it!
// this piece should be kept in sync with the traversal in $broadcast
此处会深度优先遍历,然后重复上面的检查。直到遍历结束。作者很贴心的标注一下循环结束了:
// `break traverseScopesLoop;` takes us to here
后面的代码就十分好懂了,clearPhase。然后处理DigestQueue结束循环。
之后检查ttl数值,如果ttl值超出了10次(预设值),则会抛出过多循环的异常。
实验
简单的过了一遍代码后,我们开始做一下性能测试:(注:由于不同机器配置性能不同,渲染时间仅作横向对比之用)
现在,假设我们拥有2个用户组,每组用户拥有1000个用户信息。用户信息如下:
[{name: "user1"}, {name:"user2"},...]
我们第一步做最简单未经过优化的渲染:
<div>
<div ng-repeat="user in userList">
<label>Name</label>
<p>{{user.name}}</p>
</div>
</div>
切换分组渲染时间平均310ms左右。
track by
然后简单使用优化track by优化:
ng-repeat="user in userList track by $index"
第一次渲染260ms左右。之后切换耗费11ms左右。
效果不错。接着,我们比较不同长度的数组切换比较。假设用户组1长度仍然为1000,用户组2长度100:(下图中,状态1、2代表绑定数组的切换)
状态1\状态2 | 用户组1 | 用户组2 |
用户组1 | ~0.3ms | ~111ms |
用户组2 | ~175ms | ~0.1ms |
我们可以看出,元素动态创建/删除会极大影响渲染性能。创建相同数量元素比删除同样数量元素更消耗性能。
ng-show
基于以上实验,我们可以很容易想到。如果我们使用元素池,预先创建足量的元素。接着通过ng-show来动态调整显示的元素,这样性能是否会上升呢?
$scope.getTimes = function(n) {
return new Array(n);
};
<div ng-repeat="i in getTimes(1000) track by $index" ng-show="userList[$index]">
<label>Name</label>
<p>{{userList[$index].name}}</p>
</div>
状态1\状态2 | 用户组1 | 用户组2 |
用户组1 | ~1.3ms | ~42ms |
用户组2 | ~22ms | ~1.0ms |
可以发现,同组切换时间消耗少量增加。但是相对的,异组切换性能大幅提升了。这是由于web中,元素操作是十分消耗性能的操作。因而为了性能,我们需要尽可能避免元素的创建/删除。同样的,由于每次渲染,都会调用new Array和检查ng-show属性,从而导致了同组切换的时间增加了。
ng-if与ng-show
Angularjs中还有另一个方法ng-if,它是只有满足表达式条件才会变更元素。对于用户组切换,其毫无疑问会创建/删除元素。不过在此,我还是把数据罗列一下:
<div ng-repeat="i in getTimes(1000) track by $index" ng-if="userList[$index]">
<label>Name</label>
<p>{{userList[$index].name}}</p>
</div>
状态1\状态2 | 用户组1 | 用户组2 |
用户组1 | ~11ms | ~250ms |
用户组2 | ~300ms | ~5.5ms |
可以看出,使用缓存+ng-if。性能消耗会比原本没有track by更消耗性能。
那么ng-if的适用场景是什么?是否所有的ng-if都适合被ng-show代替呢?让我们接下去继续看看列子。
组合
首先,我们对比一下有无缓存的初始化1000条数据的时间。
有缓存 | 无缓存 | |
用户组1 | ~276ms | ~240ms |
用户组2 | ~278ms | ~36ms |
现在,我们假设用户有一个id属性。UI中,根据id是除以5的余数来做不同的渲染。规则如下:
余数 | 渲染元素 |
0 | 画一个2*2的table |
1 | 显示一个长度为5的ul li列表 |
2 | 显示一个checkbox的input |
3 | 显示一个textarea |
4 | 显示一个text input |
<div ng-repeat="user in userList track by $index">
<label>Name</label>
<p>{{user.name}}</p>
<div ng-show="user.id % 5 === 0">
<table>
<tbody>
<tr>
<th>11</th>
<th>12</th>
</tr>
<tr>
<th>21</th>
<th>22</th>
</tr>
</tbody>
</table>
</div>
<div ng-show="user.id % 5 === 1">
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
</div>
<div ng-show="user.id % 5 === 2">
<input type="checkbox" />
</div>
<div ng-show="user.id % 5 === 3">
<textarea></textarea>
</div>
<div ng-show="user.id % 5 === 4">
<input type="text" />
</div>
</div>
<div ng-repeat="user in userList track by $index">
<label>Name</label>
<p>{{user.name}}</p>
<div ng-if="user.id % 5 === 0">
<table>
<tbody>
<tr>
<th>11</th>
<th>12</th>
</tr>
<tr>
<th>21</th>
<th>22</th>
</tr>
</tbody>
</table>
</div>
<div ng-if="user.id % 5 === 1">
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
</div>
<div ng-if="user.id % 5 === 2">
<input type="checkbox" />
</div>
<div ng-if="user.id % 5 === 3">
<textarea></textarea>
</div>
<div ng-if="user.id % 5 === 4">
<input type="text" />
</div>
</div>
| ng-show | ng-if | ng-switch |
用户组1 | ~557ms | ~766ms | ~858ms |
接着,测试切换:
ng-show | ng-if | ng-switch | |
组1->组2 | ~260ms | ~257ms | ~261ms |
组2->组1 | ~430ms | ~470ms | ~560ms |
好像ng-show各项数值都优于ng-if与ng-switch。不过还没完,我们继续修改例子。为用户添加以下几个属性,对应绑定于之前定义的元素(m,n初始化时伪随机生成以便于测试对比数值):
属性 | 描述 |
matrix | 一个m*n的数组 |
list | 一个长度为n的列表 |
desc | string |
checked | boolean |
<div ng-repeat="user in userList track by $index">
<label>Name</label>
<p>{{user.name}}</p>
<div ng-show="user.id % 5 === 0">
<table>
<tbody>
<tr ng-repeat="line in user.matrix track by $index">
<th ng-repeat="val in line track by $index">{{val}}</th>
</tr>
</tbody>
</table>
</div>
<div ng-show="user.id % 5 === 1">
<ul>
<li ng-repeat="val in user.list track by $index">{{val}}</li>
</ul>
</div>
<div ng-show="user.id % 5 === 2">
<input type="checkbox" ng-checked="user.checked" />
</div>
<div ng-show="user.id % 5 === 3">
<textarea ng-model="user.desc"></textarea>
</div>
<div ng-show="user.id % 5 === 4">
<input type="text" ng-model="user.desc" />
</div>
</div>
<div ng-repeat="user in userList track by $index">
<label>Name</label>
<p>{{user.name}}</p>
<div ng-if="user.id % 5 === 0">
<table>
<tbody>
<tr ng-repeat="line in user.matrix track by $index">
<th ng-repeat="val in line track by $index">{{val}}</th>
</tr>
</tbody>
</table>
</div>
<div ng-if="user.id % 5 === 1">
<ul>
<li ng-repeat="val in user.list track by $index">{{val}}</li>
</ul>
</div>
<div ng-if="user.id % 5 === 2">
<input type="checkbox" ng-checked="user.checked" />
</div>
<div ng-if="user.id % 5 === 3">
<textarea ng-model="user.desc"></textarea>
</div>
<div ng-if="user.id % 5 === 4">
<input type="text" ng-model="user.desc" />
</div>
</div>
| ng-show | ng-if | ng-switch |
用户组1 | ~4678ms | ~1800ms | ~1990ms |
是不是大吃一惊?原因很简单,由于ng-show只是隐藏元素。但是实际的数据绑定仍旧会被执行。虽然在页面上看不到,但是元素绑定的数据还是一并更改了:
通过以上实验,我们很容易分析出。当页面布局简单时,可以通过ng-show+cachelist来实现快速的数据切换。而当元素组件存在大量元素变化的时候,使用ng-if/ng-switch来避免多余的元素绑定。通过两者结合的方式,可以使得程序在初始化和动态变化的时候保持更好的性能。同样的,在事件处理中。ng-if相较于ng-show会更有利于性能,但是如果事件绑定不多,使用ng-show则更佳。