のらねこの気まま暮らし

技術系だけど、Qiita向きではないポエムとかを書くめったに更新されないヤツ

AngularJSのfactoryとserviceを読み解く(後編)

前編: ngularJSのfactoryとserviceを読み解く(前編)

前回のAngulaJS!(なんちゃらライブ的な) factoryやserviceといったAngularJSの機能をドキュメントを読んだりぐーぐる先生に聞いたりしてもまったくさっぱりよくわからなかったのでAngularJSのコードを追ってみた。

factoryは初期化処理をInjectorで一度だけ実行してその結果をAngularJSのcacheFactoryを使ってキャッシュしているところまではわかった。

しかし、serviceはよくわからなかった。 よくわからないので振り返りから。

.service(name, fn)

  1. serviceが呼ばれる
  2. $injector.instantiateにfnが渡される
  3. $injector.instantiateで別のクラス(仮にAとする)を作成してprototypeを継承する
  4. $injector.instantiateでクラスAのインスタンスを生成して$injector.invokeにserviceに指定したfnと共に渡す
  5. $injector.invokeでDIに指定したDependenciesを解析、取得
  6. $injector.invokeで、引数をdependenciesに、コンテキストをクラスAのインスタンスにしていしてfnを実行する
  7. $injector.invokeの返り値がobjectまたはfunctionであればそれをfactoryに渡す
  8. $injector.invokeの返り値がobjectでもfunctionでもなければクラスAのインスタンスをfactoryに渡す

つまり、こういうことだ!

// serviceに登録するfunction(コンストラクタ)
var User = function (options) {
    this.name = options.name || null;
    this.age    = options.age    || null;
    
    this.set = function (column, value) {
        this[column] = value;
    };
    this.get = function (column) {
        return this[column];
    };
};

// $injector.instantiate
var Constructor= function () {};
Constructor.prototype = User.prototype;
var instance = new Constructor();

// $injector.invoke
User.apply(instance, [{name: "tarou", age: 10}]);
var user = instance;

・・・実にわかりにくい。

気をつけなければならないのは、serviceの第二引数は初期化コールバック関数ではなく、クラスのコンストラクタであること。

factoryとおんなじ用に使うのは不適切で、angularとは別に定義したクラスを指定する(app.service("name", ["$rootScope", MyServiceClass])のように)か、コンストラクタで完結するクラスを定義すべきだ。

var User = function (options) {
    this.name = options.name || null;
    this.age    = options.age    || null;
    
    this.set = function (column, value) {
        this[column] = value;
    };
    this.get = function (column) {
        return this[column];
    };
};
app.service('user', [User]);
app.service('user', [function () {
    this.name = options.name || null;
    this.age    = options.age    || null;
    
    this.set = function (column, value) {
        this[column] = value;
    };
    this.get = function (column) {
        return this[column];
    };
}]);

serviceはどういう時に使うべき?

factoryとの違いとして、serviceはインスタンスの生成を保証し、かつ同一の名前空間で他のインスタンスは作られない。

デザインパターンで言うところのSingletonであり、アプリケーション内で整合性を管理する用途にもちいられるのだと想定できる。

たとえば、複数のController間で一つのリソースを管理したい場合などがそれに該当すると思われる。

下手に$rootScopeに値を入れたりするよりは固く実装できるのではないだろうか。

まとめ

serviceはSingletonなので、それなりの扱いを用意しよう。 serviceメソッドの第二引数はコンストラクタなのでそれを念頭に置いて利用しよう

AngularJSのfactoryとserviceを読み解く(前編)

AngularJSfactoryserviceがどうにも覚えられないのでまとめてみた。

まとめ

  • factoryはobjectをキャッシュしておく
  • serviceはインスタンス化してキャッシュしておく
  • providerの謎が深まった

factory

providerにfunctionらしきものを渡している。 $getはInjectorに指定されたときに一度だけ呼ばれる。

function factory(name, factoryFn) { return provider(name, { $get: factoryFn }); }

https://github.com/angular/angular.js/blob/master/src/auto/injector.js#L666

ここまでだとまだ実行されない。

var app = angular.module('app', ['ngResource']).factory('User', function ($resource) {
  return $resource('/api/user/:id', {id: "@id"}, {});
});

