Hatena::ブログ(Diary)

Kazzzの日記 このページをアンテナに追加 RSSフィード

2014-08-09

[]コントローラ間のイベント通知では$broadcastが使えない場合がある

以前、AngularJSで複数のコントローラ間でデータを共有する手段として、$broadcastを使ったイベントの通知を紹介したことがある。
[AngularJS][Javascript]他の要素に変更を通知する

これならスマートにデータを共有できる。しかし、この方法を様々に使って見ると想定通りの動きをしないケースがあることが分かった。

サンプルとしては、1年前のエントリ同様にDOM上で親子関係を持つ二つのコントローラ、があるとしよう。

parentController.js
function ParentController($scope, $broadcast, $log) {
    //リソース取得後にブロードキャスト
   $scope.user = User.getUserFromResource( {},
        function(data){
            $scope.user = data;
            $scope.$broadcast('event:userReceived', data);
        }
    });
}

ParentControllerでは$resource等を使って外部リソース取得後に$broadcastで他のコントローラにリソース取得を通知する。なので$broadcastを実行する際にChildControllerはロードされた後に$onが実行されて$broadcastを受ける準備が整っている必要がある訳だ。

childController.js
function ChildController($scope, $log) {
    $scope.$log = $log;
    $scope.$on('event:userReceived', function(event, data) {
        $scope.$log.log('++++ userReceived broadcast');
    });
}

しかし、例えばChildControllerがng-viewディレクティブでロードされる構造になっている場合、親のコントローラの間でロード(ダイジェスト)の順序が変わるのか、1度目は問題無いのに、その後、Webブラウザによりページをリロードした場合には、ChildController中の$onの実行($broadcastの監視開始)が$broadcastより遅れてしまうことがある。その場合、ChildControllerの$onは決して呼ばれない。

このケースのように、子のコントローラで親のコントローラのイベントを確実にハンドルした場合は、子のコントローラの準備が出来たことを親コントローラが知る必要がある。

このようなケースでは$emitを使う。

childController.js (改)
function ChildController($scope, $log) {
    $scope.$log = $log;
    $scope.$on('event:userReceived', function(event, data) {
        $scope.$log.log('++++ userReceived broadcast');
    });
    //自身がロードを完了したことを親に通知する
    $scope.$emit('event:onChildControllerLoaded', this);
}

parentController.js (改)
function ParentController($scope, $broadcast, $log) {
   $scope.$on("event:onChildControllerLoaded", function(event, controller){
        $scope.user = User.getUserFromResource({},
            function(data){
                $scope.user = data;
                //リソース取得後にブロードキャスト
               $scope.$broadcast('event:userReceived', $scope.user);
            }
        });
   }
}

ちょっと面倒だが、これでChildControllerはParentControllerの$broadcastを受けることができる。

2014-05-10

[][]Bootstrap2 → 3への移行

拙作のアプリケーション、Bootstrap2ベースリリースしておりそこから1年も経過していれば気にもならなかっただろうが、開発中にBootstrap3が出てしまったせいで喉に引っかかった小骨のようにずっと気になっていたのだが、この連休が良いチャンスと思い一気にBootstrap3への移行をやってしまうことにした。

移行前のフロントエンドの構成は以下の通り。

ソフトウェア構成(2014/5/1時点)

AngularJS v1.2.13
jquery-2.1.0
jquery.numeric.js 1.3.1
jquery-cookie 0.4
※Twitter Bootstrap v2.3.2
Angular-strap v0.7.4
UI Bootstrap v0.7.0 (TPL有)
TODC Bootstrap v2.3.0
Moment.js 2.1.0
Spin.js 1.3.3
Holder.js 2.2.0
angularLocalStorage.js
sprintf.js 0.0.7
UI.Util 0.1.0

よくもまあ、JavaScriptのライブラリィの衝突の怖さも知らずに揃えた物だと思うが、現行はこの構成で動いている。

この構成でBootstrapをTwitter Bootstrap v2.3.2からTwitter Bootstrap v3.1.1に変更する。

CSS

CSSそのものは各所のBootstrap2→3の移行サイトを参考にさせて頂きそれなりにシミュレーションしたのでそれほど苦労はなかったが、それでも以下は結構時間がかかった。

グリッドシステム

Bootstrap - Grid system
Bootstrapの特徴であるグリッドシステムだが、私のケースでは殆どのspanxxクラス必要に応じてcol-xs-xxかcol-sm-xx(dm)に変更した。 以前のspanxxはBootstrapにレスポンシブを任せるやり方だったが、Bootstrap3では必要な部分と不要な部分でグリッドの適用自分で指定してやる方法になるので、

