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

ジェラルド モナコ
Gerald Monaco

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

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

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

手動による DOM 操作を避ける

手動による DOM 操作に起因する問題を回避する最善の方法は、当然のことながら、そのような問題を完全に回避することです。Angular には、DOM のほとんどの部分を操作できる API とパターンが組み込まれているため、DOM に直接アクセスするのではなく、これらを使用することをおすすめします。

コンポーネント独自の 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 に commit した後、ブラウザでのみ実行されるので、これを行う優れた方法です。

ブラウザのみの 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 に対する読み書きが必要になることがあります。afterRender は、DOM の一貫した状態を確実に保持できるため、最適な選択肢です。afterRenderafterNextRender は、phase の値として EarlyReadRead、または Write を受け入れます。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 のサーバーサイド レンダリングには多数の改良が加えられており、優れたユーザー エクスペリエンスを実現しやすくなることを目標としています。これまでのヒントが、アプリケーションやライブラリでツールを最大限に活用する際にお役に立てば幸いです。