Angular開発をしていて遭遇する"ExpressionChangedAfterItHasBeenCheckedError"に関して解説されていた記事がありました。自分の理解を深める事も兼ねて記事を和訳(+自分のコメント)したものを共有します。
※Mediumに掲載されていた記事を翻訳したものになります。筆者の方から許可をいただきました。
※和訳初めてしてみました。
Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError
error
スタックオーバーフロには毎日ExpressionChangedAfterItHasBeenCheckedError
に関しての質問が投稿されているように思えます。これはAngularで開発している人がAngularの変更検知(以下和訳ではChangeDetectionとします。)の仕組みとなんでこのエラーが必要なのかを理解していないためにこの質問が投稿されるのだと思います。(すいませんそうです。)たくさんの開発者がこのエラーをバグだと考えていると思います。しかしこれはバグではないです。このエラーは開発者への警告です。UIとモデルとが動悸されていないという現象を開発者に警告しているエラーなのです。
この記事では、まずこのエラーの原因とこのエラーがどのようにAngularから検知されているかを説明します。そしてこのエラーが起こるパターンを幾つか示してそのエラーパターンへの解決策を示します。最後にこのエラーがなぜ重要なのかを説明します。
この記事内にたくさんのソースコードへのリンクがあると、読んだ人が他の人にオススメ出来ないと思うのでソースの参照リンクは載せていません。
変更検知に関連する処理
Angularアプリケーションはコンポーネントのツリーでできています。ChangeDetectionの間にAngularは1つ1つのコンポーネントを以下の流れでチェックしています。具体的な処理の流れは以下の通りです。
- 全ての子コンポーネント・ディレクティブに渡しているプロパティをアップデートする
- 全ての子コンポーネントのライフサイクルフック
ngOnInit
,onChanges
,ngDoCheck
をよぶ - 自身のDOMをアップデートする
- 子コンポーネントのChangeDetectionを走らせる
- 全ての子コンポーネントのライフサイクルフック
ngAfterViewInit
を呼ぶ
ChangeDetectionの処理は上記以外にもいくつかあります。その処理は私のこの記事Everything you need to know about change detection in Angularに書いてあります。
上記の処理の最中にAngularは**ChangeDetectionで使用したプロパティーの値を覚えるようにしています。**具体的にはコンポーネントのoldValues
プロパティに値を保存しています。ChangeDetectionが終わるとAngularは次のdigest cycleを行います。しかし上記リストの処理を行うのではなく、現在コンポーネントの値と前のdigest cycleで使われた値を比べる処理を行います。(このループを検証ループとします。)以下に検証ループの処理内容を示します。
- 子コンポーネントに渡したプロパティーの値が、現在のコンポーネントの値を更新したものと同じかをチェックします
- DOM更新のために使われた値と現在のコンポーネントのその値と同じであるかをチェックします
- 全ての子コンポーネントに対して上記2つのチェックをします
気をつけてほしいのが検証ループはDevelopモードでのみ実行されます。その理由はこの記事の最後に説明します。
例を使って示します。あなたは親コンポーネントA
と子コンポーネントB
を作っています。A
はname
とtext
プロパティを持っています。そしてtemplateでnameプロパティを使っています。
template: '<span>{{name}}</span>'
そしてB
コンポーネントにtext
プロパティを渡しています。
@Component({
selector: 'a-comp',
template: `
<span>{{name}}</span>
<b-comp [text]="text"></b-comp>`
})
export class AComponent {
name = 'I am A component';
text = 'A message for the child component`;
}
このコンポーネントでAngularのChange Detectionがどのように行われるのかを見てみましょう。親コンポーネントであるA
からチェックが始まります。最初は値の更新(1.全ての子コンポーネント・ディレクティブに渡しているプロパティをアップデートする)です。text
を評価して値A message for the child component
をB
に渡します。その際にAngularは使った値をストアします。
view.oldValues[0] = 'A message for the child component';
そして(2. 全ての子コンポーネントのライフサイクルフックngOnInit
, onChanges
, ngDoCheck
をよぶ)子コンポーネントのライフサイクルフックをコールします。
次は3つ目の処理(3. 自身のDOMをアップデート)です。{{name}}
にI am A component
テキストを当てはめます。そしてDOMを更新し、使った値をストアします。
view.oldValues[1] = 'I am A component';
次にAngularはB
に対して同じチェックを行います。B
でのチェックが終わると1digest loopが終わったといえます。
AngularがDevelopモードの場合は2回目のloop
が走ります。(これはコンポーネントの値が正しいかを検証するためのloop
です)ちょっと考えてみてください。どのようにかなってB
コンポーネントにA message for the child component
を渡した後に、A
コンポーネントのtext
プロパティがupdated test
に変更されてしまいました。検証ループではtext
プロパティが変化していないか(普通は同じなはず)をチェックします。
AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false
そうです。この時にAngularはExpressionChangedAfterItHasBeenCheckedError
を投げます。
3つめの処理(3. 自身のDOMをアップデートする)でも同じです。もしDOMのアップデート後にname
プロパティが変更されると同じエラーが投げられます。
AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false
(この場合子コンポーネントの表示がモデルと異なるみたいなバグが生まれる。)
多分あなたは今、「そんなこと起きなくね???」と思っているのではないでしょか。これが起きる例を示します。
値が書き換えられる原因
このエラーの原因となるのは子コンポーネントです。 簡単なデモからはじめてみます。まずはシンプルな例を使います。その後に実際のケースに近い例を示します。子コンポーネントは親コンポーネントをInjectできます。今回はBコンポーネントでAコンポーネントをInjectしてngOnInt
でAのtext
の値をアップデートしてみます。
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngOnInit() {
this.parent.text = 'updated text';
}
}
上のように書くと
Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'A message for the child component'. Current value: 'updated text'.
みたいなエラーが投げられます。
同じようにA
のtemplateの中で使われているname
プロパテーを変更してみましょう。
ngOnInit() {
this.parent.name = 'updated name';
}
今度はエラーが出ていませんね。
ChangeDetectionの処理の順番をもう一度見てみましょう。
- 全ての子コンポーネント・ディレクティブに渡しているプロパティをアップデートする
- 全ての子コンポーネントのライフサイクルフック
ngOnInit
,onChanges
,ngDoCheck
をよぶ - 自身のDOMをアップデートする
- 子コンポーネントのChangeDetectionを走らせる
- 全ての子コンポーネントのライフサイクルフック
ngAfterViewInit
を呼ぶ
ngOnInit
はDOMのアップデートの前に呼ばれます。なのでエラーが投げられなかったのです。
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngAfterViewInit() {
this.parent.name = 'updated name';
}
}
と書くと予想したようにエラーが投げられます。
AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'I am A component'. Current value: 'updated name'.
もちろん実際のケースは、はるかに複雑です。 親要素のプロパティの更新またはDOMレンダリングの原因となる操作は、ServiceやObservableを使用して間接的に行われます。 しかし根本的な原因は常に同じです。(=子コンポーネント)
実際のエラーパターンを見てみよう
Shared Service パターン
このPlunkerに例を載せています。これは親と子である同じサービスをInjectしており、子コンポーネントがサービス経由で親のプロパティーの値を変えるパターンです。これは直接子が親の値を変えていないので(親をInjectしてない)分かりづらいパターンです。
同期のためのイベントブロードキャストパターン
このPlunkerに例を載せています。このパターンが使われているアプリケーションでは、子が何かのタイミングでevent.emit()をして、それを親が監視するように設計されています。そのイベント発火のタイミングで親コンポーネントの値が更新されます。そしてこれらの値が子コンポーネントのプロパテーに結び付けれることがあります。このパターンも親プロパティの値を更新する可能性があります。
動的コンポーネント生成パターン
このパターンは他のパターン(@Input
バインドが原因となる)とは少し違います。このPlunkerに例を載せています。このパターンが使われているアプリケーションでは、ngAfterViewInit
で親コンポーネントが子コンポーネントを動的に生成するように設計されています。子コンポーネントを生成するということはDOMの変更が必要になります。そしてngAfterViewInit
はDOMの更新が終わったタイミングで呼ばれます。なのでエラーが投げられます。
エラーを治すには
エラーの最後の文にこのようなことが書いてあります。
Expression has changed after it was checked. Previous value:… Has it been created in a change detection hook ?
このエラー文で提案してくれている治し方は、動的コンポーネント生成パターンの治し方です。上で最後に示したパターンだと、子コンポーネントを生成するコードを、エラーで提案してくれたとおりにngOnInit
に移すことで解決出来ます。AngularのドキュメントにViewChildren
はngAfterViewInit
の後に使うことができると書いてあります。しかしこのパターンだと子コンポーネントを、Viewを生成する段階で作るので子コンポーネントをngAfterViewInit
より前で利用する子ができます。
もし ExpressionChangedAfterItHasBeenCheckedError
でググると2つの代表的な解決策が見つかるとおもいます。
i) 非同期関数内で値をアップデート
ii) 強制的にChangeDetectionを走らせる
これからなぜこの2つが動くのか、そしてこれを使うよりも設計をやり直しをしたほうがいい理由を書いていこうと思います。
解決策1: 非同期関数内で値をアップデート
ここで覚えておいてほしいことはChangeDetectionと検証ループは同期処理ということです。なので、非同期関数内でプロパティーをアップデートすればdigest loopでも検証ループでも値は更新されないことになります。だからエラーが投げられないのです。
export class BComponent {
name = 'I am B component';
@Input() text;
constructor(private parent: AppComponent) {}
ngOnInit() {
setTimeout(() => {
this.parent.text = 'updated text';
});
}
ngAfterViewInit() {
setTimeout(() => {
this.parent.name = 'updated name';
});
}
}
実際、こうするとエラーは投げられません。setTimeout
はMacroTaskとして次のVMのターンの時に実行されます。もし現在のVMターンで実行したければPromiseを使ってください。MicroTaskとしてスケジューリングされます。
Promise.resolve(null).then(() => this.parent.name = 'updated name');
MicroTaskは現在の同期処理が全て終われば実行されます。
解決策2: 強制的にChangeDetectionを走らせる
これは親コンポーネントであるA
でChangeDetectionサイクルと検査ループのあいだにもう一度ChangeDetectionサイクルを走らせることです。タイミング的にはngAfterViewInit
内で走らせればよいです。そうすることで全子コンポーネントに対してChangeDetectionが走ります。
export class AppComponent {
name = 'I am A component';
text = 'A message for the child component';
constructor(private cd: ChangeDetectorRef) {}
ngAfterViewInit() {
this.cd.detectChanges();
}
}
やはりエラーは投げられません。この解決策には問題点があります。問題点は全子コンポーネントにたいしてChangeDetectionが走るので、子コンポーネントが親の値をまた変えちゃう危険性が残っています。
なんで検査ループが必要なの
Angularはデータの流れを親コンポーネントから子コンポーネントに一直線に流れさせています。なので子コンポーネントが親コンポーネントの変更検知が終わった後に親コンポーネントの値を変えることを許しません。これによってdigest loopを一回だけ回せば全ての更新が済むようになっています。他の所から(Consumerから)参照されているプロパティーが変更されるとツリーは不安定になります。今までの例だとB
コンポーネントはtext
プロパティに依存していると言えます。もしこのプロパティーが変更されると、この変更がB
に伝えられるまでツリーは不安定になります。DOMにも同じことがいえます。もしテンプレートからプロパティを参照している場合ですね。もしプロパティの同期が行われないとユーザーは間違った情報をページ上で見ることになるでしょう。
このデータを同期させるプロセスは、ChangeDetection内で行われます。なので親コンポーネントの同期処理の後に子が親の値を変えるとどうなるでしょう。これは不安定なツリーを放置することになります。つまりUIとデータが噛み合わない事が起きます。これはとても難しいバグになります。
なぜツリーが安定するまでループを回し続けないのでしょう?その答えはとてもシンプルです。そのような構造のコード(子コンポーネントが親コンポーネントを更新する)だと状態が安定になることはないので無限ループになるからです。さきほど書いたように、直接親コンポーネントの値を変えたりするならばバグの原因をすぐ見つけれます。ただ、実際のケースでは間接的に値を変えてしまうケースが多く見つけにくいです。
興味深いことにAngularJSではデータフローが一方向ではありませんでした。ツリーが安定するまでloopを回していたんです。なので10 $digest() iterations reached. Aborting!
のようなエラーがよくなげられました。このエラーをぐぐってみてください。とても多くの質問が見つかり驚くことになるでしょう。
最後の疑問は、”そんなやばいのになんでDevモードだけでチェックするの?”でしょう。たぶん、アプリが実行されている時の”不安定な状態”は深刻な問題にならなないからです。全ての値は次のループで正しい値に更新されます。ただ開発モードの時はエラーとして警告を出したほうが良いと考えられたのだと思います。
まとめ
- ChangeDetectionの処理順番がこのエラーの鍵を握っている
- 幾つかエラーの解決手段があるが、データが親から子に一直線に流れるようにアプリケーションを設計することが1番の解決策である