AngularJSでorderByやfilterが効かないことがある(ような気がする)

この記事はだいぶ前に書かれたものなので情報が古いかもしれません
M字開脚、V字ジャンプ、C字えびぞり

この記事を三行にまとめると

AngularJSのソートやフィルタリングをやってみるっすよー。
何かソートやフィルタリングが効かないことがあるっすよー。
画像はMVCの人文字です。
AngularJSっていうjavascriptのMVCフレームワークがありまして……これがなかなか便利なので、僕も最近使っているんですが、いまいち使い慣れてないこともあって、意外に簡単なことでつまづいたりしている毎日です。

AngularJS

今日はその中でも、たぶんAngularJSを使う上で、かなり頻繁に使われる機能なんじゃないかと思われる『ng-repeat』のフィルタリングやソートについて。

一言で言うと、これが効かない場合があるような気がするんですよ。や、僕の考え方とかやり方が間違ってただけなら、遠慮なくつっこんでもらえればと思うんですが。



一応、まずは基本的な使い方から

ぶっちゃけ、AngularJSの詳細をまず僕自身がよく分かってないんで、分かっててここに来た人に対しては、釈迦に説法というかガガに絶叫のような感じになってしまいますが、僕自身がいまいちど理解を深める意味も含めて、AngularJSのng-repeatの使い方をおさらいしておきましょー。まあ、レディー・ガガがどんなに奇抜な格好をしても、僕は絶叫はしないですけどね。ビックリはするけど。

ng-repeatってのは、PHPで言うところのforeach文みたいな感じですね。「俺ぁPHPは分からん」って言われてしまうとあれですけど、ようはループ処理を行なうためのものです。repeatするわけですね。

AngularJSでは、JSONデータを使ってあれやこれやの処理を行ないます。

ま、文字で書いてもピンと来ないですし、実際に簡単なコードを書いてやってみましょう。とりあえず細かい説明は抜きにして、基本的なコードを以下に書いてみたいと思います。

<!DOCTYPE html>
<html ng-app>
<head></head>
<body>
<div ng-controller="ctrlRead">
  <h4>実行結果1</h4>
  <p ng-repeat="item in items">
    ID:{{item.id}} , 名前:{{item.name}} , 年齢:{{item.age}}歳
  </p>
</div>

<script>
function ctrlRead($scope) {
  $scope.items = [
    {"id":1,"name":"マイケル","age":29},
    {"id":2,"name":"ジョージ","age":18},
    {"id":3,"name":"ナンシー","age":24}
  ];
};
</script>
</body>
</html>

最低限、これだけ書けば動きます。このコードでページを作ると、以下のサンプルページの実行結果1のような結果が得られます。

サンプルページ

実行結果のページの方では、PHPで配列を作って、json_encodeを使ってjsonデータを作ってます。でもやってることは一緒ですので、問題ナッシング。

ポイントとしては……まず、htmlタグに『ng-app』っていう属性をつけることですかね。何でこれをつけるかってーと……うーん……何でかなぁ。僕の印象としては、phpタグみたいなものかなって思ってるんですけど、どうでしょう。PHPを書くときには『<?php 〜 ?>』って、phpタグで囲わないと、構文がPHPとして解釈されないじゃないですか。『echo ‘hello world’』とか書いても、それがそのまま文字列として表示されちゃうじゃないですか。あれと同じ感覚で、ng-appで囲われた中では、AngularJS用の構文が書けるっていう、そんなイメージです。

なので、絶対htmlタグに書かなければいけないってことはないですね。phpタグと同じで、AngularJSを適用させたい部分が囲われてれば良いから、bodyタグとかに書いても良いし、特定のテーブルの中でだけ使いたければ、tableタグにつけても良い。

ただ、phpタグと違う点は、一つのページの中で複数のng-appを書くのはダメみたい。

<div ng-app>
〜 中略1 〜
</div>

<div ng-app>
〜 中略2 〜
</div>

こうやって、複数の箇所でng-appを書いても、適用されるのは最初の一つだけ。この場合は、中略1の中しか効果が発生しないみたいですね。僕が試した限りでは動きませんでした。だから、やっぱりhtmlタグかbodyタグに書いとくのが無難ってことなのかな。



あとは、divタグのところにつけた『ng-controller=”ctrlRead”』ってやつですね。このng-controllerってやつで、使用するコントローラーを決めることができます。『ctrlRead』ってのは、別に何でも良い。それに対応するコントローラーが書いてありさえすれば、『jejeje』とか『baigaesi』だって良いよ。divタグの下にあるscriptタグの中で、『function ctrlRead($scope)』ってのありますけど、これがいわゆるコントローラーになる。だからここを『function jejeje($scope)』とかにすれば、特に問題なく動く。そんな遊び心満載のコードを人に見せても良いと思うなら、遠慮なくやっちまいなー。

