非同期処理後に動的に生成される DOM にアクセスする
Angular では、親コンポーネントクラスから DOM にアクセスする場合、 ViewChild というデコレーターが利用できます。
色々実装していく中で、非同期処理後に動的に生成された DOM にアクセスしたいケースがありました。
@if(isFetched) { // <-- 非同期処理が終わった後に表示される
<div>fetched from API!</div> // <-- この要素にアクセスしたい
}
今回のようなユースケースでの DOM へのアクセスするにはいくつかポイントがあるので、この記事でまとめています。
ViewChild の使い方のおさらい
まず基本的な ViewChild の利用方法について、おさらいします。
ViewChild は selector で指定した名前を、取得したい HTML に #selector名
で渡すことで、その要素にアクセスできます。
...
@Component({
selector: 'app-root',
standalone: true,
template: `
<div #ref>target element!</div>
`,
})
export class App implements AfterViewInit {
@ViewChild('ref') ref?: ElementRef<HTMLDivElement>;
ngAfterViewInit() {
console.log(`fetched {this.ref}`)
}
}
...
ここでのポイントは、DOM にアクセスできるのは DOM が描写された後ということです。
Angular のライフサイクルでは、Angular がコンポーネントのビューと子ビュー、またはディレクティブを含むビューを初期化した後に応答する AfterViewInit
というイベント以降であれば利用できます。
非同期処理後に動的に発生する要素を取得する方法
一方、何らかの非同期処理を待ってから描写される DOM を取得したい場合は、少し工夫が必要です。
先ほどと同様に実装したとしても ngAfterViewInit では DOM を参照できません。
@Component({
selector: 'app-root',
standalone: true,
template: `
@if(data()) {
<div #ref>INNER</div> <!-- dataを取得後にdiv要素が描写される -->
}
`,
})
export class App implements OnInit, AfterViewInit {
readonly data = signal<string | null>(null);
@ViewChild('ref') ref?: ElementRef<HTMLDivElement>;
ngOnInit() {
this.#fetchData(); // 一定時間に data を取得する (例. API など)
}
ngAfterViewInit() {
// このタイミングでは data がまだ取得されていないため this.ref が undefined になる
console.log(`fetched text: ${ref.nativeElement.innerText}`);
}
// 非同期処理のサンプル (外部APIのフェッチなどを想定)
#fetchData() {
setTimeout(() => {
this.data.set('fetched');
}, 500);
}
}
この例では、AfterViewInit のライフサイクルで ref を参照するタイミングで、#fetchData
の実行が終了していないため data は null のままです。
そのため template の <div #ref></div>
がまだ描写されておらず、ViewChild では取得できません。
この問題を解決するには、ViewChild がどのタイミングで HTML を参照するのかを理解する必要があります。
ViewChild はデフォルトでは DOM の変更を検知するたびにプロパティを更新します。
つまり今回の例だと #fetchData
でデータを取得し、template の @if(data()) が true になった後であれば、ref で DOM を参照できます。
ref の値の変化を監視するには signal を使うと良いでしょう。
以下は ref の要素を setter で signal に渡す実装です。
...
@Component({
selector: 'app-root',
standalone: true,
template: `
@if(data()) {
<div #ref>INNER</div>
}
`,
})
export class App implements OnInit {
readonly data = signal<string | null>(null);
readonly ref = signal<ElementRef<HTMLDivElement> | undefined>(undefined);
@ViewChild('ref')
set ref(val: ElementRef) {
this.ref.set(val); // ViewChild が差分検知したタイミングで signal をアップデートする
}
constructor() {
// ref の値を監視し、値が取得できたタイミングで `#ref` を参照する
effect(() => {
const ref = this.ref();
if (!ref) {
return;
}
console.log(`fetched text: ${ref.nativeElement.innerText}`);
});
}
ngOnInit() {
this.#fetchData();
}
#fetchData() {
setTimeout(() => {
this.data.set('fetched');
}, 500);
}
}
このように ViewChild と signal の変更検知の仕組みを使うと、 DOM が変更されたタイミングで取得することができます。
signal query を利用する方法
上での例では、setter と signal を用いて実装を行いましたが、その発展でもう1つ方法があります。
Angular v17 で強化された signal の機能である signal query を使う方法です。
これを利用することで setter や専用の signal を別で用意する必要がなくなるので、少しスマートに実装できます。
変更箇所だけ抜粋して実装を載せておきます。
...
export class App implements OnInit {
readonly data = signal<string | null>(null);
readonly ref = viewChild<ElementRef<HTMLDivElement> | undefined>('ref');
constructor() {
effect(() => {
const ref = this.ref();
if (!ref) {
return;
}
console.log(`fetched text: ${ref.nativeElement.innerText}`);
});
}
...
}
注意点として、まだ singla query は developer preview なので、破壊的な変更が deprecated のステップを踏まずに入る可能性があります。
商用利用できるようになれば、積極的に使っていきたい機能です。
まとめ
非同期処理後に動的に生成される DOM にアクセスする方法を紹介しました。
Angular に限らず、フロントエンドのフレームワークはライフサイクルや変更検知の仕組みを理解していくことで、技術の選択肢の幅が広がると思います。
また、今後は signal を使って実装していくことが主流になると思われるので、機能追加やアップデートにも注目したいですね。
Discussion