きちぽよ〜

ねむい

AngularJS の $watch, $digest, $apply について書く

「僕らはみんな河合荘」 のアニメ化決定に小躍りしている seikichi です.

f:id:kichipoyo:20131201034908j:plain

律ちゃんかわすぎ…….

前置きはさておき,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) と呼ぶことにします*2Scope.$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 のコンソールが場所を取りすぎている場合は,適当に小さくしてやって下さい……). このサンプルの内部で行われている処理を大ざっぱに書くと:

  1. テキストボックスに変更を加える
  2. input イベントが発火しイベントハンドラが実行される
  3. イベントハンドラが,$scope.name の値を変更し,$apply メソッドを呼び出す
  4. $digest ループ開始 (1週目)
  5. 全ての $watch 式を評価する
  6. テンプレート内に書かれた {{name}} によって生成された $watch 式の結果が変化している
  7. $watch 式のコールバック実行 (DOM を更新)
  8. $digest ループ2週目
  9. 全ての $watch 式を評価する
  10. 全ての $watch 式の評価結果が,前回の $digest ループの際の評価結果と同じ値である
  11. $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 で解説されています.サンプルコードはアップロードされているので,うんたらかんたら.気になる人は本買いましょう.電子書籍だと安いですよ.書きかけと言いつつ飽きたので多分続きは書きません.

参考サイト・書籍

*1:Backbone.stickitEpoxy.js などのライブラリを利用すれば Backbone.js でも双方向データバインディングを実現できます

*2:公式ドキュメントでは watchExpression や $watch expression と呼ばれているようです