Angular2のChange Detectionについて

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

こんにちは、@laco0416です。本稿はAngular2 Advent Calendarに向けた記事として執筆しています。
なお、本稿の執筆時点でのバージョンはAngular2 alpha-48です。本稿にはサンプルコードがないので影響がないのですが、alpha-48にはいろいろと致命的なバグがあるので、実際にいろいろ試すのであれば49で修正されるまでは47を使ったほうが良さそうです。

はじめに

本稿ではAngular2がデータバインディングの変更検知に利用している Change Detection という内部機構について解説しますが、その前にAngularJS (1.x系)時代の変更検知と、Angular2が乗り越えなければならない課題についておさらいすることとしましょう。

なお、こちらの「AngularJS の $watch, $digest, $apply について書く」というブログ記事の内容を参考にし、多少引用させていただきました。

AngularJSとdirty checking

AngularJSの双方向データバインディングを支えている仕組みがdirty checkingと呼ばれる変更検知機構です。そもそも双方向データバインディングとはどういったものであるのか今一度おさらいしましょう。

次のような簡単なAngularJSのアプリケーション断片を考えます。これだけのコードで、input要素に入力された内容がspan要素にデータバインディングされ、変更が即座に反映されます。

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

コードだけ見れば単純ですが、AngularJSはどのようにしてこのデータバインディングを実現しているのでしょうか。ざっくりと流れを見ていきます。

1. 変更の監視対象の抽出

最初に、AngularJSはHTMLを解析して、変更を監視しなければならない対象を抽出します。先程の例でいえばtextが監視対象になっています。
そして{{text}}という記述を解釈したAngularJSは、内部的に次のようなJavaScriptのコードを実行しています。

$scope.$watch(function() {
    return $scope.text;
}, function(newValue, oldValue) {
    // span要素のinnerHTMLを更新する処理
});

Scope.$watchメソッドは、そのスコープで監視する式と、変更時のコールバックを追加する関数です。AngularJSでデータバインディングするということは、この\$watchメソッドをどんどん呼んで監視対象を増やしていくことに他なりません。

2. 変更検知

先述のサンプルで宣言したng-modelを解釈したAngularJSは次のようなコードを内部で実行します。(実際はもっと複雑ですが本質的な部分だけを抜き出しています)

element.on('blur', function(ev) {
  scope.$apply();
});

Scope.\$applyメソッドは、さきほど\$watchメソッドで追加した監視対象を実際にチェックします。そして、watch式の結果が変わっていれば、先ほどの\$watchメソッドの第2引数のコールバックが呼び出されます。
まとめると、はじめの例は次のような流れで入力されたデータがDOMに反映されています。

  1. input要素に変更を加える
  2. ng-modelがinput要素のイベントを受け、\$scope.textを変更し、\$scope.\$applyを実行する。
  3. スコープに登録されたすべての$watch 式を評価する
  4. {{text}}の\$watch式の結果が変わっている
  5. {{text}}の\$watch式のコールバックが呼び出される
  6. コールバック内でDOMが更新される
  7. もう一度スコープに登録されたすべての\$watch式を評価する
  8. すべての\$watch式が前回と同じ結果を返すので終了する

ここでポイントは、最後の「すべての\$watch式が前回と同じ結果を返すので終了する」という部分で、逆に言えばすべての\$watch式が前回と同じ結果を返すまでは延々と\$applyを呼び続けます。呼び出される式の中には変更箇所と全く関係ないものも含まれ、無駄な処理がたくさん走ってしまうこともあります。

これを解決するのがAngular2のChange Detectionです。前置きが長くなりましたが、本題に入っていきましょう。

Change Detectionとは?

Change Detectionはdirty checkingをより効率的な検知を行えるように進化させた仕組みです。基本的にはパフォーマンスを改善することを目的としており、そのために様々な工夫を盛り込みつつ、できるだけシンプルな実装になるように設計されています。そのポイントをひとつずつ紹介していきます。

ComponentツリーとChange Detector

Change Detectionを行うのはChange Detectorという存在です。AngularJSではScopeに対して\$watchと\$applyが存在しましたが、それと同じようにある特定の範囲のデータバインディングを管轄するのがChange Detectorです。
Change DetectorはComponentに紐付いており、基本的に1 Component: 1 Change Detectorという関係になっています。そして、Componentがツリー構造になっているように、そのままの構造でChange Detectorもツリー構造になっています。

image

image

引用:VICTOR SAVKIN: CHANGE DETECTION IN ANGULAR 2

ChangeDetectionStrategyとChange Detector

