AngularJS入門(2) 〜ルーティング, フィルター〜 の続きです。

今回はサービスと、DIです。

サービス

一定処理をサービスとして定義できます。 サービスにすることでテストも容易になります。

サービスの使い方

実は既にサービスを使っています。

DescCtrlを作った時に定義した引数$routeParamsがサービスです。

// app/scripts/main.js
angular.module('...')
    .controller('DescCtrl', function ($scope, $routeParams) {

        $scope.framework = {
            name: $routeParams.name
        };

    });

サービスを使いたい時は、定義のクロージャの引数の変数名を サービス名と同じ にするだけでよいです。

AngularJSのサービス => http://docs.angularjs.org/api/ng#service

補足 Angularは変数名からサービスを特定しているので、minifyされると動作しなくなってしまいます。 minifyする場合は、サービス名を指定する必要があります。

angular.module('...')
    .controller('DescCtrl', ['$scope', function (scope) {
        // サービス名を指定した場合は引数の変数名は何でもよくなる
    }]);

サービスを定義する

今はngInitframeworksのデータを設定しているので、それをサービスにしてみます。

// app/scripts/app.js
angular.module('', [...])
    // サービスの定義
    .factory('frameworksDataSource', function () {

        // ここのスコープは1度しか実行されない
        var frameworks = ['Backbone.js', 'Ember.js', 'Knockout.js'];

        // ここで return したオブジェクトがサービスになる
        return function () {
            return frameworks;
        };
    });

補足 Angularのサービスに$が付いているので付けたくなりますが、衝突するので付けない方がよいです。 > ###### $ Prefix Naming Convention > You can create your own services, and in fact we will do exactly that in step 11. As a naming convention, angular’s built-in services, Scope methods and a few other Angular APIs have a $ prefix in front of the name. > The $ prefix is there to namespace Angular-provided services. To prevent collisions it’s best to avoid naming your services and models anything that begins with a $.

// app/scripts/main.js
/* jshint indent: 4, newcap: false */
angular.module('angularSampleApp')
    // サービスを取得する事にする
    .controller('MainCtrl', function ($scope, frameworksDataSource) {

        $scope.world = 'Angular';

        // サービス経由でデータを取得して設定する
        $scope.frameworks = frameworksDataSource();

        $scope.addFramework = function (text) {
            $scope.frameworks.push(text);
            $scope.world = '';
        };

    })
<!-- app/views/main.html -->
<p>
    <input type="text" ng-model="world">
    <span ng-click="addFramework(world)" class="btn btn-primary">Add</span>
</p>
<p>Hello {{ world }}!</p>

<div> <!-- ngInitは削除 -->
    <h3>{{ frameworks.length }} frameworks</h3>
    <input type="text" ng-model="searchText" />
    <ul>
        <li ng-repeat="f in frameworks | reverse | filter:searchText">
            <a ng-href="/#/frameworks/{{f}}">{{f|uppercase}}</a>
        </li>
    </ul>
</div>

image

サービスから取得したデータを表示する事ができました。

データの取得にDeferredオブジェクトを使用する

データソースがREST API等に移動する事を見越して、Deferredオブジェクト経由でデータを取得するように書き換えてみます。 Angularでは$qサービスがそれを提供してます。

// app/scripts/app.js
angular.module('', [...])
    // サービスの定義
    .factory('frameworksDataSource', function ($q) { // サービスから他のサービスを使う

        var frameworks = ['Backbone.js', 'Ember.js', 'Knockout.js'];

        return function () {
            var defer = $q.defer();

            defer.resolve(frameworks);

            return defer.promise;
        };
    });
// app/scripts/main.js
/* jshint indent: 4, newcap: false */
angular.module('angularSampleApp')
    .controller('MainCtrl', function ($scope, frameworksDataSource) {

        $scope.world = 'Angular';

        // サービス経由でデータを取得して設定する
        var promise = frameworksDataSource();
        promise.then(function (frameworks) {
            $scope.frameworks = frameworks;
        });

        $scope.addFramework = function (text) {
            $scope.frameworks.push(text);
            $scope.world = '';
        };

    })

ためしにデータの取得を遅延させる

