これまでAngularJSでアプリを作ってきた中で、いくつかパフォーマンスの問題に遭遇しました。
それらの問題は、AngularJSの仕組みを十分に理解できていないために、よくないコードを書いてしまって発生しているものでした。
というわけで、AngularJSの内部構造を解説しつつ、パフォーマンスを改善するコードの書き方を紹介したいと思います。
計測できないものは改善できない
パフォーマンス問題に取り組むには、ソースコード修正の前後でパフォーマンスを計測し、改善の効果を計測することが重要になります。
というわけでまずはツールの紹介です。
AngularJSでは、Batarangという便利なツール(Chrome Developer Toolsの拡張機能)が用意されています。
利用方法はとても簡単で、下記のChromeウェブストアからインストールして、Chrome Developer Toolsを起動し、AngularJSタブのEnableにチェックを入れるだけです。
このツールで、$scope
のツリー構造を見ることができたり、パフォーマンスの計測ができたり、モジュールの依存関係グラフが表示できたりします。
パフォーマンスのタブでは、処理時間のかかっている箇所が一目で分かるようになっています。
このツールはすごく簡単に使えますが結果がざっくりとしているので、より詳しく解析したければChrome Developer Toolsのプロファイリング機能などを使いましょう。
仕組みを知る
パフォーマンス改善を行うためには、フレームワークの仕組みを知り、何がボトルネックになるのかを理解しておくことが重要です。
AngularJSでは双方向のデータバインディングの仕組みがとても便利ですが、パフォーマンスの影響が出やすい機能でもあるので、その仕組みを見てみましょう。
データバインディングの仕組みの実現方法はいくつかあります。
例えばKnockout.jsでは、データの値が変化した時にイベントリスナでViewに通知する方式をとっています。
一方、AngularJSでは、dirty-checkingと呼ばれる方式で実装されています。
これは、バインドしているすべての変数について、特定のタイミングで前回の値と今回の値を比較し、値に変化があればDOMを書き換えるという仕組みです。
ざっくりと以下のような流れで動いています。
- HTMLを解析してディレクティブをコンパイルする際に、バインドされている変数を
$scope.$watch()
で登録します。 -
$rootScope
から子のscopeを順にたどり、watchで登録されたすべての変数の変更チェックを行います。この処理のことを$digest
ループと呼び、下記のようなタイミングで実行されます。(定期的なポーリングはしていません)
-
$scope.$apply()
を呼んだ時 - DOMイベント(テキストボックスのonChange、ボタンのonClickなど)が発生した後
-
$http
や$resource
でレスポンスが返ってきた後 -
$timeout
のイベントが発生した後 -
$location
でURLを変更した後
- 変更チェックが完了したら、変更があった部分のDOM書き換えを行います。
dirty-checking方式は、このように多くのオブジェクトの比較処理を行う仕組みなので、イベントリスナ方式に比べるとパフォーマンスの問題が起こりやすそうですね。
でもこの実装方法を採用することで、PureなJavaScriptオブジェクトをModelとして利用できたり、監視対象のオブジェクト間の依存関係を難しく考える必要がないというメリットもあります。
ちなみに、将来的にはdirty-checking方式からObject.observe
に置き変えることも考えられているようです。(参考←ちょっと古い情報ですが)
改善する
$digest
ループ内の処理はできるだけ少なく軽量に
$digest
ループでは、watchしている変数の数とその変数の比較処理時間がパフォーマンスに大きく影響します。
なので、watchする変数の数はできるだけ少なく、オブジェクトの比較処理はできるだけ軽くするのが基本です。
watchする変数の数の目安は2000個以下、比較処理時間の目安は25μsecだと言われています。(参考)
25μsec×2000回であれば50msec以内に処理を完了できるので、ユーザーの体感としては十分に速いと感じられるからだそうです。
Batarangを使うと$scope
のツリー構造が視覚化されるので、watchしている変数の数の目安になります。
オブジェクトの比較処理については通常はそれほど意識しなくても大丈夫です。
$scope.$watch()
の第1引数には監視対象のプロパティ名を文字列で渡すことが多いですが、監視対象の値を返すfunctionを指定することもできます。この場合、functionの中では重たい処理を書かないようにしましょう。
また、$scope.$apply()
は$rootScope
からすべての子要素に対して変更チェックを行いますが、$scope.$digest()
は現在のスコープからの子要素に対して変更チェックを行います。
手動で$scope.$apply()
を呼び出すときは、$scope.$digest()
で置き換えられないかどうか検討してみましょう。
フィルタでは重たい処理をしない
以前、markdownフォーマットの文字列をレンダリング用のHTMLに変換するようなフィルタを作成し、ngRepeat
の中でそのフィルタを使うコードを書きました。
<div ng-controller="MyController">
<input type="text" ng-model="content">
<button ng-click="addItem()">add</button>
<div ng-repeat="item in items">
<div ng-html-bind="item.content | markdown"></div>
</div>
</div>
angular.module('MyApp')
.controller('MyController', function ($scope) {
$scope.items = [];
$scope.addItem = function() {
$scope.items.push({content: $scope.content});
};
});
/* markdown変換用のフィルタ */
angular.module('MyApp')
.filter('markdown', function () {
return function (input) {
return marked(input);
};
});
しかしこの実装では、テキストボックスに1文字入力するたびに、items
リストの要素数分だけmarkdownフィルタが実行されてしまい、非常に重たくて使い物になりませんでした。
実はフィルタはレンダリングの時にだけ使うのではなく、$digest
ループで比較処理を行う際にも呼び出されています。(値が変化した時には比較用とレンダリング用の2回フィルタが呼ばれます。)
よってフィルタの処理はできるだけ軽くしなければなりません。
markdownをHTMLに変換するような重たい処理はフィルタで実装するのではなく、変換結果を$scope
に持たせておくのがよいのでしょうね。
ngShow/ngHideとngIfの使い分け
AngularJSでは、HTML要素の表示・非表示を切り替えるためのディレクティブとして、ngShow
/ ngHide
とngIf
が用意されています。
これらはほぼ同じ機能なんですが、実装方法が異なります。
ngShow
/ ngHide
はCSSで表示を切り替えていて、ngIf
はDOMの追加と削除を行うようになっています。(参考)
例えば、以下のようにタイトルだけが一覧で表示されていて、タイトルをクリックすると詳細が開くようなUIを考えてみましょう。
<div ng-repeat="item in items" ng-controller="MyController">
<a href="" ng-click="item.isOpen = !item.isOpen">
{{item.title}}
</a>
<div ng-show="item.isOpen">
<div>
/* 生成するのが重たい要素 */
</div>
</div>
</div>
ngShow
を利用すると、リストの要素が多いときに最初の読み込みが遅くなりますが、表示・非表示の切り替えは軽快になります。
逆にngShow
の代わりにngIf
を利用すると、最初の読み込みは軽快になりますが、表示・非表示の切り替えは少し遅くなります。
このような特性を理解して、状況に応じて使い分けるのがよいですね。
まとめ
AngularJSの内部構造を解説し、アプリを開発する際にパフォーマンス周りで気をつける点を紹介してみました。
僕もそれほど複雑なアプリを開発したわけじゃありませんが、上記の項目を気をつけていればパフォーマンスの問題で悩むこともほとんどありませんでした。自作のアプリをスマートフォンで動かしてみても、思っていた以上にサクサク動きましたし。
より詳しくパフォーマンス対策やAngularJSの内部構造を知りたい場合は、下記の書籍が参考になります。(今回の記事の内容もこの書籍を参考にしている箇所が多いです)
さらにチューニングしたい場合の一例として、下記のコードは面白いです。
$digest
ループの一部を上書きしてしまっているので、若干黒魔術ではありますが。