Change Detectorには Checked, CheckOnce, CheckAlways, Detached という4つの状態があります。
Checkedは自身のチェックを終えて凍結状態になっていることを示します。CheckedなChange Detectorは明示的にChecked以外の状態に設定されないかぎりチェックすら走らず、更新されることはありません。
CheckOnceは1回だけチェックを行うという状態です。次回のチェックが終了した時点で自動的にCheckedとなります。
CheckAlwaysはずっとチェックをし続ける状態で、デフォルトではこの状態です。AngularJSと同じ振る舞いをすると考えるとわかりやすいかもしれません。
Detachedはそのコンポーネントが監視対象になく、すでにデタッチされていることを示します。

これらの状態はChangeDetectionStrategyという仕組みによって切り替えられ、
ComponentのデコレータのchangeDetectionというプロパティで設定可能になっています。
changeDetectionOnPushに指定したComponentは、アタッチ時に初回のチェックを行ったあとは外部の変更によるトリガーでチェックされることはありません。配列を例にあげれば、配列の要素ひとつひとつをOnPushにしておけば、その配列のどこかひとつが変更されたとしても別の要素は影響を受けず、親だけに伝播していくイメージです。

チェック対象の数は直接的にパフォーマンスに響いてくるので、こういったテクニックにより効率的なデータバインディングが可能になったというのはとても重要なポイントです。

データ構造によるチェック方法の振り分け

単にチェックを行う行わないの制御だけではなく、チェック自体のパフォーマンスを改善するため、Angular2では対象のデータ構造によってチェックの仕方を変えるようになりました。

フィールドの場合

監視対象が配列やマップでない普通のオブジェクトの場合、Change Detectorは!==式によって変更をチェックします。

配列の場合

監視対象が配列のようにIterableなオブジェクトである場合は、IterableDifferという特殊なチェック機構を使ってチェックします。IterableDifferは要素の中身には関知せず、純粋に配列としての変化だけをチェックするので、フィールドのチェックよりも高速です。

マップの場合

マップに関しても、配列と同じように個々の要素の変更には関知せず、全体としての変更だけを扱います。マップの場合は要素の順番が保証されていないのでKeyValueDifferという特殊なチェック機構でチェックするようになっています。

このようにAngular2では監視対象によってチェック機構を切り替えることで、無駄な処理を極力減らし、パフォーマンスを改善しています。

Inputを使う上での注意

@Inputを使ってComponentへの入力を扱うとき、外部からの入力の変化はngOnChangesというメソッドで受け取ることができます。ngOnChangesメソッドは、外部からの入力がそのComponentのChange Detectorによって「変更されたかどうか」をチェックされた上で、変更されていればngOnChangesが呼ばれ、Component内部のデータバインディングが更新されます。逆に言えば、同じ入力を送り続けられることに対してChange Detectorは一切反応しません。
そして重要な点は、Inputの値に関してChange Detectorがチェックを行うのは当然ながら外部から入力があったときだけということです。InputなのにもかかわらずComponent内部で自身のフィールドを書き換えてしまうと、Change Detectorが内部に持っているレコードと、Componentが持っている最新の値の同期が崩れてしまいます。正しくChange Detectionが行われなくなってしまうので、Inputなフィールドは絶対に内部で変更しないようにしましょう。変更したい場合は、そういうシグナルをOutput経由で親に送り、親に入力を変えてもらうのが正しいフローです。
もしくは、Inputなフィールドの型をObservable<T>にしてしまい、内部でそもそも変更できないようにしてしまうのも一つの手でしょう。Angular2のアプリケーション内でRxJSをどう活用するかというのは開発側でも議論されており、いずれベストプラクティスが生まれるでしょう。今はいろいろと試行錯誤してみるのが楽しそうです。

まとめ

  • 何もしなければ基本的にAngularJSと同じ挙動
  • ChangeDetectionStrategyによって変更チェック対象を絞って効率的に更新できる
  • チェックアルゴリズムの改善によってチェック自体も高速になっている

Componentのツリー構造を意識してアプリケーションを組み立てることで、ChangeDetectionを活用した効率的なデータバインディングを活用することが可能になります。データバインディングによるパフォーマンスへの影響が気になり始めるのはアプリケーションがある程度大規模になってきてからですが、最初から意識して設計しておくことで後が楽になるでしょう。後から改善するにしても、Componentにより小分けにされているので小さな単位で改善を試していくことも可能です。
ぜひAngular2でハイパフォーマンスなWebアプリケーションを作っていきましょう。

参考記事

VICTOR SAVKIN: CHANGE DETECTION IN ANGULAR 2
Change Detection

この投稿は Angular 2 Advent Calendar 20158日目の記事です。