col-md-xx又はcol-sm-xxで調整

col-xs-xxで調整するか、form-control+指定無し
というのが基本になる。

Form

基本的に各コントロールと対応するラベルを以下のようにrowクラスとcolクラスを設定したdiv要素の階層で書き直した。

<div class="form-group">
    <label class="control-label" for="title">
        タイトル
    </label>
    <div class="row">
        <div class="col-sm-10">
            <input class="form-cntrol" type="text" id="title" ng-model="formData.title" required
                   placeholder="{{ PlaceHolder.title}}" maxlength="100"/>
        </div>
    </div>
</div>

Bootstrap2の時よりも無駄に階層が増えた感じがするが、階層的には理解しやすい。

Bootstrap3のform-controllクラスを指定したINPUT要素はcolクラスの設定無しの場合自動で横一杯に伸びてくれる。なのでcol-sm-xxでデスクトップ上では調整して、それより小さいデバイスでは調整しないことでiPadiPhoneで使えれば良い程度のレスポンシビティを確保できる。

また、新設されたレスポンシビティユーティリティのhedden-xxクラスがとても便利だ。例えば

<a class="navbar-brand hidden-sm" href="./">

等と書くだけで、このアンカーはスクリーン幅が768px〜992pxの間は非表示となる。従来このような振舞にするにはangularJSのフィルタを使うかCSS疑似クラスを用意する必要があった。
Bootstrap - Responsive utilities

input addon

Bootstrapはコントロール同志を前後に連結する"input-prepend input-append"クラスと連結するコントロールを指定する"add-on"の組合せがとても便利で重宝したのだが、この階層の作り方が全く変わってしまい、一から書き直しになってしまった。

    • addon(bootstrap2)
<div class="input-prepend input-append">
  <span class="add-on">@</span>
  <input type="number" class="input-mini text-right">
  <span class="add-on"></span>
</div>       

    • addon(bootstrap3)
<div class="input-group">
  <span class="input-group-addon">@</span>
  <input type="number" class="form-control text-right">
  <span class="input-group-addon"></span>
</div>

なお、input-mini等、input要素の幅を直接調整するクラスは廃止されてしまった?ので、rowとcolのバランスで幅を調整する必要がある。無論インラインスタイルで調整することもできるが、泥縄だしせっかくBootstrapで書くのにレスポンシビティが無くなるのでお勧めできない。

CSSその他

labelやspan、table.trやalertクラス等のセル背景色クラスの名前が(errorがdanger等)変わったのが地味に痛かった。
buttonやlabelのデフォルトクラスが"btn"で良かったのが"btn btn-default"と指定が必須となったのが更に痛かった。

<button class="btn">
f:id:Kazzz:20140510105939p:image
このようにスタイルが反映されない灰色ボタンになってしまう

<button class="btn btn-default">
f:id:Kazzz:20140510105940p:image
これがデフォルトのフラツトフェイスボタン

<button class="btn btn-primary">
f:id:Kazzz:20140510105941p:image
よく見るプライマリ-ボタン

これらはプログラミングで言うところの定数のように使用しており、特にbuttonはModal Dialogを動的に生成する部分で多用していたので、直すのが面倒なのだ。

UI BootstrapとAngularStrapの衝突

両方ともBootstrapをAngularJSで使うための様々なディレクティブが用意されており、私もModalはUI Bootstrapのものを、tooltipとDatePickerはAngularStrapのを愛用していたのだが、この両者、元々衝突することが知られており私がBootstrap2で併用出来ていたのが奇跡だった位である。
Bootstrap3では互いに干渉を避け、使うものだけでカスタム構成を図ったのだが、AngularStrapでtooltipのプロバイダをUI Bootstrapからロードしようとして失敗しているログを見た時に絶望して、すっぱりとAngularStrapを諦めることにした。

AngularStrapのbs-tooltipはUI Bootstrapので容易に交換できるのだが、問題はDatePickerだ。

    • AngularStrapのDatePicker

f:id:Kazzz:20140510015336p:image

    • UI BootstrapのDatePicker

f:id:Kazzz:20140510015335p:image

好みだと思うが、私にはUI BootstrapのDatePickerを選択するセンスはなかったw
とはいうものの、AngularStrapのDatePickerはもう使えない訳で、ならばUI BootstrapのDatePickerを出来るだけシンプルにしてやるしかない訳だ。

