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

Gerald Monaco
Gerald Monaco

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

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

ただし、手動による DOM の操作やアクセスがすべて問題になるわけではなく、必要な場合もあります。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 に 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 の読み取りまたは書き込みが必要になる場合があります。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 サーバーサイド レンダリングには今後、多くの新機能が導入される予定です。上記のヒントが、アプリケーションやライブラリでこれらの機能を最大限に活用する際に役立てば幸いです。