JavaScript
AngularJS

AngularJSのngRepeatを"ちゃんと"理解する.

More than 3 years have passed since last update.


はじめに

皆さん、AngularJS使ってますか?

Angularといったら、/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/ですよね.

/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/がない開発なんて有り得ない!

.........

......

...

無駄な前フリはこの辺にしておこう.

冒頭の正規表現は、AngularJSのngRepeatDirectiveのコードから引っ張ってきている.

そう、AngularといえばngRepeat, ngRepatといえばAngularといっても過言でないほど、Angular開発に欠かせないDirective, それがngRepeat.

今日は、そんな皆が大好きなngRepeatのお話。もとい、repeat_expressionのお話。

repeat_expressionとか偉そうに言っているが、冒頭の正規表現をもう少し噛み砕けば、大体↓みたいな書式だ.

itemName in collectionExpr as aliasName track by identifierExpr

「ngRepeatなんざ、とっくに使いこなしてるぜ」と思ったそこの人、上記の文法、使いどころを人に説明できますか?

このエントリでは、上記のrepeat expressionを3つに区切り、それぞれのトピックを語ってみようと思う。


itemName in collectionExpr

itemNameと書いているが、(keyName, valueName) の記法も可能であるのは、Object型のコレクションに対してループさせたことがあれば、大体知っているはず。

実は、Array型のコレクションに対しても、この(keyName, valueName)が便利な場合があったりする.

ngRepeatをネストして利用しているケースが分かりやすい.


