🪐

非同期処理後に動的に生成される DOM にアクセスする

2024/05/18に公開

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 の変更を検知するたびにプロパティを更新します。

https://angular.jp/api/core/ViewChild#description

つまり今回の例だと #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 を使う方法です。

https://angular.dev/guide/signals/queries#query-declarations-functions-and-the-associated-rules

これを利用することで 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