angularJS と jQuery に関する誤解を解く
最近 angularJS に対する期待の低下が著しくてつらい。
みんな使いどころを間違ってるんや。1年半くらい使ってて不満もあるけど自分のよく使う範囲では angularJS 最強だと思う。
なんだかんだで SPA から jQuery に戻った話 - ボクココ
Angularの問題では
はてなブックマーク - mizchi のブックマーク - 2015年5月18日
angularJS が向いてるのは Single Page Application ではない
angularJS が向いてるのは
◎ | フォームのような細かい部品を多用 & DOMツリーとデータスコープがほぼ一致していてユーザの入力をサーバに送ったりする webアプリ。管理画面、マイページ、業務アプリなど |
△ | Single Page Application ← 簡単に作れるけどページ間の連携が必要ないならサーバ側で分けてしまった方がよい。 |
× | SEO対策が必要なページ。SEOが大事な webサービスのフロント側とか |
× | ゲームのようにDOMツリーとデータスコープがあんまり関係ないもの、変化するデータが多くてfps単位でのスピードが求められるもの |
とくに 管理画面にありがちな、一覧(並び替え/絞り込み/表示形式変更)・編集・追加・削除 という一連の画面の作りやすさはすごい。下手するとほとんどjavascript書かずに実装できる*1。
そして「javascriptアプリケーションのサイズ」はなるべく小さい単位にとどめておくことをお薦めする。例えば「管理画面のログインからシングルページアプリケーション」というのは避けた方がよくて「ログインはjavascriptなしのフォーム」その後メニューから「商品管理画面に遷移したらそこで一つのアプリ」のように普通のページ遷移+別のmainコントローラーという構成が、扱いよい。可能なら「一覧」「詳細編集」で別にするともっと取り回しが効く。
複数のページを管理する、ということは、状態を示す変数が大きく複雑になるということだ。状態が複雑になると、実装はさらに指数関数的に複雑になり、速度も遅くなるしテストも大変になる。できるなら小さくした方が影響範囲も狭くなって改修しやすい。メモリリークなどが起こっても影響が少なくてすむし、別ユーザの操作やバッチ処理などからおこる、サーバのデータとのコンフリクトの可能性も減る。
特にパフォーマンスが求められない限り、シングルページアプリケーションよりも、ページ遷移させてそこをリッチにした方が、いろいろ初期化されるので開発も楽で、ユーザ体験も自然なことが多い。
そして angularJS で作っておくと「「一覧」「詳細編集」で別にしてたけどいろいろ仕様追加があって一つにまとめたほうが便利」的な流れでも生き残れたりする。
(追記:5/20)「angularJS はシングルページアプリケーションに向いてない」という事ではなくて「シングルページアプリケーションっ ていろいろ難しいから、避けられるなら避けたほうが開発が楽」という話です(/追記)
angularJS と jQuery は同時に使ってよい
angularJS と jQuery はまさにライブラリとフレームワークの関係で、angularJS のDOM操作部分=ディレクティブの中で、jQuery を動かすようにすればよい。いろいろな解説で「jQuery使っちゃダメ」みたいな事が書いてあるが、「コントローラーからDOMをセレクタ指定でイベント仕込むようなやり方で jQuery は使っちゃダメ」というのが正しい。なぜなら「DOMの生成破棄のタイミングがコントローラーの実行タイミングと違うから」だ。
サービスではエレメント指定しないなら使っていいし、ディレクティブでは「そのディレクティブ内に影響範囲をとどめる」という注意を持っていれば積極的に使ってもよい(scope.$apply などとの連携方法の学習が必要になるが)。
jQuery プラグインにはいろいろあって、angularJS と組み合わせたときの相性・指針は
○ | HTML/DOM/イベントに関係ないライブラリ jQuery.md5 とか | → どこでも(コントローラー内でさえ)積極的に使ってよい。まあそれjQueryである必要ないよね… |
○ | 外部サービスとの通信ライブラリ $.ajax とか | → サービスにしよう。そして受信が完了したら promise でコールバックして、 $rootScope.$apply() を呼ぶ |
○ | $(elm).hoge() すると elm にエフェクトがかかるもの(text-shadow とか) | → 簡単にディレクティブ化できるのでそうやって使いましょう。 |
○ | $(elm).hoge() すると elm の内部のdomを変更してなんかする系(スクロールバーをおしゃれにとか) | → まあだいたいディレクティブ化できるのでそうやって使いましょう。一部 dom を壊して angularJS が設定する dom 情報とを破壊するものがあるかもしれないのでそれだけ注意 |
○ | $(elm).hoge() すると elm の内部にデータに沿ったパーツが表示される系(カレンダー表示とかグラフ系とか) | → 簡単にディレクティブ化できるのでそうやって使いましょう。フォームの入力パーツなら ngModel とかと連携しないといけなくてその辺がちょっと複雑だけど少し頑張ればできる |
△ | テンプレートエンジン | → ディレクティブ化できるけど完全にangularJS化した方がいいよね |
jQuery を angularJS のディレクティブに閉じ込めるのは難しくない
angularJS の中でもディレクティブの仕様は複雑怪奇であるが、「jQueryを閉じ込める」という目的ならそんなに難しい使い方は必要ない。
例えば
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.js"></script> <button class="popup" data-text="hoge1">1</button> <button class="popup" data-text="hoge2">2</button> <div id="alert" style="display:hidden"></div> <script> $(function(){ /** * popup クラスのボタンはポップアップ。クリックするとアラートdiv(#alert)を表示する。表示内容は ボタンの data-text の内容。 */ $("button.popup").click(function(){ $("#alert").hide().text($(this).data("text")).fadeIn(); }); }); </script>
というのがあるとする*3。これなら
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/angularJS/1.2.16/angular.js"></script> <div ng-app="app"> <button popup data-text="hoge1">1</button> <button popup data-text="hoge2">2</button> <div id="alert" style="display:hidden"></div> </div> <script> angular.module('app') /** * popup ディレクティブはポップアップ。クリックするとアラートdiv(#alert)を表示する。表示内容は ボタンの data-text の内容。 */ .directive('popupDirective',function(){ return { link:function(scope, elm, attrs){ elm.click(function(){ $("#alert").hide().text(elm.data("text")).fadeIn(); }); } }; }); </script>
と、とりあえず使えるのはすぐに作れる。 #alert が、あまりに気になる(部品のスクリプトに全体の特殊事情が書かれている!)からまあ
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/angularJS/1.2.16/angular.js"></script> <div ng-app="app"> <button popup popup-target="#alert" data-text="hoge1">1</button> <button popup popup-target="#alert" data-text="hoge2">2</button> <div id="alert" style="display:hidden"></div> </div> <script> angular.module('app',[]) /** * popup ディレクティブはポップアップ。クリックするとアラートを表示する。 * 表示内容は ボタンの data-text の内容。 * popup-target 属性にアラートボックスのセレクタを指定する(例: popup-target="#alert" )。 */ .directive('popup',function(){ return { link:function(scope, elm, attrs){ elm.click(function(){ $(attrs.popupTarget).hide().text(elm.data("text")).fadeIn(); }); } }; }); </script>
こうするともうちょっと汎用的になる(元のjQueryのやつより汎用的になった!)。だいたいの jQuery プログラムは、こんな感じで自分で中途半端にラップして使うと、jQuery の影響範囲を封じ込めることができる。*4
はまりがちなのは
- 独自のタイミングで scope の値を更新したけど表示が更新されない
- jQuery でやってたアニメーションを ng-animate でどうやったらいいか分からない
- 出し消ししたいエレメントを動的に生成しようとして躓く
- → jQuery の show() hide() とか ng-show ng-if でええんやで
- 「jQuery 非依存」を謳う angularJS専用同機能モジュールを探したけど今までと書き方がだいぶ変わってつらい
- → 概念/使い方が全然違って学習/書き換えコストが高いことが多いしネットで見つかるのは玉石混合でバギーだったりするし、最初の内は自分でディレクティブ作って jQuery プラグインを中途半端にラップして使ったほうが楽。目当ての jQuery プラグインがなくて angularJS モジュールを知ってるなら angularJS 専用モジュールの使い方を覚える、でいいけど、すでに使い慣れた jQuery プラグインがあるなら自分の使い方に合わせてラップする。その参考に既存のモジュールを探すと、ためになる(オレオレラッパーにいろいろ機能・汎用性を足していくと最終的に「なるほど!このモジュールすごい考えられてたんだ!」となることが多い)
- scope.$on() scope.$emit などで独自イベントを発行したりするイベント駆動
既存の jQuery が idやセレクタ指定つかいまくりの場合は移行が厳しいが、むしろ、この状態を制限したい(idやセレクタがグローバル変数的になりがちなので回避したい)がための「ディレクティブ化」であるのでそこは積極的に変えていこう。
初期コストが増えてもメリットがある場合にフレームワークを導入する
jQuery 関係をディレクティブ・スコープに切り出すメリットとしては「angujarJS の他の機能が使える」の他に
- view の変更とデータの変更を切り離せる
- テストが容易になる
- 汎用性が上がる
- メンテナンス性が上がる
- view の変更の影響範囲を狭めやすい
- 再利用性が上がる
代わりに
- 使い回ししない部分でもディレクティブ化を求められる
- 覚えないといけない規則が多くなる
世の中のweb開発にはこれらのコストを見ない方が現実にあっている場合も多くて、例えば広告・短期イベントページなど改修・長期メンテナンスが想定されなかったり、状態が少なく「全部グローバル変数でも何とかなる」程度の複雑さのページだ。そのばあいはjQuery の方が合っているだろう。
これは「モジュール化すべきか?」「フレームワークを導入するべきか?」「マイクロサービスに分割すべきか?」みたいな問題で、PHPでも単ファイルにべた書きしたり共通includeした方がよい規模がある。rubyなどはデコードやエラー処理、cgi設定など諸々している内に「ならrails/sinatraで」となりがちだが PHP なら 3ページくらいで他と切り分けられるなら素のPHPの方が楽で早い、みたいな話だ。
まとめ
*1:まあこれについて「それjavascriptなくてもできるよ」というのは同意で、それで十分ならjavascript使わない方がいいと思います
*2:俺調べ。もうちょっと練ったらわかりやすい指針が出せるのかも。誰か……
*3:この程度だとわざわざ作らなくても既存のディレクティブで実装できるが、まあ jQuery 使う例
*4:本当はもう一段階進めて これくらい まですると、さらに angularJS っぽくて、なるほど ng-click ng-show ってそうなってるのか、みたいなのが見えてくると思う
*5:あるいは scope.$eval や $parse(exp)(scope).assign(val) など