IterableDiffers? KeyValueDiffers?
なんだそれは…という人向けの記事です。
配列やオブジェクトの変更を検知するには
あるコンポーネントのInput()がオブジェクトや配列だった時に、その変更をトリガーにして処理を走らせたい場合、どうしますか?例えばこんな感じ。
export class AppComponent {
novels = [
{ author: 'Emily', publisher:'BananaBooks'},
{ author: 'Bob', publisher:'OrangeNews'}
];
addNovel(){
this.novels.push({ author: 'Lyra', publisher: 'GrapePaper'});
console.log('addNovel')
}
}
AppComponentでは、小説本の情報をまとめ、novelsとしてBookComponentに渡します。ボタンを押すと小説を追加することができます。BooksComponentでは受け取った書籍情報にコードを追加した物を表示します。ですが、これではAppComponent.tsでAddNovel()
が呼ばれてもnovelsの参照が変わらないため、テンプレートが更新されません。
export interface BookInfo{
author:string;
publisher:string;
}
interface BookDetail extends BookInfo{
code: number;
}
export class BooksComponent implements OnInit {
@Input() novels: BookInfo[];
_novels: BookDetail[];
ngOnInit() {
this.init();
}
init(){
this._novels = this.fetchCodes(this.novels);
}
fetchCodes(books: BookInfo[]): BookDetail[]{
// it should be service in real case
return books.map(book => {
const randomNum = Math.floor( Math.random() * 101 );
return Object.assign({code: randomNum}, book);
});
}
}
<section>
<h1>novel</h1>
<div class="novel" *ngFor="let novel of _novels">
<div class="author">author: {{novel.author}}</div>
<div class="publisher">publisher: {{novel.publisher}}</div>
<div class="code">code:{{novel.code}}</div>
</div>
</section>
よくみる手法
よく見るのが、参照の変更をトリガーにするという手法です。例えばこんな感じ。
export class BooksComponent implements OnChanges {
@Input() novels: BookInfo[];
_novels: BookDetail[];
ngOnChanges(sc:SimpleChanges) {
const novelChange = sc['novels'];
this.init();
}
init(){
this._novels = this.fetchCodes(this.novels);
}
}
こうすると、AppComponent側でnovelsの参照を新しくしてあげればngOnChangesが走るので無事init処理を走らせることができます。逆に言えば、BooksComponentを使う側はpushした後に参照を新しくする処理をしなければならないということです。
addNovel(){
this.novels.push({ author: 'Lyra', publisher: 'GrapePaper'});
this.novels = this.novels.concat([]);
}
ちょっとした手間ですが、使う側がみんなその処理をしなければいけないと考えるとそこそこ不便です。できれば使う側は追加の処理をするだけで済むようにしたいですよね。そこで登場するのがIterableDiffersです。
IterableDiffersを使う手法
IterableDiffersはその名の通り、iterableの変更を検知します。調べたら英語の記事で「おそらく最も知名度のないAPI」と言われていただけあって日本語の記事がほとんどヒットしません。でもこれがかなり便利なんです。とにかく使ってみましょう。
あとで比較してわかりやすいように、novelsと同じBookInfo[]のcomicsを追加します。そして、変更を検知してくれるIterableDiffer
も準備します。
export class BooksComponent implements OnInit {
@Input() novels: BookInfo[];
@Input() comics: BookInfo[];
_novels: BookDetail[];
_comics: BookDetail[] = [];
comicDiffer: IterableDiffer<BookInfo>;
そして、そのIterableDiffer
を生成するためのIterableDiffers
をDIします。
constructor(private iterable: IterableDiffers) { }
次に、初期化処理でIterableDiffer
を生成します。今回は例をわかりやすくするため、参照が〜というのは一旦置いておき、最初のBooksComponentにならいngOnInit()で初期化していきます。
ngOnInit() {
this.comicDiffer = this.iterable.find(this.comics).create();
}
IterableDiffers
はAngularが検知しない変更を検知するためのライフサイクルフックであるngDoCheck()
で呼び出します。今回は本を追加するという処理のみを行うため、追加された項目に対してオペレーションができるforEachAddedItem
を使います。
ngDoCheck(){
const comicChange = this.comicDiffer.diff(this.comics);
if(comicChange){
comicChange.forEachAddedItem(record => {
this._comics = this._comics.concat(this.fetchCodes([record.item]));
});
}
}
これで無事、BooksComponentを使う側は普通にcomicsプロパティに項目を追加するだけで使えるようになりました。
addComic(){
this.comics.push({ author: 'Haru', publisher: 'MaronInc'});
}
これまでの内容をまとめて簡単なデモを作成しました。
StackBlitz
一瞬ハマったこと
ngDoCheck
はngOnInit
、ngOnChanges
の後に実行されるので、初期化時に入っている情報もforEachAddedItem
に入ります。なので、ngDoCheck
手前でforEachAddedItem
と同じ処理を書いてしまうと二重に実行されてしまいます。
当たり前だけどうっかり見落としそうなこと
IterableDiffer
はあくまでIterableの変更を検知するものなので、今回のように配列の中身がオブジェクトの場合、配列の要素の中の変更までは検知してくれません。今回のケースで中をチェックしたい場合は、keyValueの変更を検知してくれるKeyValueDiffersを使うと良いでしょう。基本的な使い方は同じなのでぜひ使ってみてください。