はじめに
AngularJSのDirectiveについては、カスタムディレクティブの作成方法含め、おおよそ理解していたつもりであったが、CompileとLinkについて、若干自信が無い部分があったので、勉強がてら少し厚めに書いてみた。
なお、公式の開発ガイドにも、Oreillyの書籍でも書かれているが、カスタムディレクティブを開発することがあっても、殆どのケースでは、Linkの処理のみを気にするだけでよいため、あんまりこのエントリは役には立ちません。
おさらい
「AngularJSのDirectiveを理解する」にも書いているが、ざっくり言うと、Angularのディレクティブがテンプレートから実際のViewを描画する際は、Compile → Linkの2フェーズに分かれている。
- Compile:ディテクティブのテンプレート(html文字列ではなく、DOMオブジェクト)から、Link関数を生成する.
- Link:生成されたLink関数とディレクティブのScopeオブジェクトを紐付けて、View(HTML要素)を完成させる.
ディレクティブを作成する際、Compileフェーズを意識した記載方法として、↓のパターンがある.
myModule.directive('myDirective', function(){
return {
// templateやrestrict等は割愛.
// Compile関数はLink関数を返却するように実装する.
compile: function(tElement, tAttr){
return function postLink(scope, element, attrs){
// DOMとscopeの双方向バインディング処理.
};
}
};
});
何で分けてんの?
https://docs.angularjs.org/guide/compiler#an-example-of-compile-versus-link- に、CompileとLinkの2フェーズに分割されている理由が書かれているので、ざっくり意訳。
ng-repeatの例
Hello {{user.name}}, you have these actions:
- {{action.description}}
ngRepeatはジレンマを抱えている。
user.actions
コレクションに含まれている全てのaction
オブジェクトに対して、<li>
要素を複製しなくてはならない。これは、Viewの描画後にuser.actions
コレクションに対して、オブジェクトの追加/削除が発生することを考えると、少し複雑である. 描画後変更に耐えうるためには、複製の元となる(綺麗な状態の)<li>
要素を保持しておく必要がある.
(中略)
素朴な実装は、コピー元要素の<li>
を複製して、<ul>
へinsertし、{{action.description}}
部分をコンパイルし、scopeの値の評価を行う方法である. しかし、この方法は非効率的である。複製した<li>
要素の全てにおいて、<li>
内要素をトラバースして、内包されるディレクティブを探索することとなる。この探索処理は、コンパイル全体を重たくする原因となる。この問題を解決するため、コンパイルを2つのフェーズに分離する.
ディレクティブのコンパイル全体のうち、「scopeの値に依存する・依存しない」の違いがCompile/Linkの違いとも言えよう.
- Compile:テンプレートのhtmlが読み込まれる際に1度だけ実行される
- Link:
user.actions
の要素数繰り返される.user.actions
にオブジェクトが追加された場合も実行される
これを考えると、カスタムディレクティブを作成する上で、compile
関数の引数にはscopeは存在しないが、link
関数の第1引数がscopeである点も得心がいくと思う。
また、開発ガイドや書籍で「殆どのケースではCompileとLinkを使い分ける必要はない」と書かれているのは、scopeに応じて、テンプレート内の要素が描画後に複製されるようなケースでない限り、Compile/Linkに分割した・しないが関係無いからである。
ngRepeat
以外で、CompileとLinkの意識が必要となるのは、思いつく範囲では、下記あたりか。
- ツリー構造(フォルダ, 掲示板のスレッド)を保持するscopeに対して、これを再帰的に処理するディレクティブ
(「AngularJSで再帰的なディレクティブを作成して木構造を表現する」に記載があります.)
擬似的にコードで解説
折角なので、上記の「素朴な実装」版と「2フェーズ版」のrepeat風ディレクティブを書いてみた。
angular.module('myApp').directive('myNaiveRepeat', ['myCompiler', function(myCompiler){
var childTemplate = angular.element('<li>name:{{child.name}}, vendor:{{child.vendor}}</li>');
return{
scope:{
myNaiveRepeat: '='
},
link: function(scope, element){
// 描画関数.
var rendering = function naiveRendering(collection){
element.empty();
collection.forEach(function(child){
var cs = scope.$new();
cs.child = child;
// ★子テンプレートをコピーし、コレクション内要素と結合.
// (myCompilerはディレクティブ探索とscope評価をする関数を仮定. 実際のangularにはそんなものない.)
element.append(myCompiler(childTemplate.clone(), cs));
});
};
// 初回描画
rendering(scope.myNaiveRepeat);
// コレクションに変更があれば、再描画する.
scope.$watchCollection('myNaiveRepeat', function(newCollection){
rendering(newCollection);
});
}
};
}]);
angular.module('myApp').directive('myTwoPhaseRepeat', ['$compile', function($compile){
var childTemplate = angular.element('<li>name:{{child.name}}, vendor:{{child.vendor}}</li>');
return{
scope:{
myTwoPhaseRepeat: '='
},
compile: function(){
// ☆テンプレートからリンク関数を作成しておく.
var childLinkFunc = $compile(childTemplate);
return function(scope, element){
// 描画関数.
var rendering = function linkedRendering(collection){
element.empty();
collection.forEach(function(child){
var cs = scope.$new();
cs.child = child;
// ☆リンク関数を実行して、コレクション内要素と結合.
childLinkFunc(cs, function(clonedElement){
element.append(clonedElement);
});
});
};
// 初回描画
rendering(scope.myTwoPhaseRepeat);
// コレクションに変更があれば、再描画する.
scope.$watchCollection('myTwoPhaseRepeat', function(newCollection){
rendering(newCollection);
});
};
}
};
}]);
- 素朴版:
★部分で<li>
要素内の子ディレクティブ探索とscope評価を一緒くたに処理している(なお、myCompilerなんていうServiceはAngularJSには存在しない) - 2フェーズ版:
compileフェーズでテンプレートから$compile関数でLink関数childLinkFn
を作成し(ここで<li>
内探索は済ませる)、描画時(Linkフェーズからよばれる)はchildLinkFn
の実行のみを行う.
ちなみに $compile(template)
戻り値のLink関数 linkFn
の使い方であるが、引数の与え方で結合後の要素の取り方が違う.
-
var viewElement = linkFn(scope);
の様に、scopeの値のみを引数にした場合、templateにしたDOM要素自体に、scopeの評価値がセットされたDOMエレメントが返却される. -
linkFn(scope, function(clonedElement){...});
の様に、第2引数に関数を仕込むと、元のテンプレートDOMを汚さずに、scopeが評価された描画用要素をclonedElement
経由で取得可能.
折角なので効果がどの程度か実験.
- 1つ
<li>
内に<input ng-model="name" />
と{{child.name}}
を10個ずつ - scopeのコレクション要素数を0→1000に増幅
のボリューム条件を与えて、素朴版と2フェーズ版双方で描画に要した時間を計測してみた.
ちなみに、素朴版のmyCompiler
は以下のサービスコードを作成:
angular.module('myApp').factory('myCompiler', ['$compile', function($compile){
return function(template, scope){
return $compile(template)(scope);
};
}]);
Chromeのprofilerで計測した結果が下記:
2フェーズ版の方が、1秒くらい速い。要素数やDOMの状態でも変わるだろうが、20%程度の性能向上効果がある、ということだ。
なお、実際のngRepeatはもっと色々な工夫がされているので、上記の2フェーズ版より全然速い。
まとめ
- Compile/Linkの分離はテンプレート要素がscopeに応じて複製されるようなディレクティブを作成するケースで有効.
- カスタムディレクティブ内で適切に使いこなせば、AngularJSアプリの性能が上がる.
- Compileはテンプレート解釈時に一回呼び出され、Link関数は紐付くscopeに応じて複数回呼び出される.
- Link関数には、
linkFn(scope, function(clonedElement){...});
の形式で複製済み描画要素としてscopeと結合できる.