AngularJSのDirectiveを理解する.

  • 422
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

ディレクティブは、AngularJSにおいて、ViewとModelの双方向バインドを実現するための根幹的な仕組みである。

ディレクティブは、開発者から見ると、Templateの要素・属性として現れる。

例えば、テキストボックスの入力値とscope.nameを紐付ける場合、Angularではhtmlに下記を記述するだけで、ユーザの入力値がscopeへ即時反映される。

<input type="text" ng-model="name" />
<span>{{name}}</span>

上記コードの"ng-model"はAngularにデフォルトで組み込まれたディレクティブである。
もちろん、htmlの文法上は、ng-modelという名称の属性はinputタグには存在しない。Angularが独自にng-model属性を解釈して、双方向バインディングの機能を実現しているのである(Angularでは、これを「html文法の拡張」と呼んでいる)。

ディレクティブには、ng-repeatや{{...}}等、様々な機能の物が存在するが、その基本的、且つ最も重要な役割は、以下の責務を負うことによる、双方向バインディングの実現である。

  • Model(=scope)の変更をView(=DOM)へ反映する
  • Viewの変更時にscopeの値を変更する

Angularを用いたアプリケーションに限らず、画面の規模が増し、複雑化してくると、機能の一部を構造化・共有化して、コードを整理したくなるシーンに遭遇すると思う。
「機能の共有・再利用」をAngularのAPLで実現するにあたって、ディレクティブの作成方法を抑えておいて決して損はしない筈だ。

また、「ModelとViewの仲介」という性質上、ディレクティブの動き・仕組みを理解することは、AngularのMVCとしての根本動作を理解することに他ならない。

directiveを作成する・使用する

何はともあれ、コーディングしてみよう.

myModule.js
var myModule = angular.module('myModule', []);
myModule.directive('firstDirective', function(){
  return {
    template: '<span>初めてのディレクティブ</span>'
  }; 
});
index.html
<!doctype html>                                                 
<html ng-app="myModule">                                        
<head>                                                          
<script type="text/javascript" src="angular.min.js"></script>   
<script type="text/javascript" src="myModule.js"></script>      
</head>                                                         
<body>                                   
<div first-directive></div>                                     
</body>                                                         
</html>                                                         

上記を実行すれば、「初めてのディレクティブ」が表示されたはずだ。
機能としては無いに等しいが、下記が伝わっただろうか。

  • ディレクティブの定義には、angular.moduleのdirective関数を利用する.
  • ディレクティブの名称はキャメルケースが、利用時はハイフン繋ぎに展開される.

directiveの構成要素

ここからは、ディレクティブ作成時の構成要素をより詳細に見ていこう。
directive関数は、ディレクティブ定義オブジェクトのファクトリ関数を登録する。ファクトリ関数は下記のように記述される。

myModule.directive('myDirective', function(){   
  // ディレクティブ定義オブジェクト(Directive Definision Object)
  return{                                                           
    restrict: 'EAC',   // 'EA'や'EAC'のように記述                                             
    priority: 0,                                             
    transclude: true,     // 子要素を取り込むか否か.                                          
    replace: false,       // 自要素をテンプレートで置換するか否か.                                          
    template: '<span>{{title}}</span>' // templateUrlでhtmlファイルを指定も可.
            + '<div ng-transclude></div>',
    scope: {            // scopeにオブジェクトを指定すると、分離スコープの作成.
      myDirective: '=', // '='は双方向バインディング
      onChange: '&',    // '&'は関数                    
      title: '@'        // '@'は親スコープからディレクティブのローカルスコープへの単方向バインディング           
    },
    link: function postLink(scope, element, attrs, ctrl){       //Link関数
    },
    // Link関数は以下の様に定義することも可能.                                                              
    // compile: function(tElement, tAttrs){                     //Compile関数
    //  return {
    //    pre: function preLink(scope, element, attrs, ctrl){   //Link関数その1
    //    },
    //    post: function postLink(scope, element, attrs, ctrl){ //Link関数その2
    //    }
    //  };                                                            
    //},
    require: '^myParentDirective',                                                                
    controller: ['$scope', function($scope){ // Controller定義                    
    }]                                                              
  };                                                                
});                                                                