ここで実行され、以降の呼び出しはreturn $resource('/api/user/:id', {id: "@id"}, {});の評価結果がキャッシュされたヤツが呼び出される。

app.controller("UserCtrl", function (User) {
  $scope.user = User.$get(id: 1);
});

service

serviceに指定された無名関数はconstructorという名で$injector.instantiateの引数に渡され、その処理をfactoryへと渡している。

  function service(name, constructor) {
    return factory(name, ['$injector', function($injector) {
      return $injector.instantiate(constructor);
    }]);
  }

  function value(name, val) { return factory(name, valueFn(val)); }

  function constant(name, value) {
    assertNotHasOwnProperty(name, 'constant');
    providerCache[name] = value;
    instanceCache[name] = value;
  }

https://github.com/angular/angular.js/blob/master/src/auto/injector.js#L668-L680

$injector.instantiateは与えられた引数を、あたらにConstructorクラスが作られ、serviceに登録した関数のprototypeを継承させいる。 なぜArrayの場合は末尾を取り出しているのかはよくわかってない。 ['$rootScope', function () { ... }]的なアレの為に、Arrayの判定してる。 Constructorクラスをインスタンス化し、invoke(省略)に渡す。

その返り値がObjectまたはFunctionであればそのままをfactoryにわたすが、どちらでもない場合はConstructorインスタンスをfactoryに渡している。

    function instantiate(Type, locals, serviceName) {
      var Constructor = function() {},
          instance, returnedValue;

      // Check if Type is annotated and use just the given function at n-1 as parameter
      // e.g. someModule.factory('greeter', ['$window', function(renamed$window) {}]);
      Constructor.prototype = (isArray(Type) ? Type[Type.length - 1] : Type).prototype;
      instance = new Constructor();
      returnedValue = invoke(Type, instance, locals, serviceName);

      return isObject(returnedValue) || isFunction(returnedValue) ? returnedValue : instance;
    }

https://github.com/angular/angular.js/blob/master/src/auto/injector.js#L804-L815

なお、invokeでは(重要なところだけを挙げれば)Constructorクラスのインスタンスをコンテキストとしてserviceで指定した関数を実行しその結果を返しているだけである。

    function invoke(fn, self, locals, serviceName){
      if (typeof locals === 'string') {
        serviceName = locals;
        locals = null;
      }

      var args = [],
          $inject = annotate(fn, strictDi, serviceName),
          length, i,
          key;

      for(i = 0, length = $inject.length; i < length; i++) {
        key = $inject[i];
        if (typeof key !== 'string') {
          throw $injectorMinErr('itkn',
                  'Incorrect injection token! Expected service name as string, got {0}', key);
        }
        args.push(
          locals && locals.hasOwnProperty(key)
          ? locals[key]
          : getService(key)
        );
      }
      if (!fn.$inject) {
        // this means that we must be an array.
        fn = fn[length];
      }

      // http://jsperf.com/angularjs-invoke-apply-vs-switch
      // #5388
      return fn.apply(self, args);
    }

https://github.com/angular/angular.js/blob/master/src/auto/injector.js#L801

ここまで追ってみたけどよくわからないので、続きは明日。

Constructorで空のクラスをprototypeだけ継承し、インスタンス化する。そののちにinvokによってDependenciesを引数としてserviceで指定したfunctionに渡すことで、DIとしての動きを作っているっぽい。

どう使うのかはまだ曖昧だけど、輪郭が見えてきた。

後編: AngularJSのfactoryとserviceを読み解く(後編)

AngularJSでDOMの更新を監視する with iScroll4

iScrollをAngularJSと使いたい案件があったので、それを使うためにいろいろ調べた。

DOMを動かしてScroll

position:fixed等、モバイルでは一部バグがあったり非サポートだったりして、その大体にiScrollやscrollerjs等といったライブラリを用いる。

これらはDOMをCSSとJSで動かしてあたかもスクロールしているかのように見せるライブラリだ。

いろいろライブラリがあるけれど、とりあえずiScroll4を使った。 一度使ったことがあったし、ng-iscrollも使っていたので。

