LoginSignup
4

More than 3 years have passed since last update.

AngularUniversalでクライアントとサーバで2回APIを実行しないようにする

Last updated at Posted at 2019-11-02

AngularUniversalでSSRを行った際に、レンダリングをサーバ・クライアントそれぞれで実行してしまうことがわかりました。
そのままだとせっかくSSRを行っているのに、二重処理を行っている状態なのでクライアント側に表示されてから実際に操作可能な状態になるまでに時間がかかってしまいユーザーにストレスを与えてしまいます。

これを改善する方法が今回の内容です。


SSR補足

念のためSSR(Server Side Rendering)について補足しておきます。
すでに理解している方は飛ばして問題ありません。

React・Vue・AngularなどのSPAフレームワークが流行る前、JSPやPHPなどでサーバサイドもフロントも書くような場合には、リクエストが発生する度に(ページ遷移ごとに)サーバである程度HTMLを生成してクライアント側に返却していました。

これがSPA化されると、表示されている画面を使い回しながら必要な部分だけを更新していくので、ユーザーにとってはスムーズに画面が更新され、ストレスが軽減されるメリットがあります。

反面、画面の初期表示という観点においてはデメリットもあります。
最初にほぼ真っ白な画面が表示されてそこからクライアント側で画面を構築していくため、端末の性能によっては表示に時間がかかったり、クローラがHTMLの内容を認識してくれないなどの問題があります。(GoogleBotはJSを認識するためほぼ問題ないと言われていますが、他の多くのクローラは認識できません)

そこでSSRの登場です。

SSRではレンダリング処理をサーバサイドで行います。
これを利用し、画面の初期表示をサーバサイドで行うことで、SPAの初期表示に関するデメリットを解消します。

なのでSSRはSPAが前提で考えた方が理解しやすいです。
JSPとか利用したことがあるとSSRなんて当たり前じゃん。何がメリットなの?って混乱してしまうのですが(私は混乱したので)、上記前提であれば理解できると思います。

AngularUniversalはSSR/CSRをそれぞれ実行する

では事象の説明です。
初期表示時にAPIをリクエストし、その結果を画面に表示するようなケースを再現します。

今回は NestJSでAngularUniversalを導入する でSSRが動く状態になったものをベースに再現してみます。
※ExpressでのUniversalも同様の方法で実現可能です。

src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    HttpClientModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

クライアント側の実装

src/app/app.components.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  title:string;

  constructor(private http: HttpClient) {}

  ngOnInit() {
    console.log('ngOnInit Start');
    this.http.get('http://localhost:4200/api/cats', { responseType: 'text' }).subscribe((data) => {
      this.title = data;
    });
  }
}

API側の実装

server/src/cats/cats.controller.ts
import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    console.log('API called')
    return 'This action returns all cats';
  }
}

では実行してみます。

$ npm run serve

中略

[nodemon] starting `node dist/server-app/main.js`
[Nest] 2162   - 2019-11-02 11:13:53   [NestFactory] Starting Nest application...
[Nest] 2162   - 2019-11-02 11:13:53   [InstanceLoader] AngularUniversalModule dependencies initialized +16ms
[Nest] 2162   - 2019-11-02 11:13:53   [InstanceLoader] ApplicationModule dependencies initialized +1ms
[Nest] 2162   - 2019-11-02 11:13:53   [RoutesResolver] CatsController {/api/cats}: +10ms
[Nest] 2162   - 2019-11-02 11:13:53   [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 2162   - 2019-11-02 11:13:53   [NestApplication] Nest application successfully started +2ms

localhost:4200にアクセスします。
以下はChormeでの表示結果です。

実行結果.png

こちらがサーバ側の実行結果になります。

サーバ実行結果
ngOnInit Start
API called
API called

サーバ側・クライアント側それぞれに ngOnInit Start が表示されています。
つまり、どちらでもレンダリングが実行されているということです。
そのためAPIも2回呼び出されていることがわかります。

これが今回のテーマに関する事象になります。

Nuxt.jsではどうか

参考までにですが、Nuxt.jsを利用したSSRの場合はどうなるかについて補足しておきます。
コードになります。

pages/index.vue
<template>
  <div>
    <span>{{ title }} nuxt-app is running!</span>
  </div>
</template>

<script>
export default {
  data() {
    console.log('data Start')
    return {
      title: 'Nuxt SSR!'
    }
  }
}
</script>

クライアントの実行結果です。

SSR実行結果1.png

Nuxt.jsを利用した場合は、SSRの実行結果がクライアント上にも表示されるため、Chromeだけで確認可能です。

上記の画面の通り、data Startが2つ表示されているため、AngularUniversalと同様にサーバ・クライアント両方で処理が実行されていることがわかります。

これを対策するには、メソッド名をdataからasyncDataに変更します。

pages/index.vue(修正後抜粋)
export default {
  asyncData() {
    console.log('data Start')
    return {
      title: 'Nuxt SSR!'
    }
  }
}

結果です。

SSR実行結果2.png

data Startが1つのみ表示され、またNext SSRの配下に表示されているため、クライアント側でレンダリング処理が実行されていないことがわかります。
このようにNuxt.JSではメソッド名を変更するだけで今回の対策が可能です。

解決方法

TransferHttpCacheModule というものを利用して解決します。
このモジュールはサーバ側でレンダリングする際に発生したGETリクエストの結果を保持しておいて、クライアント側のレンダリングの際には保持しておいた結果を利用して再リクエストさせないということを実現してくれます。

まずは以下パッケージをインストールしてください。

$ npm install @nguniversal/common

ここからは設定になります。

まずは app.module.ts ファイルに TransferHttpCacheModule モジュールをインポートさせます。

src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';

import {TransferHttpCacheModule} from '@nguniversal/common';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    TransferHttpCacheModule,
    HttpClientModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

次に app.server.module.ts ファイルに ServerTransferStateModule モジュールをインポートさせます。

src/app/app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ServerTransferStateModule,
    ModuleMapLoaderModule,
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

最後に、main.ts ファイルが 以下のようになっていればOKです。

src/main.ts(抜粋)
document.addEventListener('DOMContentLoaded', () => {
  platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));
});

