ygoto3.com

Front-end engineer at CyberAgent

AngularJS の Controller / Service / Directive / Filter 役割のポイント

会社内で AngularJS の Working Group を作り活動している中で、よく上がる質問の1つが AngularJS における Controller / Service / Directive / Filter に書く処理をどう分けたらいいのか、でした。

本記事では、Controller / Service / Directive / Filter の役割のポイントを整理したいと思います。

基本ポイント

他の MVC デザインパターンと同様、ビジネスロジックとプレゼンテーションロジックを分離することが基本です。

ビジネスロジックを Service が担当し、Controller でそれを紐付けてテンプレートに共有します。プレゼンテーションロジックは Directive と Filter が担当し、DOM 操作処理やデータ整形処理をテンプレートに共有します。

Controller

Controller ではビューで表示するデータとユーザーアクションに対するメソッドを定義します。

AngularJS では $scope オブジェクトを介してデータやメソッドをテンプレートで共有することができます。Controller には、ビジネスロジックを $scope オブジェクトに書き込んでいき、テンプレートでは $scope オブジェクトで共有されたデータとメソッドを参照します。

共有される $scope オブジェクトがカオスな状態になるのを避けるために、Controller では $scope オブジェクトを書き込み専用として、テンプレートでは読み取り専用として扱うと良いです。

また、直接 DOM を参照することなどは行わなないようにします。DOM 操作処理が Controller に入ってしまうと、デザイン変更などでテンプレートの HTML に変更を行わなければいけない場合に、Controller も書き変える必要が出てくる可能性があります。

そのため、DOM 操作処理は Controller 内では極力行わなず、Directive にその役目を渡しましょう。

ビジネスロジックとプレゼンテーションロジックを Service、Directive、Filter に分離して Controller を簡潔に保つことができると理想的です。

Service

ビジネスロジック担当です。ビューに依存しない処理を記述します。

また、各 Service はシングルトンとして存在するため、異なる Controller や Directive 間で共有するモデルとして使用できます。

Directive

HTML を拡張する機能です。前述の DOM 操作が必要になる場合を含み、プレゼンテーションロジックを記述します。

自身で Controller を持ち、単一で完結するコンポーネントを作ることもできますし、属する scope で公開されているデータと振舞いをテンプレートに紐付ける役割を担います。

Filter

データを整形する処理を記述します。

Directive と同様にプレゼンテーションロジックを記述しますが、Directive と違い、直接 Scope にアクセスすることはできません。モデルを変更することなく表示フォーマットのみを変更します。

サンプル

ここでは、フォームから ユーザーデータを追加する処理を例に説明します。

この例では、FormCtrl という Controller、noHyphen という Directive、User という Service を組み合わせて実装しています。

まずモジュールを宣言します。

1
var app = angular.module('app', []);

テンプレート

1
2
3
4
5
6
7
<body ng-app="app">
<form name="registrationForm" ng-controller="FormCtrl" novalidate>
<input type="text" ng-model="user.nickname" required no-hyphen />
<button type="submit" name="nickname" ng-click="submit()"
ng-disabled="registrationForm.$invalid">送信</button>
</form>
</body>

このようなテンプレート用意した場合、各々の役割は以降のようになります。

Controller に書く実装

1
2
3
4
5
6
7
8
9
10
11
12
13
app.controller('FormCtrl', function ($scope, $log, User) {
$scope.submit = function () {
User.addUser($scope.user)
.then(
function (resource) {
$log.log(resource);
},
function (err) {
$log.warn(err);
}
);
};
});

ここでのポイントは $scope オブジェクトの設定だけを記述している点です。DOM にイベントハンドラを紐付ける $('button').on('click', function () { ... }) などの処理はビルトインの Directive である ngClick に任せてあります。

また、バリデーション機能は、特定の DOM の値を取得する必要があるため、Controller 内には記述しません。後述する noHyphen という Directive を実装して機能を実現します。

このフォームは送信ボタンを押された時に Ajax 処理も実行しますが、その処理も後述する User という Service に実装を切り分けています。