ファクトリ関数

angular.modulde(...).controller 等と同様、Directiveのファクトリ関数もAngularの任意のServiceをインジェクション可能である(下記の例では、Ajaxサービス $http をインジェクションしている)

myModule.directive('myDirective', ['$http', function($http){
  return {
    // $httpを用いたAjax処理.
  };
}]);

restrict

restrict は、このディレクティブがhtmlの文法上、どのようなノードとなるかを表す。下記の文字を組み合わせる。

  • タグ ('E')
  • 属性 ('A')
  • クラス ('C')
  • コメント ('M')

例えば、'AC'であれば、以下のどちらの記法も許可する、という意味になる.
デフォルトは'A'(属性のみ)である。

<div my-directive></div>
<div class="my-directive"></div>

priority

1つの要素に、複数のディレクティブが存在する場合に、compiler(後述)から見た解釈の優先順序を決定する.
整数値(負数も可)を指定し、値が大きければ大きいほど優先される。デフォルト値は0であり、同一のpriority値をもつディレクティブが複数存在する場合は、動作順序は特に保証されない。

transclude

ディレクティブの子要素を挿入可能かどうかを指定する。true とした場合は、ディレクティブを利用する側のコードにて、下記のように子要素を記載していれば、テンプレート中の ngTranscludeディレクティブと組み合わせることで、子要素を展開することが出来る。

index.html
<div my-directive>
  <h3>...</h3>
  <p>.........</p>
</div>
myModule.js
myModule.directive('myDirective', function(){
  return {
    transclude: true,
    template: '<div class="head"></div><div class="body" ng-transclude></div>'
  };
});

なお、transclude のデフォルトは false である。

template/templateUrl

このディレクティブがもつテンプレートをhtml文字列として指定する。templateUrlの場合は、記載されたhtmlファイルのパスを指定する。
もちろん、template中にng-***等の他ディレクティブや、{{...}}の記法を用いることも出来る。

scope

ディレクティブは独自のScopeを持つことができる.
scope プロパティに対して、オブジェクトリテラルを指定することで、親Scopeと独立した子Scopeオブジェクトがこのディレクティブ用に生成されるようになる。

scope プロパティを省略した場合は、このディレクティブ用のScopeは作成されず、親Scopeにそのまま属する形となる.

scope を指定した場合、分離された子Scopeのプロパティと、親Scopeとの関係を指定することが可能である。

...
scope: {
  myDirective: '=',
  title: '@',
  onChange: '&'
}
...

「プロパティ名:記号」の記法であり、記号の種類として、'@', '=', '&'の3種類が指定可能である。

  • '@' 子Scopeのプロパティ値をディレクティブのHTML属性値と紐付ける。 <div my-drirective="my name is {{name}}"></div>, scope:{myDirective:'@'} であれば、このscope.myDirectiveの値は、"my name is ○○"となる(○○は親Scopeにおけるnameの値)
  • '=' 子Scopeのプロパティを親Scopeのプロパティと双方向バインディングで紐付ける。親Scopeプロパティが変更されれば、子Scopeのプロパティは変更され、子が変更されれば、親も変更される。親scopeの値を使って、このディレクティブの状態を管理したいときに有用。
  • '&' 子ScopeのプロパティにAngularのExpression関数を紐付ける。ディレクティブのイベント発生時に発火するイベントハンドラの設定ポイントをディレクティブ利用者へ提供するときに有用。

また、'@', '=', '&'記号の後ろにディレクティブ要素中での属性名を明示することで、子scopeのプロパティ名とディレクティブ中での属性名を別名にすることも可能。

scope: {
  info: '=myDirective'
}

とりあえず、「'@'は文字列、'='はバインド、'&'は関数」と覚えておこう。

link

link はディレクティブにおける肝とも言えるべき要素である。ディレクティブの最大の役割「ScopeとViewのバインディング管理」を担うのがこのLink関数であるからだ。

Link関数の構成要素
function postLink(scope, element, attrs, ctrl){
  scope.$watch(...);
  element.on(...);
  scope.$on('$destroy', ...);
}

