WEB+DB PRESS Vol.79にAngularJSの記事を書きました
2/22発売のWEB+DB PRESS Vol.79のJavaScript連載で「AngularJS」をテーマに記事を書きました。
こんな感じのことを書いてます。
- AngularJSについて
- AngularJSの主な機能
- 2wayバインディング
- スコープ
- モジュール
- コントローラ
- Dependency Injection (DI)
- サービス
- ディレクティブ
- AngularJSアプリケーションのファイル構成
AngularJSを使って本格的にアプリケーションを作っていくのに最低限必要な要素について解説しています。
AngularJS触ってみようかなーとか、とりあえずHelloWorldしてみたけどどういう機能があるのかいまいち分からないなー。
といった人のお役に立てれば幸いです。
以下、短いですけど執筆後記です。
2年間続いた「JavaScript活用最前線 -大規模開発の現場から-」は今号で最終回となります。
ちょうど2年ほど前に、現場で使えるJavaScriptネタで連載を書かないかという話をいただいて、最初は僕とid:ama-chの2人で書く予定だったのですが、僕が転職することになったため、急遽同期のid:teppeisにお願いすることになったという経緯があります。
記事のやり取りをしていたサイボウズLiveを見ていて、申し訳ないなという気持ちもあったり。
そんな中、連載が2年目に入ったのと、ちょうど今の会社に転職して個人で執筆できるようになったのもあって、2年目の連載は3人でローテーションして書いてきました。
業務をこなしながら8ページ分の記事を書くのは、業務のタイミング次第では本当に大変でしたが、非常にいい経験ができたと思っています。
id:teppeis、id:ama-ch、2年間お疲れさまでした!
AngularJSのディレクティブの仕組みを追ってみた
追ってみたシリーズ第3回目。
AngularJSのディレクティブ、名前は聞いたことあるけどあれでしょ?自前の「ng-hoge」を作るための仕組みでしょ?
だいたいそんな感じですが、どうやって実現しているのか。
ディレクティブの役割
ディレクティブはHTMLビューを書き出すためだけのものじゃない。
「ng-controller」や「ng-model」など、HTMLに対して処理をバインド/注入する機能もディレクティブで出来ている。
AngularJSが持つ組み込みのディレクティブだけでもこれだけの数がある。
まさしくAngularJSのベースとなっている仕組みがDirectiveというわけか。
追ってみる
ベースとなるngDirective関数を見ると、"link"と"restrict"プロパティを持ったオブジェクトを返す関数を作っていることが分かる。
function ngDirective(directive) { if (isFunction(directive)) { directive = { link: directive }; } directive.restrict = directive.restrict || 'AC'; return valueFn(directive); }
これだけじゃよく分からないので、「ng-model」ディレクティブを見てみる。
var ngModelDirective = function() { return { require: ['ngModel', '^?form'], controller: NgModelController, link: function(scope, element, attr, ctrls) { // notify others, especially parent forms var modelCtrl = ctrls[0], formCtrl = ctrls[1] || nullFormCtrl; formCtrl.$addControl(modelCtrl); scope.$on('$destroy', function() { formCtrl.$removeControl(modelCtrl); }); } }; };
「AngularJSの2way bindingの仕組みを追ってみた」で追っていた初期化処理(bootstrap関数)でcollectDirective関数が呼ばれ、スコープが管理するHTML内で「ng-model」が定義されていると、AngularJS内で「ngModel」をキーをしてマッピングされているngModelDirectiveが実行される。
ここで返されたオブジェクトがディレクティブとなる。
compileプロパティを持たず、linkプロパティを持っている場合は、linkプロパティを返す関数がcompileプロパティとしてディレクティブにセットされる。
このとき、ディレクティブに自動でセットされるプロパティは以下のものになる。
- compile(ディレクティブの実行内容)
- priority(DOMに複数のディレクティブが定義されている場合の優先順位)
- index(DOMに対して何番目に定義されたディレクティブか)
- name(ディレクティブにつけられている名前)
- require(依存関係を持つディレクティブ)
- restrict(ディレクティブの適用条件。"A"なら属性名、"E"なら要素名、"AE"ならどちらにもマッチ)
そして返されたディレクティブは、要素のcompileプロセスにおいて、applyDirectivesToNode関数で要素に対して「POST LINKING」というリンク関数としてセットされる。
リンク関数には「PRE LINKING」「POST LINKING」があるが、これは子要素に対してリンク関数を実行する前に実行するか後に実行するかというフェーズがある。
「PRE LINKING」にするか「POST LINKING」にするかは、ディレクティブを定義する際のcomlileプロパティに"pre"か"post"を指定すれば選択可能。
compile: function compile(scope, element, attr) { return { pre: function preLink(scope, iElement, iAttrs, controller) { ... }, post: function postLink(scope, iElement, iAttrs, controller) { ... } } // or return function postLink( ... ) { ... } }
この後、nodeLinkFnというクロージャ関数でリンク関数としてディレクティブのcompileに指定した処理がようやく実行される。
このとき、ディレクティブにcontrollerプロパティが指定されていれば、コントローラーがインスタンス化され、自分が所属するスコープ内の検索対象として使われる(requireで"^"がついていない場合)。
見つかったコントローラーは、コンパイル時の指定関数の第4引数に配列でセットされ渡される。
ディレクティブでできること
- 自分が定義した要素名が使えるようになり、その要素に対する初期処理をAngularJS内のフェーズごとに書ける
- 既存の要素に対し、初期処理をAngularJS内のフェーズごとに書ける
- 特定の要素、属性を持つスコープやコントローラーに対し、処理を外側から追加することができる
このスコープやコントローラーに処理を追加できるのがキモだと思われます。
独自にAngularJSに機能を追加するプラグインやライブラリを書く場合、「PRE LINKING」で所属するスコープに関数を生やしたり、コントローラーに新たに処理をバインドしたりといったことはディレクティブを使うとよさそうです。
今回はディレクティブの外部テンプレート機能については触れていないので、それはまた次に調べる。
AngularJSのDIの仕組みを追ってみた
AngularJS黒魔術のうちの1つ。DI。
コントローラーの引数に$httpなどを指定すると、なぜ何もしなくてもHttpProviderの返り値が入ってくるのか。
var userControllers = angular.module('userControllers', []); userControllers.controller('UsersCtrl', function($scope, $http) { $http.get('users/index.json').success(function(data) { $scope.users = data; }); });
これは、定義したコントローラーをインスタンス化する際にannotate関数でDI対象となる引数を取得して、該当するサービスオブジェクトに差し替えているから。
function invoke(fn, self, locals){ var args = [], $inject = annotate(fn), 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) { fn = fn[length]; } return fn.apply(self, args); }
では、annotate関数では何をしているのか。
FunctionオブジェクトをtoStringで文字列化して、引数に当たる文字列を抜き出し、返している。
fn.lengthはその関数が受け取る引数の数を表す。
function annotate(fn) { var $inject, fnText, argDecl, last; if (typeof fn == 'function') { if (!($inject = fn.$inject)) { $inject = []; if (fn.length) { fnText = fn.toString().replace(STRIP_COMMENTS, ''); argDecl = fnText.match(FN_ARGS); forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){ arg.replace(FN_ARG, function(all, underscore, name){ $inject.push(name); }); }); } fn.$inject = $inject; } } return $inject; }
上の例の場合、argDecl[1]には"$scope, $http"という文字列が入る。
これをsplitして、getService関数でファクトリ関数の返り値をキャッシュしたプールからオブジェクトを取り出し、コントローラーのコンストラクタに渡して実行する、というわけか。
このキャッシュプールから取り出すキーになるのが"$http"だったり"$routeParams"だったりするので、引数の名前が違うとDIされない。
Angularが用意しているProviderを使いたければ、Angularが中で持っている名前にしないといけないし、module.factoryなどでユーザーが定義したサービスだったら、その名前で指定しないといけない。
また、よく言われるminifyのための注意として、ユーザーが定義するコントローラーやサービスには文字列でも引数を指定しておくというのも納得。
そうしないとminify時に引数名が短縮されてしまうので、DIすべき対象が見つからなくなってしまう。
userControllers.controller('UsersCtrl', ['$scope', '$http', function($scope, $http) { $http.get('users/index.json').success(function(data) { $scope.users = data; }); }]);
AngularJSの2way bindingの仕組みを追ってみた
AngularJSの特徴でもある、モデルとビューの2way binding。
AngularJSの簡単なコードがあるとする。(投稿時点ではv1.2.6)
<body ng-app ng-init="message = 'nothing'"> <div ng-controller="SampleCtrl"> <input type="text" ng-model="message"> <br> <button ng-click="clearMessage()">Clear</button> <br> <span>{{getMessage()}}</span> </div> <script> var SampleCtrl = function($scope) { $scope.message = ''; $scope.clearMessage = function() { $scope.message = ''; }; $scope.getMessage = function() { return $scope.message; }; }; <script> </body>
テキストボックスに文字を打ち込むと「{{getMessage()}}」に打ち込んだ文字がリアルタイムに表示されるし、「Set "init"」ボタンを押すとテキストボックスと「{{getMessage()}}」で表示されるテキストが空文字になる。
テキストボックスに$scopeのmessageプロパティをバインドしているので、テキストボックスがインタラクティブになるのは分かる。
しかしなぜ$scope.getMessage()も同じタイミングで実行されHTMLまで書き変わるのか。気になる。気になる・・!
AngularJSのソースがどう動いているのか追ってみる。
AngularJSはuncompressedなファイルでは全体で20539行。
ギリギリ読めないことはない。
20535行目でロード時のイベントハンドラが発火される。
jqLite(document).ready(function() { angularInit(document, bootstrap); });
angularInit関数の中で、「'ng:app', 'ng-app', 'x-ng-app', 'data-ng-app'」をid,classあるいは属性で持つ要素がAngularアプリのベース要素としてセットされる。
該当する要素がなければその後の処理はなにも実行されない。
1283行目のdoBootstrap関数が実行される。
名前の通り初期化関数であり、ここでモジュールをロードし、Angular内の各サービスがファクトリー関数でインスタンス化される。
ここで利用されるinjectorというオブジェクトは、内部関数を呼び出すための抽象レイヤーみたいなもの。
本筋とはずれるが、window.nameに「NG_DEFER_BOOTSTRAP」という名前をセットしておくとdoBootstrap関数は呼ばれない。
代わりにangular.resumeBootstrap(extraModules)という、後から手動でdoBootstrap関数を実行できる関数が生える。
サービスのファクトリー関数を見ると、名前の最後に"Provider"をつけて再起的にinvokeしていることが分かる。
createInternalInjector(instanceCache, function(servicename) { var provider = providerInjector.get(servicename + providerSuffix); // providersuffix == "Provider" return instanceInjector.invoke(provider.$get, provider); });
doBootstrap関数では、最終的にScopeインスタンスの$apply関数が実行される。
injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', '$animate', function(scope, element, compile, injector, animate) { scope.$apply(function() { element.data('$injector', injector); compile(element)(scope); }); }] );
$apply関数でやっていることは引数に渡した関数を実行すること。
ようやくcompile関数にたどり着いた。
compile関数で、引数に渡されたelementから再帰的にHTML要素を舐め、HTML要素にセットした「ng-*」属性名からディレクティブを呼び出し、ディレクティブのcompile関数で属性値を$eval関数で処理する。
例えば、ng-init属性に対応するngInitDirectiveディレクティブはこのようになっている。
var ngInitDirective = ngDirective({ priority: 450, compile: function() { return { pre: function(scope, element, attrs) { scope.$eval(attrs.ngInit); } }; } });
ng-controllerやng-modelもcompile関数でディレクティブを集め、返却されたクロージャで各ディレクティブをコンパイルしていく。
ここでまず、「ng-init="message = 'nothing'」といった初期化のための属性値はAngularが持つ構文解析にかけられ、対応するScopeインスタンスにセットされる。
実際に値をセットしているのは、10238行目のsetter関数で行われる。
最初の引数の「obj」にはScopeインスタンスが入っている。
////////////////////////////////////////////////// // Parser helper functions ////////////////////////////////////////////////// function setter(obj, path, setValue, fullExp, options) { //needed? options = options || {}; var element = path.split('.'), key; // カット key = ensureSafeMemberName(element.shift(), fullExp); obj[key] = setValue; return setValue; }
コントローラについて、$ControllerProviderが返したクロージャが実行され、インスタンス化される。
return function(expression, locals) { var instance, match, constructor, identifier; if(isString(expression)) { match = expression.match(CNTRL_REG), constructor = match[1], identifier = match[3]; expression = controllers.hasOwnProperty(constructor) ? controllers[constructor] : getter(locals.$scope, constructor, true) || getter($window, constructor, true); assertArgFn(expression, constructor, true); } instance = $injector.instantiate(expression, locals); if (identifier) { if (!(locals && typeof locals.$scope == 'object')) { throw minErr('$controller')('noscp', "Cannot export controller '{0}' as '{1}'! No $scope object provided via `locals`.", constructor || expression.name, identifier); } locals.$scope[identifier] = instance; } return instance; };
実際にインスタンス化されるのは「$injector.instantiate(expression, locals)」の行だが、ここでユーザーが定義したコントローラーのコンストラクタを実行した結果が返る。
新規で作ったFunctionオブジェクトをthisとしてコントローラーのコンストラクタを実行するのが面白い。
function invoke(fn, self, locals){ var args = [], $inject = annotate(fn), 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); } function instantiate(Type, locals) { 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); return isObject(returnedValue) || isFunction(returnedValue) ? returnedValue : instance; }
「ng-model」はNgModelControllerとしてインスタンス化され、属性値をwatchする。
モデルに指定した変数の値が変更された場合、コントローラーの$viewValueに値をセットして再描画する。
これがモデルからビューへのバインディングになっているわけか。
$scope.$watch(function ngModelWatch() { var value = ngModelGet($scope); // if scope model value and ngModel value are out of sync if (ctrl.$modelValue !== value) { var formatters = ctrl.$formatters, idx = formatters.length; ctrl.$modelValue = value; while(idx--) { value = formatters[idx](value); } if (ctrl.$viewValue !== value) { ctrl.$viewValue = value; ctrl.$render(); } } return value; });
要素が「input type="text"」の場合、textInputTypeというディレクティブが呼ばれる。
キー入力を監視し、Scopeインスタンスの$apply関数でコントローラーの$setViewValue関数に新しい値を渡して再描画させる。
他のinput要素などの再描画ロジックの違いはここで吸収し、ctrl.$renderに関数をセットしている。
var listener = function() { if (composing) return; var value = element.val(); if (toBoolean(attr.ngTrim || 'T')) { value = trim(value); } if (ctrl.$viewValue !== value) { scope.$apply(function() { ctrl.$setViewValue(value); }); } };
「ng-pattern」という属性値をセットしておくと、バリデーションとしてセットできることも分かる。
// pattern validator var pattern = attr.ngPattern, patternValidator, match; var validate = function(regexp, value) { if (ctrl.$isEmpty(value) || regexp.test(value)) { ctrl.$setValidity('pattern', true); return value; } else { ctrl.$setValidity('pattern', false); return undefined; } };
「{{getMessage()}}」で表現している箇所に関しては、TextInterpolateDirectiveのtextInterpolateLinkFn関数でテキストノードにリスナー関数をバインディングする。
リスナー関数では「{{}}」を評価した結果を返し、値に変更があればテキストノードの値を書き換える。
$scope.messageの値が変わったら、$scope.getMessage()の結果も変わるので、「{{}}」の部分が変更される。
これでモデルからビューへのバインディングの仕組みが分かった。
function textInterpolateLinkFn(scope, node) { var parent = node.parent(), bindings = parent.data('$binding') || []; bindings.push(interpolateFn); safeAddClass(parent.data('$binding', bindings), 'ng-binding'); scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { node[0].nodeValue = value; }); }
最後に、$rootScope.$digest関数が呼ばれ、Scopeの階層を下りながらScopeのwatch対象に対してリスナー関数を実行していく。
$scope.messageには"nothing"という文字列が新しく入っているので、変更後の値として扱われる。
if ((watchers = current.$$watchers)) { // process our watches length = watchers.length; while (length--) { try { watch = watchers[length]; // Most common watches are on primitives, in which case we can short // circuit it with === operator, only when === fails do we use .equals if (watch) { if ((value = watch.get(current)) !== (last = watch.last) && !(watch.eq ? equals(value, last) : (typeof value == 'number' && typeof last == 'number' && isNaN(value) && isNaN(last)))) { dirty = true; lastDirtyWatch = watch; watch.last = watch.eq ? copy(value) : value; watch.fn(value, ((last === initWatchVal) ? value : last), current); // カット } } } } }
これでbootstrap関数で実行される処理は終わり。
長い・・!フレームワークの特性上、ロード時にいろいろやってるのは想像つくけど、これはすごい。
クロージャを上手く使ってキャプチャすることでなるべくプロトタイプに値を持たせずに引き回してたり、全体を見たときに作られるインスタンスがとても少ない。
Scopeという概念がHTMLの階層とマッチしていて、値の変更時のキャプチャリング/バブリングを上手く抽象化していることが分かる。
でもって、Scopeはその階層でのハンドラや独自処理をプロトタイプに書けると。
invokeの使い方はAngular使わないコードでも参考になるなー。
あと、GoogleClosureLibraryを使ったことのある人だったら見覚えのある実装がいくつかあったりしてニヤッとしたり。
これで2way bindingの仕組みが分かったので、AngularJSと少し仲良くなれた気がする。
次はDIともうちょっとディレクティブをちゃんと追ってみよう。
WEB+DB PRESS Vol.76にWeb Componentsの記事を書きました
うう、発売されてからだいぶ経ってしまった。。。
WEB+DB PRESSのJavaScript連載の第9回目にWeb Componentsについての記事を書かせていただきました。
- 作者: 五十嵐啓人,伊野亘輝,近藤宇智朗,渡邊恵太,須藤耕平,中島聡,A-Listers,はまちや2,川添貴生,片山育美,池田拓司,濱崎健吾,佐藤太一,曾川景介,久保渓,門脇恒平,登尾徳誠,伊藤直也,mala,後藤秀宣,若原祥正,奥野幹也,大林源,WEB+DB PRESS編集部
- 出版社/メーカー: 技術評論社
- 発売日: 2013/08/24
- メディア: 大型本
- この商品を含むブログを見る
サービス/アプリの作り始めは特に気にする必要もないのですが(最初から検討するリソースがあるならその方がいいけど)、ページ数の多いサイト、インタラクションの多いアプリなどを作っていると、何かしらの方法でクライアントサイドのコンポーネント化を考えますよね。
サーバサイドのビューライブラリでのHTMLテンプレート化、Sassなどのmix-in、RequireJSなどを使って依存関係解決など、HTML/CSS/JavaScriptを個別にコンポーネント化していったり。
そうやってコンポーネント化が進んでくると、HTML/CSS/JavaScriptトータルで見たときの管理コストが逆に増えるケースもあると思います。
このJSであてられてるCSSクラスはどこで定義されてるんだろうとか、どの画面のどのDOMにこのJSコンポーネントは適用されているのか、とか。
大きめのクライアントサイドアプリだと、後から入ってくる人はHTMLの構造を把握するのも結構大変ですよね。
jQueryUIなんかそうですけど、UIコンポーネントを適用するために決まったDOM構造をHTMLに定義しておかないとよく分からないセレクタエラーで動いてくれない。ドキュメント通りのHTML構造にしろ、とかそういうHTMLの枠組みはUIコンポーネントの責務なのでは、、と思うこともしばしば。
そういった不便をブラウザネイティブの機能として解消してくれそうなのがWeb Componentsだと思っています。 なんせカスタムタグ一発でビデオタグみたいなリッチなUIを提供できるわけなので、ライブラリを使う人からしたらとても楽チン。
まだまだ一部の仕様しか使えないWeb Conponentsですが、GoogleのPolymerに続きMozillaもBrickを公開してきたりと、動きが活発になってきて楽しい感じです。
実際にvideoタグやaudioタグなどは既にブラウザに実装されて普通に使われているわけなので、運用も問題なさそう。 ただ、実際に普及するにはIEが実装してくれないとですが><