AngularJS の $watch, $digest, $apply について書く
「僕らはみんな河合荘」 のアニメ化決定に小躍りしている seikichi です.
律ちゃんかわすぎ…….
前置きはさておき,AngularJS の話をします. AngularJS はブラウザ上で動作するWebアプリケーションを作成するための JavaScript フレームワークです.Backbone.js,Ember.js,Knockout.js などに代表される,最近流行り(?)のMV*フレームワークの1つと言えば良いのでしょうか.
AngularJSの公式サイト に掲載されているサンプルを見てみます.
テキストボックスの内容を変更すると,<h1>
要素の中身にリアルタイムに反映されます.
すごい.JavaScript をまだ1行も書いてないのに,何か作った気分になってしまいました (おいおい).
AngularJS は ビューでの変更をモデルに,モデルでの変更をビューに自動的に反映する機能を備えています.この機能を双方向データバインディング (Two Way Data-Binding) と呼びます.なんて便利なフレームワークなのでしょうか.一度使うと Backbone.js などの双方向データバインディング無しのフレームワーク(or ライブラリ) には戻れそうにありません*1 (ほんまか).
ただ,使っていると「このフレームワークは裏で一体何をやっているのだろうか」という,もやもやした疑問が生まれてきます.適当にググると $watch や $diget や $apply だのよくわからないキーワードが出てきて混乱するばかり.そんなこんなで,AngularJS の内部の仕組みをちょっと調べてみました,というのがこの記事です.
この記事では以下の内容について解説します.なお AngularJS の基本的な概念 ($scope,コントローラ,DI など) については解説しません.
- Scope.$watch メソッドを使ってモデルの変更を監視する
- Scope.$apply メソッドを使ってモデルの変更をビュー (DOM) に適用する
- ngBind と ngModel もどきを実装する (書きかけ)
Scope.$watch メソッドを使ってモデルの変更を監視する
Scope.$watch メソッドを利用するとモデルの変更を監視することができます (参考).
突然ですが,$scope.name
の値が変更された際に,何か処理を行いたいとします.
この場合,コントローラ内で Scope.$watch メソッドを用いて以下のように書くことができます.
$scope.$watch(function() { return $scope.name; }, function(newValue, oldValue) { // $scope.name が変更された際に,以下の処理が実行されます console.log('"name" changed'); }); // 以下のコードでもOK $scope.$watch('name', function(newValue, oldValue) { // $scope.name が変更された際に,以下の処理が実行されます console.log('"name" changed'); });
jsFiddle で実行した例を以下に示します.
テキストボックスの内容を変更するたびに,コンソールにログが表示されます (Firebug のコンソールが場所を取りすぎている場合は,適当に小さくしてやって下さい……).
以降の解説では Scope.$watch メソッドの第一引数を $watch 式 ($watch expression) と呼ぶことにします*2. Scope.$watch メソッドについて (若干嘘を交えて) 大ざっぱに解説すると, $watch 式の評価結果が変更されると,第二引数に指定したコールバック関数が呼ばれます. また,この解説では $watch 式は関数であるとします.本当は文字列でも良いのですが (公式ドキュメント),その話をしようとすると Expressions の説明が必要になるので止めます.めんどくさいねん.
AngularJS のテンプレートに {{name}}
や <input ng-model="name">
と書くと,AngularJS の内部では $watch メソッドが呼ばれます.モデルの変更を検知し,自動的にビュー(DOM) に反映する機能は $watch メソッドを用いて実現されているのでした.めでたしめでたし.
……という説明で納得できるわけもなく,サンプルを見ていると色々と疑問が湧いてきます. $watch メソッドをよくよく見てみると,$watch 式 (第一引数) は何の変哲もない JavaScript の関数です.このため $watch 式の評価結果が変更されたかどうかは,関数を実際に評価してみないと分かりません.AngularJS は $watch 式をいつ評価しているのでしょうか.答えから書くと,次節で解説する $apply メソッドの呼び出しを経由して,$watch 式の評価が行われます.
Scope.$apply メソッドを使ってモデルの変更をビュー (DOM) に適用する
Scope.$apply メソッドを利用することでモデルの変更をビューに適用できます (参考).
$apply メソッドの利用例を以下に示します:
$scope.$apply(function() { $scope.name = 'kichipoyo' }); // 以下のコードと同じ $scope.$apply('name = "kichipoyo"');
$apply の疑似コードは次のようになります (出典).
function $apply(expr) { try { return $eval(expr); } catch (e) { $exceptionHandler(e); } finally { $rootScope.$digest(); } }
要するに,Scope.$apply は第一引数を評価したあと,$rootScope.$digest() を呼び出します.
この $rootScope.$digest() という処理の中で,全ての $watch 式が評価されます. ここからちょっと重要な話になるのですが, $rootScope.$digest() は全ての $watch 式の評価結果が変化しなくなるまで, 「全ての $watch 式の評価 → 変更があった $watch 式のコールバックを実行」 という処理を繰り返します. この繰り返しの処理を $digest ループ,もしくは $digest サイクルと呼びます.
ちなみに,$rootScope.$digest() ではなく,各コントローラに注入 (DI?) した $scope に対して,$scope.$digest() を呼ぶと,そのスコープと,子孫のスコープに登録されている $watch 式に対して $digest ループが行われるのですが,Scope の階層関係とかを説明するのが面倒になったので,この記事では $rootScope.$digest() という形式で $digest メソッドを呼び出す場合の話しかしません.公式ドキュメントに詳しい話があるので,興味がある人はそちらを当たって下さい.
話を戻して具体例を見てみることにします.以下のサンプルを実行し,テキストボックスに値を入力して,コンソールに出力される値を見てみて下さい.
テキストボックスの中身を変更するたび,$watch 式が2回評価されるのが分かります. (繰り返しで恐縮ですが Firebug のコンソールが場所を取りすぎている場合は,適当に小さくしてやって下さい……). このサンプルの内部で行われている処理を大ざっぱに書くと:
- テキストボックスに変更を加える
- input イベントが発火しイベントハンドラが実行される
- イベントハンドラが,$scope.name の値を変更し,$apply メソッドを呼び出す
- $digest ループ開始 (1週目)
- 全ての $watch 式を評価する
- テンプレート内に書かれた
{{name}}
によって生成された $watch 式の結果が変化している - $watch 式のコールバック実行 (DOM を更新)
- $digest ループ2週目
- 全ての $watch 式を評価する
- 全ての $watch 式の評価結果が,前回の $digest ループの際の評価結果と同じ値である
- $digest ループを終了
そんなこんなで,通常 $digest ループは最低2回行われます.
肝心の Scope.$apply メソッドはいつ呼ばれるのでしょうか. 実は AngularJS が提供している $http や $timeout といったサービスは, 内部で $apply を呼び出しています. このため,これらのサービスを利用して (= コールバック関数内で) モデルの値を変更する場合は, 明示的に $apply を呼ぶ必要はありません. ただし,$timeout の代わりに setTimeout を利用したり,$http の代わりに jQuery.ajax を利用して, モデルの値を変更する場合は,明示的に $apply を呼ぶ必要があります.……どう考えてもアンチパターンですが.
例1: $timeout を利用して $scope.name の値を変更
例2: setTimeout を利用して $scope.name の値を変更 ($apply が呼ばれないため,ビューに変更が通知されない)
例3: setTimeout を利用して $scope.name の値を変更 + $apply の呼び出し
$digest ループには上限が設定されています (デフォルトでは10). この上限までループを繰り返してもモデルの値が安定しない場合は, AngularJS はエラーを発生させ,$digest ループを終了させます. 以下のように Math.random() の結果を $watch してみると実際にエラーが起こります.
ngBind と ngModel ディレクティブもどきを自作する (書きかけ)
Mastering Web Application Development with AngularJS という書籍の Chapter 11 で解説されています.サンプルコードはアップロードされているので,うんたらかんたら.気になる人は本買いましょう.電子書籍だと安いですよ.書きかけと言いつつ飽きたので多分続きは書きません.
参考サイト・書籍
- AngularJS のソースコード
- Mastering Web Application Development with AngularJS
- AngularJS: Scopes
- Try, Catch, Fail :AngularJS: $watch, $digest and $apply
- Using scope.$watch and scope.$apply - Stack Overflow
*1:Backbone.stickit やEpoxy.js などのライブラリを利用すれば Backbone.js でも双方向データバインディングを実現できます
*2:公式ドキュメントでは watchExpression や $watch expression と呼ばれているようです