scopeはこのディレクティブに紐付けられたScopeオブジェクト、elementがこのディレクティブが記載されている要素を表すjQueryオブジェクト、 attrs が当該要素の属性名と値のマップ、ctrlは親ディレクティブのController(後述)である。

基本的に利用するのは、scopeelementであり、これらを利用して双方向バインディングを実現する。基本的な処理を以下に説明する。

Scopeの値変更をDOMへ反映する.

例えば、scope.someStatusの変更時処理をフックしたい場合は、下記のように$watchを使ってリスナを登録する。

scope.$watch('someStatus', function(newValue, oldValue){
  // DOMへの反映処理
});
DOMイベント発生時にScopeの値を変更する.

逆にDOMの何らかのイベントをハンドリングしてScopeの状態を変更する際には、scope.$applyをDOMのイベントリスナとして登録する。

element.find('video').on('pause', function(){
  scope.$apply(function(){
    scope.title=...; // scopeの変更処理
  });
});

Link関数の引数 element はjQueryオブジェクトなので、要素内の探索やイベント登録には、$.fn.find$.fn.on等のjQuery APIがそのまま利用可能だ。

ディレクティブ消滅時に終了処理を行う.

ディレクティブが消滅する際には、当該ディレクティブに紐付けられたScopeオブジェクトについて、scope.$destroy が叩かれる。このポイントに処理を挟み込んで、何かしらの終了処理を登録する場合は、下記のようにする。

scope.$on('$destroy', function(){
  // 終了処理
});

DOMエレメントに対しても同名のリスナが登録可能であるため、下記でも可.

element.on('$destory', function(){
  // 首領処理
});

controller, require

controllerプロパティは、AngularアプリケーションにおけるControllerと同様の役割を担っている。
要するに、ディレクティブが付与された要素にng-controller が記載されているイメージとでも言えば良いだろうか。
例えば、Scopeの初期化処理等を実装することも出来るし、Scopeに関数を設定して、テンプレートから呼び出すことも出来る。

Scopeに手を加える処理を実装するだけれあれば、link関数でもほぼ同様のことが実現できると思う。Controllerがディレクティブにおいて意味をなすのは、複数のディレクティブ間でやり取りを発生させたい時だ。

例として、2つのディレクティブdirectiveAdirectiveBを以下の様に用意したとする。

myModule.directive('directiveA', function(){                 
  return {                                                
    transclude: true,                                        
    template: '<div ng-transclude></div>',
    scope: {},                                                                         
    controller: ['$scope', function($scope){                   
      this.hello = 'Hello !';                   
    }]                                                  
  };                                                         
});

myModule.directive('directiveB', function(){                        
  return {                                                          
    require: '^directiveA',
    scope: {},                                        
    link: function(scope, element, attrs, ctrl){
      element.text(ctrl.hello);
    },                                                         
  };                                                                
});                                                                                                                           

directiveAtranscludetrueにしているため、子要素を挿入することができ、子要素に directiveB を記述して利用することを考えてみよう.

index.html
<div directive-a>
  <div directive-b></div>
</div>

わざわざ書くまでもないが、実行結果は下記のようになる:

実行結果
<div directive-a>
  <div directive-b>Hello!</div>
</div>

ポイントは、被依存ディレクティブの requireプロパティに依存ディレクティブを記載することで、依存ディレクティブのControllerがLink関数の引数として取得可能な点である。それぞれのディレクティブでScopeが分離していたとしても、親ディレクティブのController経由で値のやりとりが実現可能なのだ。

なお、require プロパティ指定時、ディレクティブ名の先頭に下記の記号を付与することで、探索範囲や依存ディレクティブが存在しない場合の挙動を制御できる。

  • (何もなし) 同一要素で依存ディレクティブを要求する。依存ディレクティブが存在しない場合はエラー.
  • '?' 同一要素で依存ディレクティブを要求する。依存ディレクティブが存在しない場合はctrlにはnullが入る.
  • '^' 自身の要素から親を遡って依存ディレクティブを要求する。依存ディレクティが存在しない場合はエラー.
  • '^?' 自身の要素から親を遡って依存ディレクティブを要求する。依存ディレクティブが存在しない場合はctrlにはnullが入る.

ちなみに、複数のディレクティブに依存する場合、require:['^directiveA', '^?directiveC']のように、配列表記も可能である。

