【Angular】Angular17のSSRでクライアントとサーバーで同じ処理を2回実行しないようにする

 前回はAngular17でSSRのプロジェクトを作成しましたが、CSRと同じ感覚でコードを書くとクライアント側とサーバー側で同じ処理を実行してしまいます。今回はそれを回避する方法を解説していきます。

 ソースは前回の記事のものを流用します。

環境

  • node: 18.20.0
  • Angular CLI:17.0.0

何も考えずにタイムスタンプを出力してみる

 まずは何も考えず、 page-b に現在のタイムスタンプを出力するコードを書いてみます。

import { Component } from '@angular/core';

@Component({
  selector: 'app-page-b',
  standalone: true,
  imports: [],
  templateUrl: './page-b.component.html',
  styleUrl: './page-b.component.css'
})
export class PageBComponent {

  timestamp: string = '';     // 追加

  constructor() {             // 追加
    this.timestamp = (Date.now()).toString();
  }
}
<p>page-b works!</p>
<p>{{ timestamp }}</p>

確認

 このコードを実行すると画面上にタイムスタンプが表示されます。このタイムスタンプはページを遷移する毎に更新されます。

 また、このタイムスタンプはpage-b の「ページのソースを表示」で表示されるソースに記載されているタイムスタンプとは異なります。「ページのソースを表示」で記載されているタイムスタンプはブラウザを更新しない限りは、ページを遷移しても更新されません(今回の場合は「1727493001775」で固定)。

<body class="mat-typography"><!--nghm-->
  <app-root _nghost-ng-c2754073592="" ng-version="17.3.12" ngh="1" ng-server-context="ssr"><nav _ngcontent-ng-c2754073592="" mat-tab-nav-bar="" class="mat-mdc-tab-nav-bar mat-mdc-tab-header mat-mdc-tab-nav-bar-stretch-tabs mat-primary _mat-animation-noopable" ng-reflect-tab-panel="[object Object]" role="tablist" ngh="0"><button aria-hidden="true" type="button" mat-ripple="" tabindex="-1" class="mat-ripple mat-mdc-tab-header-pagination mat-mdc-tab-header-pagination-before mat-mdc-tab-header-pagination-disabled" ng-reflect-disabled="true" disabled=""><div class="mat-mdc-tab-header-pagination-chevron"></div></button><div class="mat-mdc-tab-link-container"><div class="mat-mdc-tab-list" style="transform: translateX(0px);"><div class="mat-mdc-tab-links"><a _ngcontent-ng-c2754073592="" mat-tab-link="" routerlink="/page-a" class="mdc-tab mat-mdc-tab-link mat-mdc-focus-indicator" ng-reflect-router-link="/page-a" href="/page-a" aria-controls="mat-tab-nav-panel-2" aria-disabled="false" aria-selected="false" id="mat-tab-link-0" tabindex="-1" role="tab" ngh="0"><span class="mdc-tab__ripple"></span><div mat-ripple="" class="mat-ripple mat-mdc-tab-ripple" ng-reflect-trigger="[object Object]" ng-reflect-disabled="false"></div><span class="mdc-tab__content"><span class="mdc-tab__text-label">page-a</span></span><span class="mdc-tab-indicator"><span class="mdc-tab-indicator__content mdc-tab-indicator__content--underline"></span></span></a><a _ngcontent-ng-c2754073592="" mat-tab-link="" routerlink="/page-b" class="mdc-tab mat-mdc-tab-link mat-mdc-focus-indicator" ng-reflect-router-link="/page-b" href="/page-b" aria-controls="mat-tab-nav-panel-2" aria-disabled="false" aria-selected="false" id="mat-tab-link-1" tabindex="-1" role="tab" ngh="0"><span class="mdc-tab__ripple"></span><div mat-ripple="" class="mat-ripple mat-mdc-tab-ripple" ng-reflect-trigger="[object Object]" ng-reflect-disabled="false"></div><span class="mdc-tab__content"><span class="mdc-tab__text-label">page-b</span></span><span class="mdc-tab-indicator"><span class="mdc-tab-indicator__content mdc-tab-indicator__content--underline"></span></span></a></div></div></div><button aria-hidden="true" type="button" mat-ripple="" tabindex="-1" class="mat-ripple mat-mdc-tab-header-pagination mat-mdc-tab-header-pagination-after mat-mdc-tab-header-pagination-disabled" ng-reflect-disabled="true" disabled=""><div class="mat-mdc-tab-header-pagination-chevron"></div></button></nav><mat-tab-nav-panel _ngcontent-ng-c2754073592="" role="tabpanel" class="mat-mdc-tab-nav-panel" id="mat-tab-nav-panel-2" ngh="0"><router-outlet _ngcontent-ng-c2754073592=""></router-outlet><app-page-b _nghost-ng-c711978552="" ngh="0"><p _ngcontent-ng-c711978552="">page-b works!</p><p _ngcontent-ng-c711978552="">1727493001775</p></app-page-b><!--container--></mat-tab-nav-panel></app-root>