コントローラ```

scope.collection = [[1,3],[2,4]];



最下層から親ループ変数の取得

<ul>

<li ng-repeat="parent in collection">
<ul>
<li ng-repeat="child in parent">{{$parent.$index+1}}-{{$index+1}}:{{child}}</li>
</ul>
</li>
</ul>

最下層の<li>で、外側のindexが欲しいがために、上記のようなコードをつい書きたくなったりしないだろうか?

確かに、$parentで親スコープを参照すれば、外側の$indexを取得出来るが、parentのループとchildのループの間に、ネストの階層が増えると破綻するコードだ(というか、大半のケースにおいて$parentなんて悪だ)

(keyName, valueName) in ...の書式を使えば、親子scopeの相対的な階層関係に縛られることなく、外側のindexが取得できる.


(key,value)形式を利用した親ループ変数の参照

<ul>

<li ng-repeat="(i, parent) in collection">
<ul>
<li ng-repeat="(j, child) in parent">{{i+1}}-{{j+1}}:{{child}}</li>
</ul>
</li>
</ul>


ネストしたngRepeatでの$first$odd

$indexはよくても、$firstとか$oddはどうすんのさー. 結局 $parent.$firstとかするしかないじゃん」とか駄々こねちゃう人. 一旦、落ち着け. ちゃんとリファレンスをよく読め.

少しタイプ量は増えるけど、ngInitを使えば何とかなるでよ.

<li ng-repeat="parent in collection" ng-init="outerFirst=$first">...</li>

上記のように、ngInitで別名で参照を確保しておけば、ネストしたscopeからも、outerFirstで参照できるようになるよ.


collectionExpr as aliasName

先に断っておくが、repeat expressionにasが導入されたのは、version 1.3.x以降のため、〜1.2.xではas表記は使えません.

ascollectionExprに別名を付ける仕組みである.

只の配列にわざわざ新しく名前を付けても面白くもなんともないが、collectionExprはAngular Expressionsである. |記号でフィルタを書けば、その動作結果が渡されるのは、周知の通り.

ng-repeat="item in collection | filter:'hogehoge'"のような、ループを作ったとき、「フィルタ済みのコレクションを再利用したいなぁ」なんてことはないだろうか。

asは、ngRepeatにおいて、コレクションに別名を与える役割があるため、

ng-repeat="item in collection | filter:'hogehoge' as results"のように書いてやることで、collectionが属するscopeでresultsが参照できるようになる.

なお、asと言っても、1.2.xで導入されたController As記法(ng-controller="MainCtrl as main"みたいな奴)とは別物.

scopeに参照を放り込む機能、という意味では似ているが、それぞれのディレクティブでそれぞれ実装されているだけで、「Angular Expressions内でasを書けば、何でも別名を付けられるのね、素敵!」とか思わないこと.


track by identifierExpr

さて、今回、一番語っておきたいのが、このtrack byだったりする.

「angular ngRepeat track」等のキーワードでググると、「ngRepeat使ってエラーが出たらtrack by $indexを付けろ」のようなエントリばかりにぶつかるが(少なくとも日本語ページで上位10件は大体そんなんばっかだ)、どれもこれも話が浅い。


表層的な話

一旦、世の中に出回っている話から。

大体、track byに出くわすのは、下記のように、collectionとして、要素重複があるコードを書いた時ではなかろうか.

<li ng-repeat="item in [1,2,1]">{{item}}</li>

「ん?<li>が描画されてないぞ. 何故だ!?」と、コンソールのエラー出力を見てみると、次のようなエラーが目に止まる。

Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys.

「重複する要素があるから、track by使って一意なキーを指定しろやゴラァ」てなことを仰っている訳だ。

世の皆さんが言うように、track by $indexを書くと、エラーは解消される.

<li ng-repeat="item in [1,2,1] track by $index">{{item}}</li>

track by $indexはngRepeatで重複するキーを許可するためのオプションである」等という、酷い説明も存在したりするが、無視しろ、無視。

track byは、飽くまで「要素を一意に識別するための方法を変更するためのオプション」である。$indexを指定したところで、重複がゆるされないのは一緒である。"要素の値が一緒であっても、$indexはそれぞれ別個の値であり、一意性が確保できるから"エラーが解消されるだけの話だ。


track by $indexの弊害

track byの挙動を理解していても「じゃぁ毎回、track by $index付けちゃおっかな。っていうか次のupdateでデフォルトにして欲しいな」などと言っている輩もいるので、まだ注意が必要だ.

そもそも、何でこんな仕組みがあるか、考えてみればよい(大体、グダグダ言ってる奴はコードもリファレンスも読んでいないに違いない).

AngularJSが、"Duplicates in a repeater are not allowed"と警告するのにはちゃんとした訳がある。

ngRepeatの主たる責務には以下がある:


  • 監視対象のcollectionExprについて、要素が増減したら、合わせてDOMをappend, removeする

  • 監視対象のcollectionExprについて、要素順序が変動したら、DOMも並べ替える

ngRepeatが要素の一意識別子を必要とするのは、「どの要素が生成・消滅し、これと合わせて、どのDOMをappend・removeすればよいか」を追いかけていく(trackingする)ためだ。

次の例を考えて欲しい.

画面描画後に、ボタンを押す等で、ngClickを発火させ、コレクションの要素数を一つ増やしたとしよう.


  • 描画直後のコレクション:[100, 200, 300]

  • 操作後のコレクション:[50, 100, 200, 300]

僕は一言も書いてないが、殆どの人が上記の例を見て、「先頭に50という要素を詰める操作をしたのね」と思ったはずだ.

もし、<li>で列挙されているのであれば、先頭に新しい<li>要素が挿入されるイメージのはずだ.

実際、ng-repeat="item in collection"とだけ書いていれば、そのイメージ通りに動作する.

しかし、track by $indexがAngularJSに何をやらせているかというと、「要素の同一性は順番しか意識しなくていいよ」という意味であるから、Anguarからしたら、「元々100という要素の値が50に変わったのかぁ、200100になって300という要素の値は200に変化して、あ、そういえば新しく300って要素も追加されたのね」と解釈するようになる。

どう考えても馬鹿丸出しである。

したがって、track by $indexを付けた場合は、どのように配列を操作したかと関係なく、挿入される<li>要素は末尾の要素だ.

一見、正しく動作しているように見えるが、実は追加されたDOMが意図と異なっている訳だ。

なお、collection.push(50)だと、上手く行っているように見えるのも質が悪い

はっきりとおかしいな、と思うのは、ngAnimateモジュールと組み合わせた時である。

DOMのappendに対応して、enterというイベントが定義されているが、ここにアニメーションを設定してみると、挿入されるDOM要素の違いが目で見て確認できる。


main.js

angular.module('myApp', ['ngAnimate']).controller('MainCtrl', ['$scope', function($scope){

$scope.collection = [100, 200, 300];
$scope.add = function(){
// 先頭要素にランダムな0〜99の数値を追加していく.
$scope.collection = [~~(Math.random() * 100)].concat($scope.collection);
};
}]).animation('.repeated-item', function(){
return {
enter: function(elem, done){
elem.css('opacity', 0);
$(elem).animate({opacity: 1}, done);

return function(isCancelled){
if(isCancelled){
$(elem).stop();
}
};
}
};
});



main.html

<div ng-controller="MainCtrl as main">

<button ng-click="add()">push!</button>
<ul>
<!-- きちんと先頭のDOM要素がアニメーションする -->
<li class="repeated-item" ng-repeat="item in collection">{{item}}</li>
</ul>
<ul>
<!-- アニメーションするのは、末尾のDOMだけ -->
<li class="repeated-item" ng-repeat="item in collection track by $index">{{item}}</li>
</ul>
</div>


本当にtrack byを使うべきケース

track by $indexは、上述のように、DOMのライフサイクルが妙なことになる訳だが、それを理解した上で問題ないと判断できる場合のみ、利用すべきである.

基本的には、ngRepeatが自動生成する一意キー($$hashKey)に任せておいた方がよい.

じゃぁ逆にどんなケースでtrack byって使うのさ、

と考えたが、あまりパッとは実例が思い浮かばない。

「対象としているコレクション内の要素について、同一性をオブジェクトのハッシュで管理するのが不向きなとき」という抽象的な答えしか出てこない。

+ コレクション内の要素について、参照先のオブジェクトが変化したが、論理的には同一とみなせるケース

+ コレクション内の要素について、オブジェクトへの参照は同じだが、オブジェクトのフィールドが変化しており、同一とはみなしたくないケース


(そもそも$watchCollectionが発火しない気もする...)

track by $index以外で、何か良い具体例を見つけたら追記します...


  • 2014.10.08 追記
    $http等のAjaxで取得したリソースに明示的にidが付与されている、など、外部からオブジェクトに一意識別子が与えられている場合は、無駄な再描画を防ぐためにtrack byを利用するのは正しい使用例ですね。
    (@nazokingさん、ご指摘ありがとうございました)


まとめ



  • ngRepeatのネストで(key, value)形式が便利になることも. ngInitとも組み合わせOK.


  • ngRepeatをネストしたからといって、$parentを使うのは止めとけ.

  • フィルタの結果がasでscopeに保存できるよ. でも1.2.x系では動かないよ.


  • track by $indexは実は危険.