Controllerを用いた親子ディレクティブ制御は、タブやカルーセルなど、コンテナとコンテンツで表現されるUIを扱う場合に、コンテナ <-> コンテンツの間の対話に用いると良いと思われる。

Link関数についての補足:Compile, Pre Link, Post Link

link関数部分は、下記のようにcompileプロパティによる記述も可能である:

compile: function(tElement, tAttr){              
  return {                                       
    pre: function preLink(scope, element, attr, ctrl){   
    },                                           
    post: function postLink(scope, element, attr, ctrl){  
    }                                            
  };                                             
},                                               

linkプロパティで指定していたLink関数は、上述のpostプロパティに相当する。

Angularのドキュメントにもあるが、殆どのケースでは、linkプロパティのみの指定(postLink関数のみの指定)で事は足りる。
が、折角の機会なので、ここについても真面目に書いてみようと思う。

compileが何を示すかについては、まず、Angularのディレクティブ解釈の機構がどのような動作順序で動くかを理解する必要がある。この「ディレクティブの解釈機構」は$compileというAngularのサービスである。
$compileの動作について、以下にAngularのHTML Compilerガイドの一部の訳文を示す.

HTMLのコンパイルは以下の3フェーズから構成されます:
1. \$compileがDOMをトラバースし、ディレクティブにマッチします。
Compilerがディレクティブにマッチする要素を見つけた場合、そのディレクティブを、「そのDOM要素にマッチしたディレクティブのリスト」に追加します。
2. あるDOM要素にマッチした全てのディレクティブが特定されると、Compilerはディレクティブのpriorityに従って、ディレクティブをソートします。
各ディレクティブのcompile関数が実行されます。それぞれのcompile関数は、DOMを変更する機会が与えられています。compile関数はlink関数を返します。それぞれのディレクティブが返却した関数群は、「結合された」link関数に構成されます。「結合された」link関数は、各ディレクティブが返却したlink関数を呼び出すような関数となります。
3. \$compileは、前述の「結合されたlink関数」を呼び出すことで、テンプレートとscopeを結合します。ここで、個別のディレクティブがもつlink関数を呼び出し、DOM要素へのイベントリスナの登録と、各ディレクティブに渡されるscopeへの$watchの設定が行われます。

要するに、Angularにおいては、「ディレクティブを解釈して、DOMテンプレートへ変換(=Compile)」と「DOMテンプレートとscopeの結合(=Link)」の2フェーズに分かれている、と言っている。

Linkは、自分よりpriorityの低いディレクティブのlink関数の実行前と実行後に対して、それぞれpreLinkとpostLinkに分かれている。preLinkは、まだ下位のディレクティブがDOMを操作する余地があるため、DOMを触る上では安全とは言えない。DOMの操作は基本的にpostLinkで実現すべきである。

priority: 10 のディレクティブAと、 priority: 20 のディレクティブBが同一のdiv要素にセットされていた場合、下記の順序でcompile, link関数, controller定義が実行される。

  1. ディレクティブBのcompile
  2. ディレクティブAのcompile
  3. ディレクティブBのcontroller
  4. ディレクティブAのcontroller
  5. ディレクティブBのpreLink
  6. ディレクティブAのpreLink
  7. ディレクティブAのpostLink
  8. ディレクティブBのpostLink

上記の5.〜8.が上述の『「結合された」link関数』に相当する。

なお、この文書を書くに当たって、compileやpreLink関数でないと実現できないディレクティブの例を考えてみたのだが、残念ながら、思い浮かばなかった。
※ 2014/07/19 追記
AngularJSで再帰的なディレクティブを作成して木構造を表現するは、CompileとLinkの使い分け(性格にはCompileのクロージャとLink関数の連動)に意味があるパターン.

https://docs.angularjs.org/guide/compiler を読むと、ng-repeatがcompile/linkの概念の恩恵を受けているらしい(性能改善)ので、機会が合ったら、ng-repeatのコードを読んでみようと思う。
※ 2014/07/19
CompileとLinkの2フェーズの分離が性能に与える影響については、AngularJSのCompileとLinkってなぁに?に別途書いているので、興味があれば参照されたし。

参考