Angular SSR を使用して DOM に安全にアクセスする

Gerald Monaco
Gerald Monaco

昨年、Angular には、デベロッパーが Core Web Vitals を改善し、エンドユーザーに優れたエクスペリエンスを提供できるようにする、水分補給遅延ビューなどの多くの新機能が追加されました。また、この機能に基づくサーバーサイド レンダリング関連の追加機能(ストリーミングや部分的なハイドレーションなど)の研究も進められています。

残念なことに、基盤となる DOM 構造を手動で操作すると、アプリケーションやライブラリが新機能や今後追加される機能を十分に活用できなくなる可能性があります。Angular では、サーバーがコンポーネントをシリアル化してから、ブラウザでハイドレートされるまで、DOM の構造に一貫性を持たせる必要があります。ハイドレーションの前に ElementRefRenderer2、または DOM の API を使用して、DOM でノードを手動で追加、移動、削除すると、不整合が発生し、これらの機能が動作しなくなる可能性があります。

ただし、手動による DOM の操作やアクセスがすべて問題になるわけではなく、必要な場合もあります。DOM を安全に使用するための鍵は、必要性をできる限り最小限に抑え、その使用をできる限り延期することです。次のガイドラインでは、この目標を達成し、Angular の新しい機能と今後の機能をすべて活用できる、真に汎用性の高い将来性のある Angular コンポーネントを作成する方法について説明します。

手動による DOM 操作の回避

手動で DOM を操作する際に発生する問題を回避する最善の方法は、可能な限り手動操作を避けることです。Angular には、DOM のほとんどの側面を操作できる API とパターンが組み込まれています。DOM に直接アクセスするのではなく、これらの API とパターンを使用することをおすすめします。

コンポーネント独自の DOM 要素を変更する

コンポーネントまたはディレクティブを作成するときに、ラッパー要素をターゲットに設定したり導入したりするのではなく、クラス、スタイル、属性を追加するなど、ホスト要素(コンポーネントまたはディレクティブのセレクタに一致する DOM 要素)を変更する必要がある場合があります。ElementRef を使用して基になる DOM 要素を変更したくなりますが、代わりに、ホスト バインディングを使用して、値を式に宣言的にバインドする必要があります。

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

HTML のデータ バインディングと同様に、属性やスタイルにバインディングしたり、'true' を別の式に変更して、Angular が必要に応じて値を自動的に追加または削除したりすることもできます。

場合によっては、キーを動的に計算する必要があります。値のセットまたはマップを返すシグナルまたは関数にバインドすることもできます。

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

より複雑なアプリケーションでは、ExpressionChangedAfterItHasBeenCheckedError を回避するために手動で DOM 操作を行ったくなるかもしれません。代わりに、前の例のように値をシグナルにバインドできます。これは必要に応じて行えます。コードベース全体にシグナルを適用する必要はありません。

テンプレートの外部で DOM 要素を変更する

DOM を使用して、他の親コンポーネントや子コンポーネントに属するものなど、通常はアクセスできない要素にアクセスしようとしてしまいがちです。ただし、これはエラーが発生しやすく、カプセル化に違反し、将来的にコンポーネントの変更やアップグレードが難しくなります。

代わりに、他のすべてのコンポーネントをブラックボックスとして扱う必要があります。他のコンポーネント(同じアプリやライブラリ内であっても)がコンポーネントの動作や外観を操作またはカスタマイズする必要があるタイミングと場所を検討し、それを行うための安全で文書化された方法を公開します。単純な @Input プロパティや @Output プロパティでは不十分な場合は、階層型依存関係インジェクションなどの機能を使用して、サブツリーで API を利用できるようにします。

従来、モーダル ダイアログやツールチップなどの機能を実装するには、<body> などのホスト要素の末尾に要素を追加し、そこにコンテンツを移動または投影していました。しかし最近は、テンプレートにシンプルな <dialog> 要素をレンダリングできるかもしれません。

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

手動による DOM 操作の遅延

前述のガイドラインに沿って DOM の直接操作とアクセスを可能な限り最小限に抑えた後も、回避できない操作やアクセスが残っている場合があります。このような場合は、できるだけ遅らせることが重要です。そのためには、afterRender コールバックと afterNextRender コールバックが便利です。これらのコールバックは、Angular が変更をチェックし、DOM にコミットした後に、ブラウザでのみ実行されます。

ブラウザ専用の JavaScript を実行する

ブラウザでのみ動作するライブラリや API がある場合があります(グラフ ライブラリ、一部の IntersectionObserver の使用など)。ブラウザで実行されているかどうかを条件付きでチェックしたり、サーバー上での動作をスタブしたりする代わりに、afterNextRender を使用できます。

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

カスタム レイアウトを実行する

ツールチップの配置など、ターゲット ブラウザでまだサポートされていないレイアウトを実行するために、DOM の読み取りまたは書き込みが必要になることがあります。この場合は、DOM が一貫した状態であることを確認できる afterRender が最適です。afterRenderafterNextRender は、EarlyReadReadWritephase 値を受け入れます。DOM レイアウトを書き込んだ後に読み取ると、ブラウザはレイアウトを同期的に再計算します。これにより、パフォーマンスに重大な影響が生じる可能性があります(レイアウト スラッシングを参照)。そのため、ロジックを適切なフェーズに慎重に分割することが重要です。

たとえば、ツールチップ コンポーネントでページ上の別の要素に対してツールチップを表示する場合は、2 つのフェーズを使用します。EarlyRead フェーズでは、まず要素のサイズと位置を取得します。

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

次に、Write フェーズで、以前に読み取った値を使用して、ツールチップの位置を実際に変更します。

afterRender(() => {
    tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
    tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
  }, { phase: AfterRenderPhase.Write },
);

ロジックを適切なフェーズに分割することで、Angular はアプリケーション内の他のすべてのコンポーネントで DOM 操作を効果的にバッチ処理し、パフォーマンスへの影響を最小限に抑えることができます。

まとめ

ユーザーに優れたエクスペリエンスを簡単に提供できるようにすることを目標に、Angular サーバーサイド レンダリングには今後、多くの新機能が導入される予定です。上記のヒントが、アプリケーションやライブラリでこれらの機能を最大限に活用する際に役立つことを願っています。