AngularJSでつらくならないために抑えておきたい5つのポイント

この記事はエウレカ Advent Calendar 2015 (Qiita) の15日目の記事です。


はじめに

こんにちは。エウレカでPairsの開発を担当している太田です。
PairsのPC/SPのクライアントサイドはPHPで実装されていたのですが、AngularJSによるSPAへ移行する開発をしています。
今回はそこで得られた知見をご紹介したいと思います。

1. TypeScriptを使おう

AngularJSではなく、JavaScriptの困ったところなのですが、

  • スクリプト言語なので実行してみないと動くかどうかわからない
  • JavaScriptの言語仕様がちょっと独特でしんどい(prototype、スコープなど)

といったつらみがあります。JavaScriptに慣れていないエンジニアにとっては痛いところです。
しかし、エウレカのハイブリッドエンジニアとしてはここを避けるわけにはいきません。
この問題を乗り越えるために、Pairsでは静的型付け言語でJavaScriptのスーパーセットであるTypeScriptを採用しています。
TypeScriptにより言語仕様的なハードルが下がり、かつ型による安心も得られるというお得感満載になります。
また、AngularJS + TypeScriptはとても相性がいいと思います。実際Angular2はTypeScriptで実装されています。
以降のサンプルコードではTypeScriptを使っています。

2. コントローラー肥大化を回避しよう

AngularJSに限った話ではなく、MVCフレームワークあるある話ですが、コントローラーについついロジックを詰め込んでしまい、肥大化してしまい、メンテナンス性・ポータビリティが落ちてしまうことがよくあります。ロジックを共有したい目的でコントローラーの継承が実装されたりしてくると、かなりまずい兆候です。
これを避けるためにロジックをただちにモデルへ移動したり、(ビュー特有の処理であれば)TypeScriptのmixinで共有処理をまとめたりといった対処が必要です。

【参考】
BaseViewControllerは作りたくない

3. カスタムディレクティブでUIコンポーネントを作ろう

AngularではカスタムディレクティブでUIコンポーネントを作成することができます。
コンポーネントベースでUIを組むといろいろいいことがあります。

  • 問題の分割統治
  • 再利用可能部品の実現
  • Angularの自由すぎる仕様に制約を付ける

例えば、とあるビューをこのようなUIコンポーネントに分けることについて説明します。

_customdirective_1 2

※画像は開発中のものです

カスタムディレクティブの作り方

ディレクティブの構造をテンプレートから見ていきます。
まずはpartnerListディレクティブの使い方です。

<div ng-show="ctrl.partners.length > 0">
<div data-banner-show></div>
<div data-partner-list ng-attr-partners="ctrl.partners" ng-attr-pager="ctrl.pager" ng-attr-kind="ctrl.kind" ng-attr-user-setting="ctrl.userSetting"></div>
</div>

data-partner-list属性を含むdivがpartnerListディレクティブになります。
ng-attr-xxx属性で定義しているのはディレクティブに引き渡すオブジェクトです。
後述しますが、カスタムディレクティブのScopeは原則Isolate Scopeとします。
Isolate Scopeにするため、カスタムディレクティブが扱うデータは上位から属性で引き渡す必要があります。

partnerListディレクティブ自体のテンプレートでは上位ディレクティブから受け取ったpartnersリストをng-repeatで回してpartnerItemディレクティブを生成します。

<ol class="box_like_list">
<partner-item ng-repeat="(pindex , partner) in ctrl.partners" ng-attr-partner="partner"></partner-item>
</ol>

そして、partnerItemディレクティブ自体のテンプレートにlikeActionButtonディレクティブを含みます。

<ul>
<li class="like_item">
<!--- snip... --->
<div class="item_inner01">
<div class="box_user_photo" data-index="1">
<!--- snip... --->
</div>
<div class="box_user_summary">
<!--- snip... --->
</div>
</div>
<!--- snip... --->
<div class="item_inner02"></div>
</li>
</ul>

カスタムディレクティブのScopeはIsolate Scopeにしよう

partnerListディレクティブの定義はこのようになっています。

export function partnerList(): ng.IDirective {
return {
restrict: 'A',
scope: {
partners: '=',
pager: '=',
kind: '@',
userSetting: '=',
},
controller: Pairs.like.controller.PartnerListController,
controllerAs: 'ctrl',
bindToController: true,
templateUrl: '/partial/like/top/partner_list',
link: (scope: any, element: any, attributes: any, ctrl: any) => {
}
};
}

ディレクティブのrestrictオプションの’A’はこのディレクティブをHTMLElementのAttributeで定義することを示します。

