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も同様の方法で実現可能です。
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 { }
クライアント側の実装
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側の実装
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での表示結果です。
こちらがサーバ側の実行結果になります。
ngOnInit Start
API called
API called
サーバ側・クライアント側それぞれに ngOnInit Start
が表示されています。
つまり、どちらでもレンダリングが実行されているということです。
そのためAPIも2回呼び出されていることがわかります。
これが今回のテーマに関する事象になります。
Nuxt.jsではどうか
参考までにですが、Nuxt.jsを利用したSSRの場合はどうなるかについて補足しておきます。
コードになります。
<template>
<div>
<span>{{ title }} nuxt-app is running!</span>
</div>
</template>
<script>
export default {
data() {
console.log('data Start')
return {
title: 'Nuxt SSR!'
}
}
}
</script>
クライアントの実行結果です。
Nuxt.jsを利用した場合は、SSRの実行結果がクライアント上にも表示されるため、Chromeだけで確認可能です。
上記の画面の通り、data Start
が2つ表示されているため、AngularUniversalと同様にサーバ・クライアント両方で処理が実行されていることがわかります。
これを対策するには、メソッド名をdata
からasyncData
に変更します。
export default {
asyncData() {
console.log('data Start')
return {
title: 'Nuxt SSR!'
}
}
}
結果です。
data Start
が1つのみ表示され、またNext SSRの配下に表示されているため、クライアント側でレンダリング処理が実行されていないことがわかります。
このようにNuxt.JSではメソッド名を変更するだけで今回の対策が可能です。
解決方法
TransferHttpCacheModule
というものを利用して解決します。
このモジュールはサーバ側でレンダリングする際に発生したGETリクエストの結果を保持しておいて、クライアント側のレンダリングの際には保持しておいた結果を利用して再リクエストさせないということを実現してくれます。
まずは以下パッケージをインストールしてください。
$ npm install @nguniversal/common
ここからは設定になります。
まずは app.module.ts
ファイルに TransferHttpCacheModule
モジュールをインポートさせます。
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
モジュールをインポートさせます。
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です。
document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));
});
なんとこれだけでOKです。
実行してみます。
まずはクライアント側の表示結果です。
先ほどと同様に ngOnInit Start
が表示されています。
つまりクライアント側でも ngOnInit
が実行されレンダリングされているということです。
次にサーバ側の実行結果になります。
今回は API called
は1つしか表示されていません。
つまり、サーバ側だけでしかAPIを呼び出していないということです。
ngOnInit Start
API called
このように TransferHttpCacheModule
を利用することによって、APIのリクエストを1回にすることが出来ました。
レンダリング処理の中で、一番時間がかかるのはAPIの実行だと思うので、ほとんどの場合は上記の対応だけで良いはずです。
が、上記はHttpClientを利用したAPI呼び出ししか保持してくれないので、axiosを利用したAPI呼び出しや、そもそも処理に時間がかかるようなクライアントの処理は別途対応する必要があります。
TransferStateを利用してSSR実行結果をクライアントに渡す
HttpClient以外にサーバ側で生成したデータをクライアント側に渡すには、TransferState
を利用します。
まずは app.module.ts
ファイルに BrowserTransferStateModule
モジュールをインポートさせます。
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を利用するだけです。
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
メソッドを利用します。
import { TransferState, makeStateKey } from '@angular/platform-browser';
export const BODY_KEY = makeStateKey<string>('BODY_KEY');
set
メソッドでStateStoreにKeyとValueを設定します。
this.transferState.set<string>(BODY_KEY, body);
hasKey
メソッドでStateStoreに値がセットされているか確認します。
if (this.transferState.hasKey(BODY_KEY)) {
get
メソッドで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