Directive に書く実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.directive('noHyphen', function () {
return {
require: 'ngModel',
link: function (iScope, iElem, iAttr, ngModelCtrl) {
ngModelCtrl.$parsers.push(function (viewVal) {
var _isValid = true;
if (~viewVal.indexOf('-')) {
_isValid = false;
}
ngModelCtrl.$setValidity('noHyphen', _isValid)
});
}
};
});

ここでのポイントは、ngModel を介して DOM 操作をしている点です。Controller で必要だった DOM にイベントハンドラを紐付ける処理は Directive に記述します。

処理した結果を ngModel ディレクティブを介して FormCtrl$scope に渡しています。

Service に書く実装

今回は、factory メソッドを使用します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
app.factory('User', function ($http) {
var _onSuccess = function (res) {
return res.data;
},
_onError = function (res) {
return $q.reject('an error occured.');
},
_addUser = function (user) {
var request = $http({
method: 'post',
url: '/api/something',
params: {
action: 'add'
},
data: {
nickname: user.nickname
}
});
return request.then(_onSuccess, _onError);
};
return {
addUser: _addUser
};
});

ここでのポイントは、ビューに依存しない処理のみを記述している点です。

ここでは、新規ユーザーデータの送信に使われる Ajax 処理を実装しているので、FormCtrl はこの User Service をインジェクトすることで自身の $scope オブジェクトに持っているデータを送信することができます。

特有の概念が多いため AngularJS での役割の分担は分かりづらいですが、自分は上記のように処理を分けるようにしています。

2012 jQuery→Early 2013 Backbone→Late 2013 AngularJSな自分がハマった10のこと

こちらはFrontrend Advent Calendar 2013 22日目の記事です。

本記事は、2012年までjQueryだけで開発していたフロントエンドエンジニアの自分が、Frontrendな方々に影響を受けたのをきっかけに、とあるコミュニティ系WebサービスでAngularJSを導入するまでの過程で影響された記事やイベントや人を時系列で紹介すると共に導入後AngularJSの開発でハマった10のことを紹介します。

2012.07:Anatomy of Backbone.jsで学ぶ

AngularJSと言えば、MVC的なアーキテクチャをJavaScript開発に取り入れるためのフレームワークで現在は業務でも使用させていただいています。

しかし、振り返ってみると、2012年はMVなんちゃらなフレームワーク等には、全く無縁の生活を送っていました。

そんな2012年の7月頃に、FrontrendのYuya SaitoさんにCode SchoolAnatomy of Backbone.jsを紹介していただいて、BackboneをはじめとするJS開発におけるMVC的な発想を知ります。

ご存知の方も多いと思いますが、Code Schoolはステップごとに用意されている講義形式の動画で学び、そのまま出題される課題をブラウザ上のエディタでコードを書いて解答して、実践的にプログラミングを学ぶことができるサービスです。

Anatomy of Backbone.jsを受講した印象としては、とても初心者が学習しやすいチュートリアルです。JSライブラリと言えばjQueryしか使ったことがなかった自分はここでBackboneの基本の基本を学べたと思います。

2013.02:Frontrend Vol.4でBackbone導入決意

基本のチュートリアルをこなしたとは言え、実際のプロジェクト(リリース済)で使うことに敷居の高さとリスクを感じ、なかなか導入することもできないまま2013年になってしまいました。

しかし、2月9日に開催されたFrontrend Vol.4でのahomuさんセッション「jQuery to Backbone – アーキテクチャを意識したJavaScript入門」で、まさに「自分みたいにjQueryくらいしかライブラリ触ったことない人でもBackboneとか使えるかもー」と思い始めます。セッションの内容を参考にさっそく自分がフロントエンドを担当しているWebサービスでBackboneの導入を始めました。

このセッションは、当時のJS開発におけるjQueryが解決しない問題とBackboneを導入することで得られるメリットが分かりやすく説明されている上に、jQueryベースで記述されたコードをBackboneの構造に徐々に移していく具体的なコーディング手順まで紹介されています。自分はまさにその手順に従って、プロジェクトにBackboneを導入できたように思います。

2013.09:Backbone Is Not EnoughでEmberJSとAngularJSが気になる