余談っすけど、冒頭でも触れたように、AngularJSはMVCフレームワークなので、コントローラーとかモデルとかってのが出て来るんですよ。今回はモデルは書いてないですが、『ng-model』ってのもちゃんとある。MVCに関しては未だに上手く説明できないんで(もう四年もCakePHPいじってるのに)、詳しく知りたい方は各々調べていただくとして……とにかくそういうことっす^^;

コントローラーの中で、『$scope』っていう変数にjsonデータをぶっ込んでますね。AngularJSには$scopeっていう変数があらかじめ用意されてるので、これにいろいろと自分で値を入れて、使って行くことになります。これは$scopeじゃないとダメ。$dogezaとかじゃ動かない。他にも用意されてる変数がいくつかあるみたいですが、今回は出番がないので、紹介はしないっす。アニメのエンディングとかでも、その回に出番のなかったキャラの名前はエンドロールに出て来ないじゃん? そんな感じ。



以上が、基本的な準備編ってとこですかね。この辺が書けたら、いよいよ『ng-repeat』を使って、$scopeに入れた値を出力する。

上記のコードに『ng-repeat=”item in items”』ってのがありますが、これがいわゆるforeach文です。ループ処理をしているところ。『item in items』ってのは、PHPのforeach文でいうと『foreach($items as $item)』みたいな意味に相当しますかね。もう完全にPHPを知ってる前提で話しちゃってますけど、そこは勘弁。

何でitemsって名前なのかというと、さっきコントローラーの中で$scope.itemsっていう変数に値を入れたからです。ここで『$scope.hanzawa』とかにjsonデータを入れたなら、『ng-repeat=”item in hanzawa”』って書けば良い。その辺は自由。別にitemの方も『ng-repeat=”naoki in hanzawa”』にしたって良いよ? それも自由。

ng-repeatを使うと、その属性がつけられたタグでリピートします。今回はpタグにつけてますが、テーブルレイアウトの中でループさせたいなら、trタグとかtdタグにつけても良い。

値を出力させるには『{{item.id}}』のように、かっこを二重で囲みます。そういう決まりになってる。

僕の感覚では、通常のPHPの書き方よりも、Smartyのときの書き方に若干印象が近いのかなぁって気がしたんですけど……まあそんなことはどうでもいいか。そもそもSmartyってあまり触ったことないから、よく分かんないし。



じゃあソートしていくよー

ng-repeatの基本的な使い方はこれでマスターしたってことにして、次はソートをやってみましょう。

これも結構簡単にできる。あとに出てくるフィルタリングもそうなんだけど、そこら辺がAngularJSの使い勝手の良さの一つなのかな。ここは僕も、ソートの条件とかを変えるたびにサーバーにリクエストを投げて条件通りのデータをSQLで取って来て……ってやるよりは、良い感じに思う。最初に全データを取ってくることを考えると、あまりにもデータの数が膨大な場合は、少し考えた方が良いのかもしれないけど。

まあそれは今は置いときましょう。それよりもソートの実行だ。

さっきと同じデータを使って、今度は年齢の若い順に表示されるようにしてみたいと思います。

ソートをするには、ng-repeatのところに『orderBy』っていう条件を入れてあげる。

<div ng-controller="ctrlRead">
  <h4>実行結果2</h4>
  <p ng-repeat="item in items | orderBy:'age'">
    ID:{{item.id}} , 名前:{{item.name}} , 年齢:{{item.age}}歳
   </p>
</div>

これだけっす。かーんたーんヽ(*^▽^*)ノ

orderByの後に、コロンを挟んでソートしたいキーを指定するだけです。idでソートしたければidにすれば良いし、名前でソートしたければnameにすれば良い。実際に年齢でソートした結果は、サンプルページ(の実行結果2)にて。

これはいわゆるソートが『asc』の状態ですが、『desc』にしたい場合は、こう。

<p ng-repeat="item in items | orderBy:'age':true">
  ID:{{item.id}} , 名前:{{item.name}} , 年齢:{{item.age}}歳
</p>

第三引数……って言い方になるかは分からないけど、キーを指定した更に後ろで、reverseの値を指定できます(省略可)。省略するとfalseになるので、リバースしない、つまりdescにならないってことですね。trueにすればソートがリバースされると、そういう塩梅です。