幸いにもAngularJSを利用したUI Bootstrapは$templateCacheプロバイダを利用したテンプレートのロード機構採用しており、DatePickerのレイアウト差し替えることが出来る。

書き換えたテンプレートをロードする(AngularJS側)
angular.module('myModule').run(['$templateCache', function($templateCache) {
    //DatePicker用テンプレートキャッシュ入れ替え (ui-bootstrap-tpls-0.10.0.js)
    $templateCache.put("template/datepicker/datepicker.html",
    "<table>\n" +
    "  <thead>\n" +
    "    <tr>\n" +
    "      <th><button type=\"button\" class=\"btn btn-default btn-noBorder btn-sm pull-left\" ng-click=\"move(-1)\"><i class=\"glyphicon glyphicon-chevron-left\"></i></button></th>\n" +
    "      <th colspan=\"{{rows[0].length - 2 + showWeekNumbers}}\"><button type=\"button\" class=\"btn btn-default btn-noBorder btn-sm btn-block\" ng-click=\"toggleMode()\"><strong>{{title}}</strong></button></th>\n" +
    "      <th><button type=\"button\" class=\"btn btn-default btn-noBorder btn-sm pull-right\" ng-click=\"move(1)\"><i 
class=\"glyphicon glyphicon-chevron-right\"></i></button></th>\n" +
    :
    :
   略

これを活用して、以下のようにAngularStrapのDatePicker(というか元はjQueryのDatepickerプラグインだよね)風の落ち着いたL&Fにすることが出来た。
f:id:Kazzz:20140510015337p:image

他にも色々あるのだが、長くなりそうなので割愛する。(特にIEで苦労した件はネタにしたい)
最終的にBootstrap3に移行したことにより、フロントエンドの構成は以下のようになった。

ソフトウェア構成(2014/05/10現在)

AngularJS v1.2.13
jquery-2.1.0
jquery.numeric.js 1.3.1
jquery-cookie 0.4
Twitter Bootstrap v3.1.1
UI Bootstrap v0.10.0 (TPL有)
UI.Utile 0.1.1
Moment.js 2.1.0
Spin.js 1.3.3
Holder.js 2.2.0
angularLocalStorage.js
sprintf.js 0.0.7

結果としてはライブラリィの棚卸にもなったようで、苦労した甲斐があったというものだ。

2014-04-24

[]注目されているものと、これから注目されるもの

売れているらしい。

オライリーによるとジュンク堂とEbookで二冠を達成した模様。 やはりAngularJSは注目度高いんだねぇ。
O'Reilly Japan - Home

私は原著は持っているが、やはり邦訳本は買ってしまうだろう。

TypeScriptリファレンス Ver.1.0対応

TypeScriptリファレンス Ver.1.0対応

次はTypeScriptが来るかな。

2014-04-22

[][]IEのAjax Cacheに注意

アプリケーションをデプロイした後、利用者からリロードしてもデータが最新にならない」「更新したはずのデータが書き変わらない」というクレームを受けたので調べてみると以下のようなログが出ていた。
f:id:Kazzz:20140422205124p:image
データの更新後、$location.path("/path")内部パスを変える処理を行っているのだが、パスを変えた後のGETリクエストが全てHTTP 204で返って来ていることが分かる。(後で調べるとと304の時もあった)
普段、開発とテストに使用しているChromeでは見たことが無いので、UAを調べると案の定IE(IE10、IE11)だった。

調べてみるとIE(少なくとも10と11)はAjaxのリクエストを勝手にキャッシュするようで、このログが表示されている間はサーバに全く問合せに行っていなかった。
この問題(IEだけのようだが)解決するにはHTMLのヘッダーのようにリクエストのヘッダを明示的にキャッシュ無効にする必要がある。

AngularJSの場合、modules.configで$httpProviderの初期化を行うのが慣習のようなので、同じように対処する。

modules.config(['$provide', '$httpProvider', '$logProvider', function($provide, $httpProvider, $logProvider) {
    //GETメソッド用のヘッダーを初期化
    if (!$httpProvider.defaults.headers.get) {
        $httpProvider.defaults.headers.get = {};
    }
    //IEのAjaxリクエストキャッシュを無効にする
    $httpProvider.defaults.headers.get['If-Modified-Since'] = '0';
    :
    :
}]);

この他にもChrome/Firefoxだと問題無いのにIEだと駄目というケースは他にもたくさんある。

2014-03-09

[]アプリケーション内のページ遷移には$location.pathを使うべし

AngularJSでページ遷移を実行する際に、ルーティングを端折ったり、従来のリダイレクトのようについつい低レベルのAPIで

$window.location.href="#/foo/hoge?reset";

等と書いたりすると※、一見上手く動くように見えたりするのだが(実際Chrome等では問題なく動作する)、AngularJSのダイジェストサイクルはWebブラウザ(のレンダリングエンジン)に依存するのかIEでは再帰エラーを起こしたりする。

Chromeでは問題ないのにIEではダイジェストループの再帰でエラーになる様子(IE11のデバッグコンソール)

f:id:Kazzz:20140309212007p:image
SCRIPT5022: [$rootScope:infdig] 10 $digest() iterations reached. Aborting!

AngularJSはメジャーなWebブラウザを一通りサポートしているが、ブラウザのURLを直接変更するとこのようにブラウザ固有のトラブルが発生するケースがあるので、アプリケーション内部の遷移では極力 $location.pathを使うべきだろう。

$location.path('/foo/hoge').search('reset');

$location.pathのセッターを使うことではURLは変更するものの対象のページのリロードは伴わないためか、IE特有の今回のエラーは発生しない。

残念ながらIEはAngularJSのファーストブラウザ(開発に使用しているブラウザ)ではないようで、今回のようなトラブルがよく発生する。
Chromeで動いても油断は出来ず必ずIEでもテストをする必要があり、コストがかかる。

※$window.location.hrefを書き換えるのは従来のJavaScriptのイディオムと同様なので、ネットで散見される。

2014-02-23

[]$locationProvider.html5Mode(false)の際にsearchが取れないケースがある

AngularJS v1.2.13にて

$scope.signin = function() {
    var cont = $location.search()['continue'];
    $log.debug(cont);
};

このようなコードがあった場合、WebブラウザのURL($location)が
http://localhost:8000/app/login_require?continue=http%3A%2F%2Flocalhost%3A8000%2Fapp%2Findex.html%23%2F#/
等だと、$logでの出力結果は
undefined
となってしまう。

$locationProvider.html5Mode(false)の場合、処理するURLに#(hashbang)が無いとsearchが取れないようだ。
ちなみにECMAのAPIを使うと

$scope.signin = function() {
    var cont = location.search;
    $log.debug(cont);
};
?continue=http%3A%2F%2Flocalhost%3A8000%2Fapp%2Findex.html%23%2F#/

と正しく戻るので、AngularJS側の問題ぽい。

仕方がないので予め用意してあったUtilサービスに、メソッドを追加する。

modules.factory('Util', function(MessageBox, $window, Pagination) {
    return {
        locationSearches : function() {
            var hash = {};
            if (location.search) {
                var str = location.search.substr(1).split("&");
                for (var i = 0; i < str.length; i++) {
                    var data = str[i].split("=");
                    hash[data[0]] = data[1];
                }
            }
            return hash;
        }
    }
});

2014-02-02

[]IE11でformとsubmit buttonを組み合わせた場合問題

AngularJSでformとsubmit buttunを普通に使ってメール送信用画面を書いたのだが、

mail.html
<form class="well span8 css-form" name="mailForm" ng-controller="MailController">
    <p class="lead">メールを送信します。</p>
    <div class="control-group">
        <button type="submit" ng-click="sendMail()">メール送信</button>
    </div>
</form>

IE11でのbutton要素はformactionを設定しない場合でもデフォルトのリンクを持たなくてはならないのか、このHTMLをレンダリングしてみると

ボタンへマウスホバーした時の表示

f:id:Kazzz:20140202110201p:image
このようにボタンには現在のURLへのリンクがあるように見える。
実際ボタンをタップするとMailControllerのsendMailファンクションが呼ばれた後に意図しないページの遷移(index.html)が発生してしまうのである。これでは使えない。

これがHTMLの正しい仕様かはまだ調べていないが、Chrome、Firefoxともにこのように意図しないナビゲーションは発生しないので、IE独自の仕様なのかもしれない。
いずれにせよこのままでは使い物にならないので、以下のように標準のform要素ではなくng-formディレクティブを使うことでこの挙動を止めることが出来た。

mail.html
<ng-form class="well span8 css-form" name="mailForm" ng-controller="MailController">
    <p class="lead">メールを送信します。</p>
    <div class="control-group">
        <button type="submit" class="btn btn-info pull-right" ng-click="sendMail()">メール送信</button>
    </div>
</ng-form>
ボタンへマウスホバーした時の表示

f:id:Kazzz:20140202110202p:image

ボタン個別であればbutton要素のtype属性を"submit"から"button"にすることでもこの挙動を止めることができる。こちらのやり方が正しいのかもしれない。