Backboneを使い始めて半年くらい経ち、自分が担当しているサービスでは巡るめくアップデートとプロモーション施策の実装をしていかなければいけませんでした。そしてフロントエンドエンジニアが自分だけ、という状況だったこともあり「何か劇的に開発を高速化できる方法はないかな」と日々模索していました。

そして、Shine Technologiesのブログ記事「Backbone Is Not Enough」を読み、AngularJSに興味を持ち始めます。

記事では、Backboneでの大規模なSPA開発において、ネストされるViewをうまく構成する難しさ、Viewをテストする難しさ、メモリ管理の難しさ、容易に遅くなってしまうレンダリングへの対策やデータバインディングの重要さなどに関してEmberJSやAngularJSと比較して書かれていますが、中でも「Backboneと比較したEmberJSとAngularJSのコード記述量がかなり少ない」という1点に惹かれて、EmberJSとAngularJSが気になり始めます。

2013.10:A comparison of the two-way binding in AngularJS, EmberJS and KnockoutJSでAngularJSが一番良いような気がしてくる

EmberJSとAngularJSが気になり始めましたが、これらのフレームワークはそれぞれ何が違うのかは正直よく分からないでいました。Backbone単体では自分でこつこつ設定するデータバインディングを簡単に設定できる点に関しては、EmberJSもAngularJSも同じです。

その疑問に関しては、2013年10月に公開されたJSConf EU 2013(9月開催)のMarius Gundersen氏セッションの動画「A comparison of the two-way binding in AngularJS, EmberJS and KnockoutJS」で解決されます。

このセッションでは、AngularJS、EmberJS、KnockoutJSの双方向データバインディングにおける挙動の違いが分かりやすくまとめられています。

このセッションにおいてAngularJSは、Dirty Checkingという仕組みと非同期でデータをバインドをしているため、

  • リストの単純なレンダリングに関しては速い
  • Modelが複雑で巨大になってくるとレンダリングが遅い
  • コンピューティング処理が挟まれるプロパティに関しては重くなる

などの特徴が説明されています。

担当サービスにおいて、Modelはそこまで複雑かつ巨大にならないと思った点と単純なレンダリングの速さが気に入り、AngularJSの導入を決めました。

2013.10:A Better Way to Learn AngularJSで学ぶ

導入を決めたら、次はAngularJSについて学習しなければいけません。

Anatomy of Backbone.js」のような効率が良い(そしてできれば無料の)ラーニングリソースを探していたら(残念ながら、Code SchoolにAngularJSのコースはありませんでした。)、「A Better Way to Learn AngularJS」というラーニングリソースを見つけました。

Code Schoolのようにコーディングで課題を問いていくリッチな機能は無いですが、初心者にも分かりやすく説明されたAngularJSのチュートリアルを動画で見ることができます。無料で学べるのですが、これを一通りこなすだけでAngularJSの基本的な使い方に関しては網羅できるように思います。

2013.10:AngularJSで開発を始めていろいろとハマる

もちろん基本的な使い方しか学んでいない自分は、AngularJSでサービスの開発を始めると、いろんなところでハマりました。Backboneを利用していた時と比べ、確かにコード記述量は減りましたが、AngularJSについて調べている時間は増えました。

そんなわけで「Backbone Is Not Enough」に載っていたAngularJSのラーニングカーブをしみじみ実感しましたが、同時にノウハウが溜まった後は劇的に楽になるはず、という期待でいっぱいでした。

AngularJSで開発を始めてハマった10のこと

本記事のタイトルにある通り、ここからはAngularJSでの開発で最初の頃に自分がハマった点を回避策とともにリストしていきたいと思います。

1. JSファイルをminifyしたら動かなくなった

AngularJSでは、Controllerの書き方にパターンがいくつか存在しますが、そのパターンのうちminifyするとJSが正しく動作しなくなるものがあります。

AngularJSにはDI (Dependency Injection)と呼ばれる仕組みがあります。Controllerで使用するserviceを指定する際に、functionの引数に指定された変数名から自動的に必要なserviceを決定できるすごい機能を持っているために、起こってしまうのがこの問題です。