ここには変数を入れることもできるので、自分でreverse指定用の変数を作って、それでリバースを切り替えるような処理を入れることも可能です。

<p ng-repeat="item in items | orderBy:'age':desc">
  ID:{{item.id}} , 名前:{{item.name}} , 年齢:{{item.age}}歳
</p>

<script>
function ctrlRead($scope) {
  $scope.items = [
    {"id":1,"name":"マイケル","age":29},
    {"id":2,"name":"ジョージ","age":18},
    {"id":3,"name":"ナンシー","age":24}
  ];
  $scope.desc = true;
};
</script>

今、コントローラーの中で『$scope.desc』っていう変数を作りました。この変数を使ってtrueとfalseを切り替えたりもできます。何かリンクをクリックしたら、$scope.descの値が切り替わるような処理を書けば、クリックするたびにソートが入れ替わったりする。普通にjavascriptのonClickでも行けるし、AngularJSにも『ng-click』っていう、onClickの代わりみたいなのがあるから、それを使っても可。



ちなみに。

これ、ちょっと曖昧なんですが、指定するキーは、『orderBy:’age’』みたいに、コーテーションで囲っとかないと、効かない気がする。そう書かなくとも効くよって言ってる人も見かけたんですが、バージョンアップの際に仕様が変わったとか、そういうのかもしんないね。



フィルタリングするぜよー

んじゃあ、次はフィルタリング……絞り込みっすね。

とりあえず、適当に『1』とかを入力して、絞り込んでみましょう。

<p ng-repeat="item in items | filter:1">
  ID:{{item.id}} , 名前:{{item.name}} , 年齢:{{item.age}}歳
</p>

サンプルページ(の実行結果3)がこのフィルタリングの結果です。

この結果を見ると、マイケルとジョージの二人が表示されてます。一体『1』って絞り込んだ場合に、どこの値を対象に絞り込んでるのかってことなんですが……。

結論から言うと、全部です。

ようするに、id、name、ageと、全ての要素の中から『1』という文字が含まれているデータが抽出されると、そんな感じです。今回の場合ですと、マイケルの場合はidが1なので対象になり、ジョージの場合は年齢が18なので文字列に1が含まれているから対象になると、そういうわけです。

僕も完璧に調べたわけではないですが、AngularJSには完全一致のフィルタリングはないっぽいので、絞り込み用の文字列が含まれているデータは全て引っかかってしまいます。だからジョージの年齢は81でも引っかかる。

じゃあ今ここで、年齢だけを絞り込みの対象にしたい。そんな場合はどうするか。

<p ng-repeat="item in items | filter:{age:2}">
  ID:{{item.id}} , 名前:{{item.name}} , 年齢:{{item.age}}歳
</p>

こんな風に、filterのところでキーを指定することができるので、こうすると、ageという要素の中で『2』が含まれるデータのみを抽出してくれます。サンプルページ(の実行結果4)を見てもらうと、20代のマイケルとナンシーが残ってますね。

こっちは何でか、コーテーションをつけなくても動くんですよね。かっこの中なら有効とか、そんな感じなのかな。

ともあれ、これなら例えば、年齢が20代の人だけを抽出できる……と言いたいところですが、さっきも言った通り、これは部分一致の検索なので、『2』だけだと、12歳とか120歳とか200歳の人も引っかかってしまう。

これは今のところ、どうしようもないっぽいですね。僕も実際に使ってて結構悩まされるんですけど、無理矢理フィルターしたい値にアンダーバーをつけたりして、何とか回避してる感じです。『_12_』とか『_120_』とかにしとけば、例えば12歳の人をピンポイントで絞り込みたいときは『filter:{age:’_12_’}』ってやれば、120歳の人とかは引っかからないやん? 20代の人を引っかけようとして『_2』ってやった場合に、200歳の人(『_200_』)が引っかかっちゃうのは……どうしよう。

200歳の人なんて現実的に考えて存在しないからまあいっかー……とは言えないのが、システム屋の泣き所なのよね。そういうとこ投げやっちゃう精神でいると、2000年問題とかIPアドレスの枯渇とかを生むきっかけになるんだよ、たぶん。



ちなみに。

ソートとフィルタリングを個別にやっちゃいましたけど、両方一辺に指定することも、もちろんできますよー。

<p ng-repeat="item in items | filter:{age:2} | orderBy:'age'">
  ID:{{item.id}} , 名前:{{item.name}} , 年齢:{{item.age}}歳
</p>

こんな感じでね。



ようやく本題