なんとこれだけでOKです。

実行してみます。

まずはクライアント側の表示結果です。
先ほどと同様に ngOnInit Start が表示されています。
つまりクライアント側でも ngOnInit が実行されレンダリングされているということです。

実行結果.png

次にサーバ側の実行結果になります。
今回は API called は1つしか表示されていません。
つまり、サーバ側だけでしかAPIを呼び出していないということです。

サーバ実行結果
ngOnInit Start
API called

このように TransferHttpCacheModule を利用することによって、APIのリクエストを1回にすることが出来ました。
レンダリング処理の中で、一番時間がかかるのはAPIの実行だと思うので、ほとんどの場合は上記の対応だけで良いはずです。

が、上記はHttpClientを利用したAPI呼び出ししか保持してくれないので、axiosを利用したAPI呼び出しや、そもそも処理に時間がかかるようなクライアントの処理は別途対応する必要があります。

TransferStateを利用してSSR実行結果をクライアントに渡す

HttpClient以外にサーバ側で生成したデータをクライアント側に渡すには、TransferState を利用します。

まずは app.module.ts ファイルに BrowserTransferStateModule モジュールをインポートさせます。

src/app/app.module.ts
import { BrowserModule, BrowserTransferStateModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';

import {TransferHttpCacheModule} from '@nguniversal/common';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    TransferHttpCacheModule,
    HttpClientModule,
    BrowserTransferStateModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

これでTransferStateを利用可能になりました。

この後はSSR実行結果をクライアント側に渡したいコンポーネントでTransferStateを利用するだけです。

src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferState, makeStateKey } from '@angular/platform-browser';

export const BODY_KEY = makeStateKey<string>('BODY_KEY');

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  title:string;
  body:string;

  constructor(private http: HttpClient, private transferState: TransferState) {}

  ngOnInit() {
    console.log('ngOnInit Start');
    this.http.get('http://localhost:4200/api/cats', { responseType: 'text' }).subscribe((data) => {
      this.title = data;
    });

    const bodyFunction = () => {
      // 5秒waitする
      new Promise((resolve, reject) => {
        console.log('5sec wait')
        setTimeout(() => {
            resolve();
        }, 5000);
      });
      return 'This is Body';
    }
    if (this.transferState.hasKey(BODY_KEY)) {
      this.body = this.transferState.get<string>(BODY_KEY, null);
    } else {
      const body = bodyFunction();
      this.transferState.set<string>(BODY_KEY, body);
      this.body = body;
    }
  }
}

bodyFunction が5秒かかる処理になっています。
TransferStateを利用しないとサーバでもクライアントでもそれぞれ5秒かかるため、ユーザーが操作可能になるまで合計10秒かかる計算になります。

ポイントを以下で説明します。

まずはクライアント側に転送したいデータごとにStateKeyを生成します。
生成はmakeStateKeyメソッドを利用します。

StateKeyの生成
import { TransferState, makeStateKey } from '@angular/platform-browser';

export const BODY_KEY = makeStateKey<string>('BODY_KEY');

setメソッドでStateStoreにKeyとValueを設定します。

StateStoreに値を設定
this.transferState.set<string>(BODY_KEY, body);

hasKeyメソッドでStateStoreに値がセットされているか確認します。

StateStoreにセットされているか確認
if (this.transferState.hasKey(BODY_KEY)) {

getメソッドでStateStoreからKeyに紐づくValueを取得します。

StateStoreからKeyを利用して値(Value)を取得する
this.body = this.transferState.get<string>(BODY_KEY, null);

以上がAngularUniversalでのSSR・CSRの二重実行対策になります。

また参考までに以下のような方法で、処理の呼び出し(API呼び出し)を片方だけで実行させることも可能です。 
AngularUniversalでクライアントのみ(サーバのみ)実行させる方法

参考記事

Rendering on the Web - Web上のレンダリング
Angular Docs -> TransferState
SSR の知識ゼロから始める Angular Universal

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4