ng-iScroll

Angular x iScrollを使うとなった時、google先生に問うと真っ先にでてくるのだけど、個人的な都合で利用を避けた。

DOMの変更を監視

ng-iScrollのやり方に沿えば、実際iScroll自体は動いたのだけれど、そのままだと、Ajaxで取得したHTML系のコンテンツが非同期的に投入されたときにiscroll.refresh()してくれない。

そこでどうしたかっていうのが本題。

$watchを使う

以下のコードを見てくれ、どう思う?

scope.$watch(function () {
  scroller.refresh()
});

・・・実はこれでも、XHRで取得したDOMを追加したときにrefreshしてくれる。 でもこの使い方間違っているので注意。

ドキュメントを見てもらえればわかるけれど、$watchの第一引数は、監視する値を示していて、変更時に走らせる処理は第2引数に指定しなければならない。

一応、第1引数であるwatchExpressionは、functionを渡すことも可能で、値の変更時に呼ばれるのでこれでも動くが、明らかにBKであり、間違っている。

ではどうすればいいのか。 それを知るには、$watchを含めたscopeのライフサイクルを理解しなければならない。

$watchと$digest

$watchの仕組みや動くタイミングは以下の記事が丁寧にかかれているので参考になった。

AngularJS のデータバインドを支える $watch

$watch$digestで呼ばれており、XHRや値の変更時等にdigestが呼ばれる。

以下の記事を併せて読むとより理解が深まるんじゃないかと。

AngularJS の $watch, $digest, $apply について書く

DOMの高さの更新を検知したい

さて、$watchで登録したfunctionがいつどこで呼ばれるかがなんとなくわかったと思う。

今回僕が知りたかったのはiScrollの対象としたDOMの高さが変わったタイミングだ。 つまり、こうすれば取れるはず。

angular.module("myApp").directive("ngScrollable", function () {
  return {
    restrict: "A",
    link: function (scope, elements, attr) {
      var element = elements[0];
      var content = element.children[0]; // スクロールさせたいコンテンツ。適宜セレクタは書き換えるべし。

      var scroller = new iScroll(element, {});

      scope.$watch(function () { return content.clientHeight }, function {
        scroller.refresh();
      });
    }
  }
});

ここまでで2時間かかった・・・ Angular奥が深い

AngularJS x UI-RouterでpushStateを使う