回避策は2点。

  • 引数にわたすserviceの名前を文字列で明示する
  • ngminを使用する

1点目の回避策は、引数にわたすserviceの名前を文字列で明示することです。

minifyすると動かない書き方(functionの引数だけでserviceを指定)

1
2
3
4
angular.module('myApp', [])
.controller('MyCtrl', function ($scope, $http) {
// ...
});

minifyしても動く書き方(functionの引数の前に文字列でserviceを指定)

1
2
3
4
angular.module('myApp', [])
.controller('MyCtrl', ['$scope', '$http', function ($scope, $http) {
// ...
}]);

2点目の回避策は、使用するpre-minifierをngminにすることです。

こちらだと動かない方の書き方をしても、ngminが動く方の書き方に変えてminifyしてくれます。これでキーの打数は減り、楽して開発できるでしょう。

2. RequireJSなどで遅延ロードするとAngularJSが正しく動かない

Backboneを使っているときは、モジュールごとに処理を分けてRequireJSで遅延ロードさせたりしていましたが、AngularJSで同様のことをしようと思うとモジュールのロードが完了する前にAngularの起動が行われて、意図した挙動をしなくなることがあります。

回避策は、手動で起動することです。

1
2
3
4
5
6
require(['require', 'exports', 'app'], function (require, angular) {
// 起動前のいろんな処理...
// 手動で起動
angular.bootstrap(document, ['myApp']);
});

3. AngularJS起動前だとng-hideなどで隠れるはずの要素が一瞬表示されてしまう

SPAで作っている場合には問題ないかもしれませんが、そうでない場合、先述した手動での初期化などを行うとAngularJSの起動が遅くなり、ページロード時にngHideディレクティブなどを指定しているDOMが一瞬表示されてしまうことがあります。

angular.cssを読み込んでおき、対象の要素のclass属性にng-hideを足しておくことで回避できます。

ngHideなどの処理は、内容的には**display:none;**を適用するために、ng-hideというクラスを要素に付けているだけです。

1
2
3
.ng-hide {
display: none !important;
}

angular.cssを読み込まなくても、上記のようなcssが入っていれば大丈夫です。

4. AngularJS起動前にが一瞬表示されてしまう

上記と同様に、AngularJS起動前の評価されていないexpressionも生のテキストとしてページロード時に一瞬表示されてしまいます。

1
<p>{{comment}}</p>

このような場合は、

1
<p ng-bind="comment"></p>

で回避することができます。 もしくは、

1
<p ng-cloak></p>

でも可能ですが、ngCloakを使用する場合はngHide同様angular.cssを読み込んでおく必要があります。

この問題を回避するという用途的には、ngCloakを使う方が正しいようです。

5. textarea要素にデフォルト値が設定できない

textarea要素にデフォルト値を設定したいと思い、

1
<textarea ng-model="comment">コメント</textarea>

上のように設定してもテキストエリア内にもcomment Modelにも反映されません。

回避策は、ng-initで明示的にcomment Modelをそのデフォルト値で初期化することです。

1
<textarea ng-init="comment = ‘コメント’" ng-model="comment"></textarea>

6. 表示テキストが2重エスケープされる

AngularJSでHTMLにバインドすると自動的にエスケープがかかってしまいます。サーバサイドでエスケープ処理を施している場合は、場合によっては2重エスケープ状態が発生してしまいます。

そんなときは、ngSanitizeをインストールして、

1
angular.module('myApp', ['ngSanitize']);

DOMには

1
<span>{{item.content}}</span>

と書く代わりに

1
<span ng-bind-html="item.content"></span>

のようにしてエスケープ処理をしないようにできます。

7. Directiveの命名規則がややこしい

ここからは、AngularJSで一番素敵な機能だと思っているDirectiveについてのネタが続きます。

1
<x-switch></x-switch>

このようなElement DirectiveをJS側で定義したいとき、

1
2
3
4
angular.module('myApp', [])
.directive('xSwitch', function () {
// ...
});

のように、ハイフンつなぎ(x-switch)→キャメルケース(xSwitch)とする必要があります。