<script src="polyfills.js" type="module"></script><script src="main.js" type="module"></script>

<script id="ng-state" type="application/json">{"__nghData__":[{},{"n":{"1":"0fnf3","2":"1fn2f2","4":"3fn2f2","7":"5f"},"c":{"7":[{"i":"c711978552","r":1}]}}]}</script></body>

 これは、「ページのソースを表示」で表示されているタイムスタンプはサーバー側で初回アクセス時に取得したタイムスタンプが記載されているのに対し、画面上に表示されているタイムスタンプは画面に遷移した際にクライアント側で1回1回タイムスタンプを取得しているからです。

 このままだとせっかくSSRを行っているのに、サーバーとクライアントで二重処理を行っている状態になってしまいます。今回はタイムスタンプを取得しているだけですが、APIを投げて取得したデータを加工して・・・という処理を二重に処理してしまい、結局表示に時間がかかってSSRのメリットがなくなってしまいます。

解決方法

 これを解決するためには、 PLATFORM_ID でサーバー側で処理しているのかクライアント側で処理しているのかを判定し、サーバー側のみでタイムスタンプを取得します。そして、サーバー側で取得したタイムスタンプは TransferState を使ってクライアント側へ渡す、という方法使います。

import { Component, PLATFORM_ID, Inject, TransferState, makeStateKey } from '@angular/core';  // 更新
import { isPlatformBrowser, isPlatformServer } from '@angular/common';                        // 追加

const SERVER_DATA = makeStateKey<string>('ServerData');  // 追加

@Component({
  selector: 'app-page-b',
  standalone: true,
  imports: [],
  templateUrl: './page-b.component.html',
  styleUrl: './page-b.component.css'
})
export class PageBComponent {

  timestamp: string = '';

  constructor( // 変更
    @Inject(PLATFORM_ID) private platformId: Object,
    private readonly transferState: TransferState
  ) {
    if (isPlatformBrowser(this.platformId)) {
      // client only
      this.timestamp = this.transferState.get(SERVER_DATA, '');
    }
    if (isPlatformServer(this.platformId)) {
      // server only
      this.timestamp = (Date.now()).toString();
      this.transferState.set(SERVER_DATA, this.timestamp);
    }
  }
}

 PLATFORM_ID は公式サイトでは下記のように説明されています。

A token that indicates an opaque platform ID.

https://angular.jp/api/core/PLATFORM_ID

 直訳すると「不透明なプラットフォームIDを示すトークン」となります。直訳だとわかりにくいですが、実行される環境によって返す値が違うので「不透明な」「曖昧な」プラットフォームID、ということだと思います。PLATFORM_ID はクライアント(ブラウザ)の場合は「browser」、サーバーの場合は「server」を返します。この PLATFORM_IDisPlatformBrowser()isPlatformServer() の引数に使うことでサーバー側で処理しているのか、クライアント側で処理しているのかを判定することができます。

 サーバー側で取得した値をクライアント側に渡すのに TransferState を使用します。

A key value store that is transferred from the application on the server side to the application on the client side.

https://angular.jp/api/core/TransferState

 簡単に説明すると、サーバー側からクライアント側へ値を渡すためのキーバリューストア、ってことになると思います。私はそういう認識です。サーバー側で取得したタイムスタンプを transferState.set() で保存、クライアント側で transferState.get() を使って取得します。

確認

 今回は何回ページ遷移をしても画面上のタイムスタンプは一定で、「ページのソースを表示」で確認できる値と一致することから、サーバー側で取得したタイムスタンプをきちんとクライアント側で取得できていることがわかります。

 SSRを使用する場合は、無意味にサーバーとクライアント側で同じ処理をしないように気をつけましょう。

コメント

タイトルとURLをコピーしました