何も考えずにAngularJSを使うと、遷移のURLはハッシュフラグメント(#)を使うことになる。 しかし、ngRouterの$locationProviderにはhtml5Modeという機能があり、こいつを有効にすると、pushStateを使ってURLを構築することができる。

それをUI-Routerで使うよという話。

UI-Routerも$locationProviderをそのまま利用可能

ほんとうにまんま一緒に有効化できる。

ngRouterの場合:

angular.module("myApp").config(["$locationProvider", function ($locationProvider) {
  $locationProvider.html5Mode(true);
}]);

UI-Routerの場合:

angular.module("myApp").config(["$locationProvider", function ($locationProvider) {
  $locationProvider.htmlMode(true);
}]);

ui-srefの扱い

UI-Routerを使っていて感動したのは、ui-srefの扱い。 ui-srefはstateを指定し、そのstateで定義したURLをhrefの属性に変換してくれるDirectiveですが、html5Modeを有効にすると、ハッシュ(#)を取り除いてくれる。

つまり、URLの差し替えとかせずにhtml5Modeに移行できる。 なので、UI-Routerを使っているなら、hrefよりもui-srefを使ったほうが可用性が高いです。

grunt-contrib-connectと併せて使いたい

変更を最小限にhtml5Modeを利用できるのは素晴らしいが、pushStateを使う以上は当然、サーバ側にも手を入れなければならない。

基本的には/のアクセスは/index.htmlにリライトされているだけなので、/hogeとかのURLを(内部の遷移ではなく)叩いてしまうと、当然Not Foundになる。

わざわざテンプレートをRouterにまかせて管理しているのに、同じようなHTMLを大量に生産するのも無駄過ぎるし、一般的なプロラマなら違う方法があると考える。

connect-rewrite

grunt-pluginではないが、URLに対して特定コンテンツを返す様に設定できるnode-connectのプラグインがある。これをgrunt-contrib-connectのmiddlewareとして利用してやればいい。

/api, /css, /js, /tmpl, /fonts, /imgのHTTP-PATHを除くリクエストをすべて/index.htmlへリライトしたい場合は以下の様に書く。

connect: {
  server: {
    options: {
      host: "localhost",
      port: 8000,
      base: "www",
      middleware: function (connect, options) {
        if (!Array.isArray(options.base)) options.base =[options.base];
        directory = options.directory || options.base[options.base.length - 1];

        // node-moduleを読み込み
        modRewrite = require("connect-modrewrite");

        middlewares = [];
        middlewares.push(connect.directory(directory));
        // modRewriteの設定
        middlewares.push(modRewrite([["!/api|/css|/js|/tmpl|/fonts|/img /index.html"]);
      }
    }
  }
}

備忘録

pushState対応してて気づいた余談。

RouterのtemplateUrlは絶対パスで記述しよう

相対パスで指定してて、/hogeなURLでアクセスした時にテンプレートが404になって困った。 templateUrlとしてはhttp://localhost:8000/tmpl/fuga.htmlを期待してても、相対パスで書かれていると、/hogeから/fugaに遷移しようとしたときに読みに行くURLはhttp://localhost:8000/hoge/tmpl/fuga.htmlになる。

WEB屋としてはあまりに当たり前なコトなのだけど、サンプルで書かれてた相対パスをそのまま使っていた。まあ、ハッシュフラグメントでやっている限りは困らないけど。

URLの指定にHashfragment(#)ではなくHashbang(#!)を使いたい

$locationProvider.hashPrefix('!');ってすれば、#!になる。

Angular UI-Routerのviewsの定義を確認してみた

state.viewsの指定方法を理解してなかったので調べた。

基本

viewの名前はビュー名ステート名@で結合したviewName@stateNameで構成される。

無名のview

ui-viewは名前を与えず無名のviewとして定義ができる。

<div ui-view></div>

その場合、stateは""でviewを指定する

$stateProvider
  .state("example", {
    views: {
      "": "example.html"
    }
  });

viewのstateを省略した場合

viewsの定義文字列は@でビュー名とステート名を区切る。 ビュー名に@が含まれていない場合、ビュー名の末尾に@と親ステートの名前^1を結合してview名する。

$stateProvider
  .state("example", {
    views: {
      "main": {
        templateUrl: "example.html"
      }
  })
  .state("example.child", {
    views: {
      "main": {
        templateUrl: "example.child.html"
      }
    }
  });

このようなstatesを定義した場合、viewsは以下に変換される

views: {
  "main@": {
    templateUrl: "example.html"
  }
}
views: {
  "main@example": {
    templaetUrl: "example.child.html"
  }
}

viewsを省略した場合

viewsは省略することができる。

$stateProvider.state("example", {templateUrl: "example.html"});

上記の場合、viewsの指定は以下に変換される。

views: {
  "@": {
    templateUrl: "example.html"
  }
}

rootステートについて

親要素を$stateProviderに定義しないステートもrootという親をもっている。

root = registerState({
  name: '',
  url: '^',
  views: null,
  'abstract': true
});

これによって、例えば@だけをviewsに指定した場合は最上位に定義されている無名ui-viewを指定したことになる。

AngularJSでdirectiveを作ってみる

2014-03-25っていう文字列の日付をyear, month, dayの3つのフォームに分割して入力したい、と思った。

一つのngModelからフォーマットを変えてinputを並べればいいとおもって、directiveを使ってみた記録。

<input type="date" name="year"     ng-model="user.birth_on">
<input type="date" name="month" ng-model="user.birth_on">
<input type="date" name="day"     ng-model="user.birth_on">

directiveの作り方

ngModelController$formattersというプロパティがArrayで存在するので、そいつにフォーマット用のfunctionを登録してやる。

  • $filterを利用してdateを整形する
  • 整形文字列は、ui-date-formatの属性で指定できるようにする
    • 例)ui-date-format="yyyy"
  • フォーマットする文字列はmodelから取ってくる
angular.module("app")
  .directive("uiDateFormat", [
    "$filter",
    function ($filter) {
      return {
        restrict: "A",
        require: "ngModel",
        link: function (scope, element, attrs, ctrl) {
          ctrl.$formatters.push(function (view_value) {
            return $filter("date")(view_value, attrs.uiDateFormat);
          });
        }
    }
  ]);
<input type="date" name="year"     ng-model="user.birth_on" ui-date-format="yyyy">
<input type="date" name="month" ng-model="user.birth_on" ui-date-format="M">
<input type="date" name="day"     ng-model="user.birth_on" ui-date-format="d">

ui-dateというAngular UIのライブラリ

ui-dateを使えばいいという気はしつつ、ui-dateはjquery-uiとjqueryに依存しているし、そこまでの機能は必要なさそうだった。 なので、勉強を兼ねて作ってみた。

AngularJS x UI-Routerの使い方

UI-Routerはとても強力で、ngRouterよりも多くの事ができるのでとても便利。 しかし、強力すぎてRouterの概念自体を塗り替えてしまっていてなかなかピンと来てなかったのでまとめてみる。

リストページと詳細ページを別々のページとして用意する

state-router.js

app.config(["$stateProvider", function ($stateProvider) {
    $stateProvider
        .state("list", {
            "url": "^/list",
            "templateUrl": "list.html",
            "controller": "ListCtrl"
        })
        .state("detail", {
            "url": "^/detail/:detail_id",
            "templateUrl": "detail.html",
            "controller": "DetailCtrl"
        })
     ;
}])

index.html

<div ui-view></div>

list.html

<ul ng-repeat="content in contents">
  <li><a href="#/detail/{{content.id}}">{{content.title}}</a></li>
</ul>

detail.html

<dl>
  <dt>ID</dt>
  <dd>{{detail.id}}</dd>
  <dt>Title</dt>
  <dd>{{detail.title}}</dd>
</dl>

リストと詳細がひとつのページの中にある

2カラムレイアウトでlistとdeitalを同じページの中に内包する。

state-router.js

app.config(["$stateProvider", function ($stateProvider) {
    $stateProvider
        .state("contents", {
            "templateUrl": "contents.html",
            "controller": "ContentsCtrl"
        })
        .state("contents.detail", {
            "views": {
                "detail@contents": {
                    "templateUrl": "detail.html",
                    "controller": "DetailCtrl"
                }
            }
        })
    ;
}])

index.html

<div ui-view></div>

list.html

<nav class="left-column">
  <ul ng-repeat="content in contents">
    <li><a ui-sref="contents.detail({id: content.id})">{{content.title}}</a></li>
  </ul>
</nav>

<div class="right-column" ng-view="detail">

detail.html

<dl>
  <dt>ID</dt>
  <dd>{{detail.id}}</dd>
  <dt>Title</dt>
  <dd>{{detail.title}}</dd>
</dl>

2カラムレイアウトでdetailをURLで指定できるようにする

/contentでリストとindexを、/content/{detail_id}で指定したdetailを表示できるようにする。

state-router.js

app.config(["$stateProvider", function ($stateProvider) {
    $stateProvider
        .state("contents", {
            "url": "^/content",
            "templateUrl": "contents.html",
            "controller": "ContentsCtrl"
        })
        .state("contents.detail", {
            "url": "/:id"
            "views": {
                "detail@contents": {
                    "templateUrl": "detail.html",
                    "controller": "DetailCtrl"
                }
            }
        })
    ;
}])

まとめ

  • stateは継承が可能
    • .で連結することで、親.子というstateを定義できる
  • viewsにビュー@ステートを指定すると、親ステートで定義したui-viewにtemplateを埋め込むことができる
  • stateをネストした場合、URLは親のURLに追加される形で指定できる
    • ^からURLを開始する(^/content/:detailというように)ことで相対ではなく絶対パスで指定できる
  • hrefの代わりに、ui-sref属性を用いることで、URLではなくstateをリンクに指定できる。
    • ui-srefを利用するばあい、$stateProviderに定義されてないstateを指定するとリンクにならない

UI-Routerの理解に一日費やしたので、とりあえず僕の理解の範囲をまとめた。 ややこしいけど大変便利!