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

Gerald Monaco
Gerald Monaco

この 1 年間、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 にコミットした後に、ブラウザでのみ実行されます。

ブラウザ専用の 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 には、EarlyReadRead、または Writephase 値を指定できます。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 のサーバーサイド レンダリングには、優れたユーザー エクスペリエンスを簡単に提供できるよう、多くの魅力的な改善が加えられる予定です。ここまでご紹介したヒントが、アプリケーションやライブラリで最大限に活用される一助となれば幸いです。