重要なのは次のscopeオプションです!scopeオプションを定義すると、ディレクティブのScopeはIsolate Scopeになります。

AngularのデフォルトではScopeは継承されるようになっており、下位から上位Scopeを参照することができます。これはこれで便利なこともあるのですが、上位に依存してしまったり、意図せず上位Scopeのプロパティを参照していたりと、混乱のもとになりがちです。
Isolate ScopeにするとScopeの継承関係が断ち切られ、完全に独立したScopeを作ることができます。これにより可視範囲を限定することができ、依存がない再利用可能なコンポーネントができます。

【参考】
AngularJSモダンプラクティス

4. UIコンポーネント間の連携にイベント通知を使おう

Angularにはイベント通知のAPIがビルトインされています。
イベント通知を使えば、UIコンポーネント間を疎にしながら連携を実現することができます。
イベント通知を行うAPIとして、$scopeの3つのメソッドがあります。

  • $broadcast
  • $emit
  • $on

$broadcastは上位scopeから下位scopeへ対してイベント発火する、$emitは下位scopeから上位scopeへ対してイベント発火する、という違いがあるのですが、特に理由がなければ $rootScope限定にして

  • $rootScope.$broadcast でイベント発火
  • $rootScope.$on でイベント受信

と決めてしまうでもよいのかなと思っています。(使い分けめんどいので)

UIコンポーネント間の連携例

みてね!ボタンクリックで確認モーダル表示する処理をイベント通知で行なっています。

_event1_2

※画像は開発中のものです

_event2_3

※画像は開発中のものです

みてね!ボタンがクリックされるとng-clickのctrl.sendAction(ctrl.partner, ‘mitene’)が実行されます。

<a class="button_like button_mitene_d button_like_mitene button_orange_a button_like_view button_like_c button_e">
<span class="like">{{{ msg . "みてね!" }}}</span>
</a>

ボタンクリックのハンドラーであるsendActionメソッドは$broadcastでイベントを通知します。

sendAction(partner: Pairs.libraries.classes.partner.Partner, state: string): void {
this.$rootScope.$broadcast('open_' + action + '_modal', partner);
}

イベント通知を受けるみてね!モーダルのコントローラーです。
$rootScopeの$onで待ち受けるイベントとパラメーターとコールバックを定義しておきます。
コールバックではモーダル表示処理を行なっています。

export class MiteneModalController {
isShow: boolean;
partner: Pairs.libraries.classes.partner.Partner;
static $inject = [
'$rootScope'
];
constructor(protected $rootScope: ng.IRootScopeService) {
$rootScope.$on('open_mitene_modal', (partner: Pairs.libraries.classes.partner.Partner) => {
// モーダル表示
this.partner = partner;
this.isShow = true;
this.open();
});
}
}

【参考】
オレ流AngularJSを使った設計ポリシー

5. $templateCacheでHTMLテンプレートのリクエストを減らそう

カスタムディレクティブでUIコンポーネント化を進めていくと、付随してディレクティブのテンプレートが増えていきます。
そうすると、1つのビューを表示するために多くのリクエストが必要となり、これが積み重なるとシステムに無視できない負荷を与えかねません。
これを回避するために、Angularの$templateCacheを利用して、イニシャルビューに複数のテンプレートを埋め込むようにします。

<div class="delete_after_cached like_top_like_modal_content">{{{ template "sp/like/partial/top/like_modal.html" . }}}</div>
<div class="delete_after_cached like_top_like_and_message_modal_content">{{{ template "sp/like/partial/top/like_and_message_modal.html" . }}}</div>
<div class="delete_after_cached like_top_like_response_modal_content">{{{ template "sp/like/partial/top/like_response_modal.html" . }}}</div>
<!--- snip... --->
<script type="text/javascript">// <![CDATA[
angular.module('Pairs.main').run(function($templateCache) {
$templateCache.put('/partial/like/top/like_modal', $('.like_top_like_modal_content').html());
$templateCache.put('/partial/like/top/like_and_message_modal', $('.like_top_like_and_message_modal_content').html());
$templateCache.put('/partial/like/top/like_response_modal', $('.like_top_like_response_modal_content').html());
// snip...
$('.delete_after_cached').remove();
});
// ]]></script>

こうしておくと、テンプレートが必要になったときにAngularは最初に$templateCacheを探して取得するので、サーバーへリクエストしなくなります。

まとめ

AngularJSは高機能で強力なフレームワークで、SPAを構築するために必要な機能を十二分に持ち合わせています。
しかし反面、なんとなく作っていくとつらいことになりがちなので、しっかりとした設計が大切です。