はじめに
自作ディレクティブを作成した際に、angularの(scopeの)イベントとjQueryのDOMイベントを結合させるのに若干苦労したため、メモ化。
実例
今回、実例としてBootstrapのmodalをangularのディレクティブ化する、というのをやってみる.ディレクティブの仕様としては以下のイメージ.
- my-modalタグでBootstrapのモーダルを描画できる.
- title属性でモーダルのタイトル文字列を指定できる.
- state属性を指定でき、true/falseの状態に応じてモーダルが表示/非表示化される.
なお、先に断っておくが、下記は飽くまで説明用のサンプルである。
Bootstrapとangularを組み合わせるのであれば、自作するのではなく http://angular-ui.github.io/bootstrap/ を使った方が良い。
利用側のコードイメージとして、以下な感じ。
<button ng-click="myModal.isShow=true;"class="btn btn-primary">show modal</button>
<my-modal title="{{myModal.title}}" state="myModal.state"></my-modal>
angular.module('myApp', ['MyDirective']).contoroller(['$scope', function($scope){
$scope.myModal = {
title : 'my modal!',
state : { isShow : false}
}
}]);
これに対応するmyModalディレクティブの実装として、下記のようにしてみた(ちなみに真っ当に動作しない)。
angular.module('MyDirective').directive('myModal', function(){
return{
restrict: 'E',
templateUrl: 'scripts/directives/templates/modal.html',
scope: {
title: '@',
state: '='
},
link: function(scope, element, attrs){
// scopeの値が変更された場合にDOMの状態を変更する.
scope.$watch('state.isShow', function(value, old){
if(value){
element.find('.modal').modal('show');
}else{
element.find('.modal').modal('hide');
}
});
// DOMイベント発火時にscopeの値を変更する.
element.find('.modal').on('show.bs.modal', function(){
scope.state.isShow = true;
}).on('hide.bs.modal', function(){
scope.state.isShow = false;
});
}
};
});
<div class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h4 class="modal-title">{{title}}</h4>
</div>
<div class="modal-body">
<p>One fine body…</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
やろうとしているのは、下記である.
- scope.state.isShowの値がangular側で変更される -> Boostrap(jQuery)の表示/非表示メソッドを実行する.
- ユーザがDOM操作にてBootstrap(jQuery)のイベントを発火させる -> 発火されたイベントに応じて、scope.state.isShowの値を同期させる
上記のディレクティブのコードで正常動作しないのは、2. の部分である。モーダル非表示時のscopeの値を直接いじっているが、これがangular側に正しく通達されていないため, 一度モーダルを閉じても、scope.state.isShowはtrueのままである.
element.find('.modal').on('hide.bs.modal', function(){
scope.state.isShow = false;
})
scopeの値変更をangularに通達させるためには何を使えばよいか?
そう、scope.\$applyである。早速変更してみる。
// DOMイベント発火時にscopeの値を変更する.
element.find('.modal').on('show.bs.modal', function(){
// $applyを利用して、scopeの値変更をangularに通知.
scope.$apply(function(s){
s.state.isShow = true;
});
}).on('hide.bs.modal', function(){
scope.$apply(function(s){
s.state.isShow = false;
});
});
やった!ちゃんと 開く -> 閉じる -> 開く が実行できるようになった!
... と喜んだのもつかの間、開発者ツールのコンソールをよくよく見ると、何やらangularのエラーが...。 エラートレースに以下のリンクが貼られていたため、読んでみることに。
何やら、「既に\$digestまたは\$applyが現在のスタックで実行されている最中に、\$digest, \$applyを実行すると起こるエラー」とのこと。
さらに https://docs.angularjs.org/api/ng/type/$rootScope.Scope#$digest を読むと、\$applyが実行されると、内部的に\$digestが呼び出され、この中で当該スコープのリスナー、すなわちscope.\watchで登録した関数が実行されるらしい。
ここで取り扱っているmyModalディレクティブの場合、「モーダルを閉じる」イベントのスタックを上から見てみると、
- (DOM上からモーダルを閉じる操作)
- 'hide.bs.modal'のイベント発火, \$applyによってscopeの値が変化.
- \$applyから内部的にscope.\$watachが呼び出される.
- 登録したリスナにより、element.find('.modal').modal('hide')がコールされる
- 再度、'hide.bs.modal'のイベント発火, 既に\$applyを呼び出し中のためエラー
という流れとなる。
結局、「DOMイベントリスナから\$applyを実行する前に、そのDOMイベントが\$watchを通じて発行されているかを確認すればよい」ということか?
であれば、「\$watchが実行されている時点で、scopeの値は変更されているはずだから、scopeが未変更のときのみ\$applyを実行すればよい」はず。
また、「DOMイベントから\$applyがコールされた場合、\$watchのリスナに記述したモーダルの表示/非表示メソッド(.modal('hide')や.modal('show'))の呼び出しは不要」であるため、これも実装する必要がある。
結果、修正した版のディレクティブコードが下記:
angular.module('Mydirective').directive('myModal', function(){
return{
restrict: 'E',
templateUrl: 'scripts/directives/templates/modal.html',
scope: {
title: '@',
state: '='
},
link: function(scope, element, attrs){
var external = false;
// scopeの値が変更された場合にDOMの状態を変更する.
scope.$watch('state.isShow', function(value, old){
// 既にDOMイベントが発行されていないことを確認する.
if(!external && value){
element.find('.modal').modal('show');
}else{
element.find('.modal').modal('hide');
}
external = false;
});
// DOMイベント発火時にscopeの値を変更する.
element.find('.modal').on('show.bs.modal', function(){
// scopeの値が変更されている = 既に$watchが動作していることを示すため、
// 値が未変更であるかを確認する.
if(!scope.state.isShow){
external = true;
scope.$apply(function(s){
s.state.isShow = true;
});
}
}).on('hide.bs.modal', function(){
if(scope.state.isShow){
external = true;
scope.$apply(function(s){
s.state.isShow = false;
});
}
});
}
};
});
たった一つのbooleanの状態をscopeと連動させるためだけに、これだけの記述量である。もっとシンプルに書けないものか...
まとめ
- DOMイベントを内包したjQueryプラグインとangularのscopeを連動させるのは結構手間.
- scope.\$applyをDOMイベントリスナに登録する場合、当該のイベントリスナがscope.\$watchから発火されないことをディレクティブ実装者が保証する必要がある。