使い方をさらっとおさらいするつもりが、思いのほか長くなってしまいましたが……僕が今日言いたいのは、こっからなのよ。

ここまでやってきたorderByとかfilterなんだけど……どうも、効かないことがある。

ロンよりツモ……いや、論より証拠ってことで、とりあえずサンプルページ(の実行結果5)を見てもらえやすか?

結果としては、実行結果1の場合と同じ結果になってますよね。

でもこれ、本当は実行結果2のときみたいに、年齢でソートしてるんですよ。でも結果はそうなっていない。

さっきと何が違うかっていうと、jsonデータのところが微妙に違うんです。

//実行結果1〜4までのjsonデータ
[
  {"id":1,"name":"マイケル","age":29},
  {"id":2,"name":"ジョージ","age":18},
  {"id":3,"name":"ナンシー","age":24}
]

//実行結果5のjsonデータ
{
  "1":{"id":1,"name":"マイケル","age":29},
  "2":{"id":2,"name":"ジョージ","age":18},
  "3":{"id":3,"name":"ナンシー","age":24}
}

配列の各要素に、明示的にキー(添え字?)をつけたんですよ。こうすると、orderByを指定しても、明示的につけた値で強制的にソートされてしまうみたいなんです。

ついでに言うと、filterも効かない。『filter:{age:2}』はもちろん、『filter:1』とかでやっても、上手く絞り込まれないようです。

いろいろ試してみた結果分かったことは、どうやら配列のキーは、0から順番に振られてないと、orderByやfilterが機能しないっぽいです。0から順番に振ってあれば、明示的に書いてもちゃんとorderByやfilterが機能する。まあ、0から振る場合はわざわざ書く必要はないけどね。

だから1から振るのはもちろん、アルファベットとかを使ってもダメってことですね。

ちなみに、0から順にってのは結構厳密みたいで、例えばこんなことをしても、やっぱり上手くは機能しないみたい。

{
  "1":{"id":1,"name":"マイケル","age":29},
  "2":{"id":2,"name":"ジョージ","age":18},
  "0":{"id":3,"name":"ナンシー","age":24}
}

0を最初じゃなくて、後ろに持って来た場合も、NGみたいですね。ちゃんと0から順番にってのが、重要みたいです。ちなみにこの場合は、キーで強制的にソートされるので、表示順はナンシー、マイケル、ジョージの順になる。

もしかしたらこれはバージョンアップに伴って解消されたりするかもしれないので、今現在僕が使っている、バージョン1.2ではこうなってるって言っといた方が良いかもですね。






データベースから取って来た値をjsonに直すにあたって、orderByとかfilterがうんともすんとも言わなくて、どこをどう見ても書き方は間違ってないのに、どこがいけないのかなーってずーっと悩んでたんですけど、どうやらそれは、データベースでつけてるプライマリーキーのIDをキーに指定してたせいだったようです。そのせいで、何度やってもデータベースのID順にしか表示されなかった。

キーを書いとくくらい良いじゃんねー? とか僕は思ったりするんですけど……ここがちゃんと0から振ってあるのって、そんなに大事なことなのかな。

僕が知らないだけで、こういう配列を何やかんやと扱うときに、ちゃんと0から振り直すようにするのが定石ってのは、javascriptを扱うプログラマの人たちにとっては、極々当たり前のことなのかな……? いやいや、そんなことないよね。連想配列みたいにキーがアルファベットの文字列とかだったら何となく分かるような気もするけど、でも数字だったら別に……いや、でもしかし……。

うーん、分からん。
 もしかしたら何か関連しているかも? 
 みんなからのコメント 
2014年04月22日 01:20:17
匿名
今更かもしれませんが、[]と{}は別物です。
[]の配列は順序を持つ集合で、{}のオブジェクトはキー・バリューの順序を持たない集合になります(JSだと強制的にキー順になります)。
PHPとかの言語によっては配列とオブジェクト(PHPだと連想配列)を一緒くたに扱えますが、JSの場合は別物として定義されているので気軽にキーを書くと別のデータ構造になってしまいます。

ただ、オブジェクトをngRepeatで表示させたいときもあるので、そういったときはこちらのAngularJSのissueを参考にしてください。
https://github.com/angular/angular.js/issues/1286
2014年04月23日 16:57:33
まっち~(管理人)
>匿名さん
おお、なるほど。ご教示ありがとうございます!

確かにPHPを使ってると、通常の配列と連想配列が別物という意識をあまりしないものですから、それに慣れちゃってるせいで今回みたいなことになってしまったんですね……勉強になります。