$timeoutサービスを使って読み込みを遅くさせてみます。

// app/scripts/app.js
/* jshint indent: 4 */
'use strict';

angular.module('angularSampleApp', [
        'ngCookies', 'ngResource', 'ngSanitize', 'ngRoute'
    ])
    .factory('frameworksDataSource', function ($q, $timeout) { // $timeoutサービス

        var frameworks = ['Backbone.js', 'Ember.js', 'Knockout.js'];

        return function () {

            var defer = $q.defer();

            // 1秒後にデータを返すようにする
            $timeout(function () {
                defer.resolve(frameworks);
            }, 1000);

            return defer.promise;
        };
    })
    ;

少し手抜きですが、ngHide ngShowあたりを使うと簡単にそれっぽい感を演出できます笑

<!-- app/views/main.html -->
<p>
    <input type="text" ng-model="world">
    <span ng-click="addFramework(world)" class="btn btn-primary">Add</span>
</p>
<p>Hello {{ world }}!</p>

<div>
    <h3>{{ frameworks.length }} frameworks</h3>
    <input type="text" ng-model="searchText" />
    <span ng-hide="frameworks">読み込み中...</span> <!-- 追加 -->
    <ul>
        <li ng-repeat="f in frameworks | reverse | filter:searchText">
            <a ng-href="/#/frameworks/{{f}}">{{f|uppercase}}</a>
        </li>
    </ul>
</div>

サービスのテスト

定義したサービスのテストをします。 雛形から少し書き換えが必要な分があるので書き換えます。

// Gruntfile.js
    ...
    watch: {
      js: {
        files: ['{.tmp,<%= yeoman.app %>}/scripts/{,*/}*.js'],
        tasks: ['newer:jshint:all']
      },
      jsTest: {
        files: ['test/spec/**/*.js'], // ここを書き換える(35行目あたり)
        tasks: ['newer:jshint:test', 'karma']
      },
    ...
// test/spec/controllers/main.js
'use strict';

describe('Controller: MainCtrl', function () {

  // load the controller's module
  beforeEach(module('angularSampleApp'));

  var MainCtrl,
    scope;

  // Initialize the controller and a mock scope
  beforeEach(inject(function ($controller, $rootScope) {
    scope = $rootScope.$new();
    MainCtrl = $controller('MainCtrl', {
      $scope: scope
    });
  }));

  // 不要になったテストを削除
  // it('should attach a list of awesomeThings to the scope', function () {
  //   expect(scope.awesomeThings.length).toBe(3);
  // });
});

テストを書きます。

// test/spec/app.js
'use strict';

/* jshint indent: 4 */

describe('angularSampleAppTest', function () {

    // 自分のモジュールを読み込む
    beforeEach(module('angularSampleApp'));

    describe('FrameworksDataSourceTest', function () {

  // テストに必要なサービスを準備
        var ds, timeout;

        beforeEach(function () {
            // サービスはクロージャーにインジェクトして、それらを保持して使います。
            inject(function (frameworksDataSource, $timeout) {
                ds = frameworksDataSource;
                timeout = $timeout;
            });
        });

        // テスト
        it('service test', function () {
            var data = null;

            ds().then(function (frameworks) {
                data = frameworks;
            });

            // 非同期処理なのでデータは空
            expect(data).toBeNull();

            // 内部的にはタイマーを使ってるので、タイマーを進めるとデータが取得できる
            timeout.flush();
            expect(data).toEqual(['Backbone.js', 'Ember.js', 'Knockout.js']);

        });

    });

});

このテストフレームワークはJasmineなので、詳しいテストの書き方は公式を参照してください。 Jasmine 公式 => http://jasmine.github.io/

Gruntで変更を監視していると都度テストを実行してくれます。

image

DI

既にDIの概念を知っている方は分かっているかと思いますが、今回でもDIを数カ所か使いました。 よくあるDIコンテナと違って、セットアップが全くと言っていいほど要らないので楽ですね。

// app/views/main.js
angular.module('angularSampleApp')
    .controller('MainCtrl', function ($scope, frameworksDataSource) {
        // $scope, frameworksDataSourceがインジェクトされている。
    }