8. カスタムDirectiveの内側のコンテンツが消える

当然と言えば当然なのですが、テンプレートを指定したDirective DOMの内側にあらかじめコンテンツを入れておいてもテンプレートに置き変えられてしまいます。最初はこれに気づきませんでした。

Directiveの内側に入れたコンテンツをテンプレート内の特定の場所で利用したい場合は、JS側のDirective定義でtranscludeをtrueに設定し、template側のコンテンツを利用したい要素にng-transclude属性を設定する。

1
2
3
4
5
6
7
8
9
10
angular.module('myApp', [])
.directive('someDirective', function () {
return {
transclude: true,
template: '<div class="well"><p ng-transclude></p></div>',
link: function() {
// …
}
};
})

9. カスタムDirectiveをたくさん作っていたらtemplate用HTMLファイルのリクエストでいっぱいになる

カスタムDirectiveはとても便利で、HTMLを綺麗にしておけるし、処理も独立させられるのでついついたくさん作ってしまいます。そのときに使用するテンプレートHTMLを外部においてtemplateUrlで読み込ませると、そのテンプレートの数分だけリクエストが別途走ってしまいます。

回避策は、外部テンプレートHTMLをtemplateCacheを使用してJSにキャッシュしてくれるgrunt-angular-templatesを使用することです。

こちらに関しては、別記事参照。

10. AngularJS Batarangの存在を知るのが遅かった

これは、ハマったことでも何でもないですが、AngularJSのデバッグをする際にDev ToolsとDOM自体にプロパティ表示用のコードを書いたりしていました。

AngularJSのデバッグ用Chrome Extensionに「Angular Batarang」というものがあります。これの存在をもっと早く知っていたらデバッグがもっと楽だっただろうと思います。

BackboneにもFirebug Extensionの「Backbone-Eye」などがありますし、やはり専用のデバッグツールがあると開発も快適です。

2013.10:ブログを始める(余談)

開発にハマっては調べハマっては調べしている日々の中、FrontrendのHiroki Taniさんに何気なく「ブログとか書いてみたらどうですか」と言われたのをきっかけに、どうせならAngularJSで調べたことでも書いてみようと思い、このブログを始めました。あまり継続して書けていないので、来年はもっと頑張ろうと思います。

終わりに

Frontrend Advent Calendar 2013という場を借りて、とても個人的な振り返りをさせていただきました。今日この記事を書けるのも、先に書いた通りFrontrendの方々にいただいたきっかけが大きく影響しています。

明日は、ysugimoto さんです。

$routeProviderがない - AngularJS 1.2.0-rc にアップデートする際の注意

2013年11月1日現在、AngularJS最新のstable versionは「1.0.8」、「1.2.0」はRC扱いになっています。

通常はstableな「1.0.8」を使用すればいいのですが、たとえばngAnimateなどの機能は「1.2.0」から提供されているので、ngAnimateを使いたくてアップデートすることもあるかと思います。

1.2.0ではngRouteモジュールが本体から分離されている

SPAで開発する場合など$routeProviderサービスを使用してルーティング処理を行っていると思いますが、1.0.8では本体に組み込まれている$routeProviderが1.2.0ではngRouteモジュールのサービスとして分離されています

なので、$routeProviderを使用しているアプリで「1.2.0-rc」にアップデートした場合、下記のようなエラーがコンソールに表示されます。

Uncaught Error: [$injector:modulerr] Failed to instantiate module angularApp due to: Error: [$injector:unpr] Unknown provider: $routeProvider

修正

対応方法は簡単で、ngRouteモジュールを提供するangular-route.jsを別途インストールします。例えばBowerで

1
$ bower install angular-route --save

でインストールした後、HTMLファイルでangular-routeを読み込み、

1
<script src="bower_components/angular-route/angular-route.js"></script>

依存するモジュールとしてngRouteを追加します。

1
2
3
4
angular.module('myApp', ['ngRoute'])
.config(['$routeProvider', function($routeProvider) {
// ...
}]);

これでエラーは消えて正常な動作に戻るはずです。