LoginSignup
196
141

More than 5 years have passed since last update.

SSR の知識ゼロから始める Angular Universal

Last updated at Posted at 2018-12-03

はじめに

これは Angular Advent Calendar 2018 4日目の記事です。

こんにちは (。・ω・。)
Angular で CGM サービスを運用・構築したり、ng-japan の slack で emoji を追加することを生業としている者です。(コミュニケーションの場は本格的にspectrum へ移行することが決定したため emoji 業者としての活動は終わりになりそうです;;)


自分は今年の中旬くらいから担当している Angular プロジェクトを SSR 化していたのですが、実践的な流れを網羅する情報が存在せず非常に苦労しました。
今回はその経験を生かして Angular の Universal 化に関する実践的なまとめを作成することにします。

この記事は SSR の知識 0 の方でも読み進められるように大きく分けて

の 3 部構成となっています。
SSR を導入するか迷っている方は最初から、すでに SSR の知識を持っている方は #Angular での構築・実装 から読んでいただければ幸いです。

#SSR を導入した結果

いきなり結果から書きます!
そもそも物事を行った結果に満足できないのであれば、その過程を学ぶ意味が薄くなります。
結果がわからないまま長文を読み進め、最後に落胆してしまっては時間の無駄となってしまいますね。
そのため最初に実際に導入した結果を見て、本当に SPA × SSR で良いのか、他の選択肢はないのか?という点を考えていただく構成としました。

特に効果が現れた指標

LightHouse の改善

FCP、FMP が大きく改善。Performance のスコアが緑に乗りました。
ちなみに SSR 前は 20 程でした...(小声

スクリーンショット 2018-12-04 7.33.11.png

Index 数の増加

SSR 導入以前も Fetch as Google で認識され、インデックスもされていました。しかし SSR 導入後は比較にならないスピードで反映され始めています。
ただし、この結果はサイトによって様々だと考えられます。

今回の例では、もともとのパフォーマンスが悪かったため改善率が高かった可能性があり、現状で充分なパフォーマンスを示せているのであれば SSR は必要ないかもしれません。

※ 実数値は伏せます

スクリーンショット 2018-12-02 19.31.43.png

検索流入の増加

Index の増加から少し遅れて検索流入数も増え始めました。

スクリーンショット 2018-11-30 19.05.31.png

サーチコンソールのエラー減少

SPA のインデックスは SSR をしなくてもパフォーマンス次第で達成可能です。
しかし、サーチコンソールのエラーだけは SSR を導入しないと解消が難しいものあります。
SSR 導入以前に対処不能で増え続けていたサーチコンソール上のエラーは減り続け、日に日に 0 へ近づいています。

スクリーンショット 2018-12-02 18.11.59.png

新規ユーザーの直帰率改善

Bot に対しての数値的効果は見えましたが、肝心のユーザーに対して目に見える効果はほとんどありませんでした。
これは SSR がユーザーに対して効果を及ぼす範囲が、実質的に新規ユーザーの 1 ページ目だけだからだと考えられます。
(2 ページ目以降は、SPA。二回目以降は ServiceWorker からページを取得します)
そう考えると効果がある指標は新規ユーザーの直帰率がメインでしょうか・・・?
実際この指標に関しては、SSR 導入後からじわじわ減り始めました。

スクリーンショット 2018-12-02 18.33.44.png

このサービスの規模感

こんな効果があったよ!と言われてもそのサービスの規模感がわからなければなんとも言えません。
1 ⇢ 2 と 100 ⇢ 200 の難易度は全然違いますから。

今回例に出したサービスは現在月間 4,000 万PV程の規模で、約 1 年前にリリースしたものになります。
主な使用技術は Angular + ngrx + firestore + gcp です。
エンジニアは自分 1 人で、デザイナーが 2 人、ディレクターが 3,4 人というチーム構成でやっています。

#SSR についての基礎知識

サーバーサイドレンダリング(SSR)とは、その名の通りサーバー側でアプリケーションの HTML を生成し、レスポンスとして返すことを言います。

一般的に利用されている MPA(Multiple Page Application)では言うまでもなく行われていることなので、SSR というワードは自ずと SPA(Single Page Application)を構築する際のオプション機能を指します。
オプションという言葉を用いた理由は、SSR が SPA を構築する際の必須項目ではないからです。
次節からその理由を説明していきます。

SSR の目的

SSR を行う目的は、Web アプリケーションを SPA にすることと引き換えに失った

① 初回描画速度(First Meaningful Paint => FMP)
② ページごとの静的HTML

を取り戻すことです。

image.png

参照:Next.js on Cloud Functions for Firebase with Firebase Hosting

初回描画速度について

SPA は初回アクセス時に全ページの描画に必要な JavaScript ファイルをダウンロードします。
そしてページを切り替え時に対象ページの描画に必要な部分だけを取得・更新することで、非常に快適な操作性を実現しました。
しかし初アクセス時のリソースファイル量及び初期化処理も重くなってしまい、初回描画速度低下につながります。

とある研究結果 によると、ページの表示に 3 秒以上かかるサイトではモバイルユーザーの 53% が離脱するようです。
中規模以上の SPA の場合、アプリケーションの立ち上げに数秒程度を要してしまうため、サービスの規模が大きくなるに連れて、この問題は無視できないものになっていきます。

ページごとの静的HTMLについて

SPA の各ページへアクセスした際に、レスポンスとして返ってくる HTML は常に index.html です。
この index.html には共通のソースコードが記述されており、アクセスしたページによって差はありません。
SPA では index.html 内の初期化処理で現在自分がいる URL を把握し、そのページに対応したコンテンツを動的に生成するからです。

この処理に関してユーザーへの影響はありませんが、サイトを確認しに来たクローラーにとっては大問題です。
検索エンジン以外のクローラーは JavaScript を実行できず、常に index.html の内容を認識してしまいます。

もう少し具体的に言うと、Fetch as Google に関しては問題ありません が、Twitter や FaceBook、LINE、Slack 等のビジュアライザーが正常に機能しません。
2018 年現在 SNS からの流入が無視できない規模になっている現状を考えると、どうしても最低限の SSR は必要だと考えられます。

逆に Twitter 等のクローラーがアップデートされ、JavaScript を正常に実行してくれるようになりさえすれば、SEO 的観点からの SSR は ほぼ不要 となるかもしれません。

様々な種類のレンダリングアプローチ

SSR の情報収集を始めていくと、SSR にもいくつかの種類があることがわかります。
今回はそれらのレンダリングアプローチのどれを使用すればよいのかという点に絞ってフローチャートにまとめてみました。

Which Rendering-Approach is better_ (13).png

※ この記事を書いている最中に、SPA の SEO に関する 素晴らしい 記事が公開されました。同様のフローチャートも掲載されているので合わせてご覧いただくとより情報の精度が高まると思います。
参照:【記事版】State of SEO for SPA 2018

各アプローチの補足・解説

※ TTFB のマイナス評価(✕)はCDNキャッシュ等を適切に利用することで緩和できます。

CSR ( Client Side Rendering )

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive)

クライアント ( ブラウザ上 ) 側でパスに対応したコンテンツを動的に生成する方式。

ホスティングサーバーは常に index.html を返し、同一の JavaScript が現在の URL からコンテンツを出し分けます。
サーバー側の処理がないため、当然 TTFB は早くなります。

App Shell(一部 Pre Rendering)

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )

ネイティブ アプリのように瞬時に、そしてネットワーク環境に依存せず確実に読み込めるPWA を構築するアーキテクチャの一つ。
これがどういうものかというと、まずアプリケーションを構築する静的なパーツであるナビゲーション等を予めレンダリングして index.html に組み込んでおきます。(表現方法を変えると、アプリケーションの一部分をプリレンダリングしているということです)
そしてローカルキャッシュからそれらの要素を瞬時に表示し、ユーザーの意識を引きつけた後にコンテンツの取得・表示を行います。
その結果、ユーザーはアプリケーションが瞬時に応答したと認識し体感レスポンスが向上します。
また、コンテンツ部分のオフライン対応をすると index.html が取得できない電波状況でもアプリケーションを起動することができます。

image.png

参照:App Shell モデル | Web | Google Developers

Angular であれば、CLI の generate app-shell を利用することですぐに導入できます。
初めてこの概念を知って試してみた際、上記手順になぜサーバー側のアプリケーションモジュールが必要か疑問に思ったのですが、App Shell モデルがプリレンダリングの一種だと認識することで解消されました。
(;・∀・)

参照:stories app shell - angular/angular-cli Wiki

Pre Rendering

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )

最強(ただし静的で小規模なコンテンツに限る)

事前に URL のリストから各ページのレンダリング処理を行い、その結果を html として保存しておく方式。
静的なファイル配信のため TTFB も早く、完全にレンダリングされたページをすぐに描画できるため FCP、FMP、TTI も早い。

レンダリング方法を決定する上で、コンテンツが動的か否かという点は非常に重要です。
コンテンツが会社概要等の静的なページ、または運営側が少数の記事を追加する程度であれば動的な SSR は実装する必要がないからです。
そういったページしか必要のないプロジェクトであれば、ローカルや CI ツール上で全てのページを事前にレンダリング(プリレンダリング)することで、静的な HTML を配信できます。
この方法はサーバー側の処理が一切必要ないため、ホスティングコストがほとんど発生しませんし、配信速度もこれ以上なく高速です。
その上プロジェクトの SSR 対応を行う必要がないため、ソースコードの変更もメタタグ関連程度です。

プリレンダリングが採用可能なプロジェクトなのであれば、簡単かつ低コスト、それでいて高パフォーマンスなため、これを選ばない理由がありません。
プロジェクトの特性をよく確認し、今一度本当に SSR が必要か考えてください。

参照:Angular Universal on Google App Engine ※GAE成分控えめ
参照:https://github.com/Angular-RU/angular-universal-starter/blob/master/prerender.ts

Dynamic Rendering

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )
Client
Crawler ✕✕✕

Google、Yahoo、Bing 等の検索エンジンのクローラー及び Twitter や Facebook に代表される SNS のクローラーに対してはプリレンダリングした結果を返し、ユーザーのアクセスに対しては index.html を返す( CSR する )方式です。
クローラーとユーザーに返す結果が異なるとクローキングとみなされそうで心配ですが、最終的な結果が同じであれば問題ないようです。
(参照サイトではプリレンダリングではなくサーバーサイドレンダリングと記載してありましたが、SSR が実装済であればそれをそのまま返したほうが良いと思われるので、ここでいうサーバーサイドレンダリングとは Rendertron 等の外部レンダリングソリューションを介してプリレンダリングしたものと解釈しました。)

この方法を利用することで JavaScript を実行できない SNS のクローラーも動的なメタタグを認識できるようになり SPA で最も問題となる部分をクリアできます。
また、この方法は実装コストも低く一日あれば充分リリースまで視野に入るレベルです。

良いことづくめのように思えますが、やはり SSR 用にチューニングされたものと比べると速度面で大きく劣ります。
キャッシュが存在しない状態で状態でアクセスすると Lighthouse のパフォーマンススコアが 100 のページでさえ数秒程度かかるので、現実的なサイトでは 10 秒ほどかかってしまうかもしれません。
https://render-tron.appspot.com/ から試せます)

対象のページ数が少なければ新しいバージョンをリリースした際に順次キャッシュを作っていくという手法もとれるかもしれませんが、ページ数が多いとそれも難しくなります。
キャッシュ前に各種クローラーが見に来てしまうと正常に反映されない等の問題が生じてしまいますし、グーグルに低速なサイトと判断されてしまうかもしれません。
特に Twitter のクローラーはレスポンスを 10 秒までに返さないと離脱し、おそらく数日その結果がキャッシュされてしまうため注意が必要です。

Dynamic Rendering (1).png

Rendertron で SNS のクローラーだけに対応したいという場合は、index.html に window.renderComplete = false; を追記し、メタタグを設置し終えるタイミングで window.renderComplete = true; を実行すると最低限の処理で切り上げてくれます。

自分も上図の構成で SSR 対応するまでのつなぎとして利用していましたが、やはり速度面がネックでたまに Twitter のクローラーに相手にされないことがありました。
しかし実装コストがとても低いため、本格的な SSR を実装するまでのつなぎとして一時的に OGP 対応を任せても良いと思います。無いよりはマシですから。

参照:Angular SEO with Rendertron
参照:JSサイトのための第4のレンダリング構成としてダイナミックレンダリングをGoogleが発表 #io18 #io18jp

Hybrid Rendering

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )

MPA のレンダリング方法をわざわざ SSR とは表さないので、おそらく一般的にサーバーサイドレンダリングと呼んで頭に浮かぶのがこの Hybrid Rendering のフローかと思います。
Hybrid Rendering はリクエストされたページのレンダリング結果を index.html に埋め込んでクライアント側に返却し、別のページに遷移したりコンテンツを追加する場合は CSR を行う手法です。
SSR 知識ゼロの場合に意識してほしいのは、単純に SSR をして一見ページの表示が早くなったように見えてもその裏では CSR と同じ処理が動いているということです。
言い換えるとローディングアニメーション代わりにそのページのレンダリング結果を利用しているのです。

image.png

従って、ただ単に SSR しても TTI までの時間は変わりません。
それどころかページの描画処理や通信量が増えてしまうため遅くなる可能性すらあります。

この問題は後の節で解説するハイドレーションという工程を行うことで、ある程度緩和することができます。
また、SPA にページや処理を追加し続けていくと main.bundle の容量が凄いことになってしまうため、小規模サイトを除きページや機能ごとに bundle を分割して lazy load する実装も必須となってきます。

参照:Universally speaking | Craig Spence | AngularConnect 2018

SSR + App Shell

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )
CSR
SSR

② と ⑤ の合わせ技です。
初回に訪問したユーザー、またはクローラーに対しては SSR を行い完全な HTML を返します。
二回目以降に訪問したユーザーに対しては、初回にキャッシュした App Shell を表示し CSR を行います。

これにより、ユーザーとクローラーそれぞれに対しての挙動が最適化されます。

しかし TTI までの時間が最適化されてないとその分 FMP が遅れてしまうため、UX が悪化する可能性があります。場合によっては ⑤ のままの方が良いでしょう。

#Angular での構築・実装

ここからは 様々な種類のレンダリングアプローチ でいう ⑤ にあたる Hybrid Rendering を Angular フレームワーク上でどうやって実装していけば良いのか解説していきます。

ベースの環境構築

素振り

いきなり本番に入ると情報量が多すぎて収拾がつかなくなってしまうため、まずは最小限の構成で素振りをするのが無難かと思います。

素振りをするのに一番向いている記事は stories universal rendering - angular/angular-cli Wiki です。余計な文章が一切存在しないためゴールまでが非常に明快でオススメです。また、CLI ツールの Wiki は比較的メンテナンスされていてバージョン違いで陳腐化した記述が少ない印象です。

一旦コピペで動かしてみて、主要な要素をなんとなく理解しておくと後の作業が捗りやすくなるでしょう。

ベースプロジェクト選定

Universal プロジェクトのベースを構築する上で一番悩んだのが SSR 開発時のビルドについてです。
なんせ公式のドキュメントや主なシードプレジェクトではだいたい ↓ これでビルドしてと書いてあるのです。

npm run build:ssr && npm run serve:ssr

これはつまり、何かを変更するたびに手動でビルドを行わなければならないことを意味します。
Nuxt はそういうの自動でやってくれたんだから Angular でもあるだろうなと思い、似たようなプロジェクトを探していたのですが一向に見つかりません。
仕方がないのでタスクランナーや ng build --watch を駆使してソースコードの変更に反応してビルド後サーバーを再起動 + ブラウザをリロードしてたりもしたのですが、もちろん開発体験が良くありませんでした。

そんな状況で騙し騙し開発を続けていたのですが、ある日素晴らしいプロジェクトを発見しました。
それが enten/udk ( Universal Development Kit ) です。

このプロジェクトは上記の変更検知 ~ ブラウザに反映までの一連の流れを抽象化し、どんなプロジェクトにでも組み込めるよう整備したものです。
そしてこれを利用した Angular 版のサンプルプロジェクトが enten/angular-universal になります。
現時点ではおそらくこのプロジェクトをベースに始めないとビルド関連の不毛な設定で苦しむことになります。angular のバージョンやちょっとしたパスのミスで全然違うところのエラーが発生し、原因の特定に時間を浪費するため本当に辛い。

udk を利用することで、そういった面倒な部分を全て吸収してくれます;;
しかも SSR しながら HMR できるため、変更の反映が非常に高速です。

68747470733a2f2f692e696d6775722e636f6d2f76507a434d426b2e676966.gif

参照:https://github.com/enten/udk


断定的な書き方をしてしまいましたが、もしかしたら他にも良い方法があるかもしれません。しかし、Angular Connect に代表される直近の発表でもビルド周りには苦労している発言があったため望み薄かと思われます。ただしこれは 2018/11 時点での話ですので、未来でこの記事を読んでいる方はプロジェクトを始める前に github のリポジトリを一通り検索してみることをお勧めします。

nestjs

参照:https://nestjs.com/

nestjs とは

nestjs は、Angular ライクにサーバーサイドの処理を記述できるプログレッシブ NodeJS フレームワークです。プログレッシブとなっているのは、現在利用している express 等他のフレームワークと共存もできるからです。例えば、豊富な express のミドルウェア資産を使いつつそれを拡張して nestjs 化できます。
自分の中では js でいう Typescript、css でいう scss みたいなイメージですね。
スター数は 12/2 時点で 10,517。直近のカンファレンス等では毎回名前が上がるほどの勢いを持っています。

Angular Universal のチュートリアルやサンプル記事では、サーバーサイドのフレームワークとして express が使われている事例が多いででしょう。しかしアプリケーション側の Angular とは記述法が異なるため、サーバー ⇠⇢ アプリケーションで頭を切り替えないといけませんし、express で不足している部分に関しては独自の拡張を繰り返す結果だんだんと統一感がなくなっていってしまいます。
そこで nestjs を使うと Angular と同じ世界観をサーバーサイドに提供し、一定のルールで縛ってくれます。Angular で利用されているモジュール、DI、サービス、ガード等の概念が同一のシンタックスで利用できるため、アプリケーション側で Angular を利用している方ならドキュメントをさらっと眺めるだけですぐに使えるようになると思います。
Angular を Universal 化するなら是非組み込んでおきたいフレームワークです。
(express 以外のフレームワークを使っている方にもお薦めです!)

もちろんサーバーサイドでやりたいことが SSR だけなのであれば無理に導入する必要はありません。
しかし Universal 化を行うということは SEO を最適化したいということで、そうすると最低でもサイトマップを生成する機能が必要となってきます。他にもちょっとした API を生やしたり、ログインしている人に対しては別のロジックで SSR を行いたい等、後の拡張を予想するのであれば使っておいて損はないと思います。

また、公式から nestjs × Angular Universal 導入用のモジュールも提供されているため、普通にやるよりむしろ簡単かもしれません。(nestjs/ng-universal

使用例

最近ドキュメントが再整備されたため非常に始めやすい環境になっていると思います。
導入にしてみようかなと思った方は、こちらのスライドにも目を通しておくことをお勧めします。
参照:Nest the backend for your Angular Application @AngularConnect

コントローラー

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll() {
    return 'This action returns all cats';
  }
}

サービス

import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

モジュール

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

ガード

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

使用時の注意事項

noImplicitAny: true で実装されていないのでコレがマージされるまでは、tsconfig.server.jsonnoImplicitAnyfalse にしておく必要があります。

Client と Server での処理の切り分け

Universal における大きな問題の一つに Client で動作しているコードがそのままサーバーサイドで動かないという点があります。この問題の多くはブラウザ上に存在する Window 等のグローバルオブジェクトがサーバーサイドには存在しないことが原因で発生します。

この節ではそういったグローバルオブジェクトを利用し、そのままではサーバーサイドでエラーとなってしまう部分をどうやって回避するかを説明していきます。

モックオブジェクトを利用した切り分け

最も問題となる、つまり出現頻度の高いオブジェクトは Window です。あまりにも使われる場所が多く、下手すると外部ライブラリに紛れ込んでいる可能性もあります。Universal 対応をしていると window is not define というエラー文言にはほぼ 100% 遭遇するでしょう。

そういった部分に対していちいち対応していてはキリがないため、サーバーサイドでは Window オブジェクトのモックを作成しグローバルに適応することにしました。Window のモック生成には公式で採用されている Domino というライブラリを利用します。
先程挙げた nestjs/ng-universal 内に applyDomino というとてもわかり易い名前の関数が定義されているので利用すると良いでしょう。

これで大部分のエラーは解消するはずですが、モックが対応していないプロパティもいくつか存在します。そのため Window オブジェクト自体はラッパーサービスからのみ参照するようにし、アプリケーション側で直に参照する実装は控えましょう。問題が発生した際に対処のしやすさが全く異なります。

platformId を利用した切り分け

クライアントとサーバーの切り分けで一番作業量が多くなるのが platformId での切り分けです。
platformId はその名の通り、現在 Angular が動作している環境を示すオブジェクトが入っている定数で、@angular/commonで公開されている isPlatformServerisPlatformBrowser と組み合わせることでクライアントとサーバーの処理を切り分けられます。使い方は簡単で下記のように DI して if 文の条件に使うだけです。

import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

...

constructor(@Inject(PLATFORM_ID) private platformId: Object) {}

...

ngOnInit() {
  if (isPlatformBrowser(this.platformId)) {
    // Client only
  }

  if (isPlatformServer(this.platformId)) {
    // Server only
  }
}

...

しかしこれでは、ソースコードのいたるところにごちゃごちゃと if 文が混入し、コードの見通しが悪くなってしまいますね...
そこで少しでもこの問題を緩和するため、クライアントサイドでのみ実行する補助コンポーネントを作成し極力コンポーネントの外部でそういった関係性を記述するようにしました。

page.component.html
<app-client-only>
  <app-ad clientId="xxx-xxx-xxx"></app-ad>
</app-client-only>
client-only.component.html
<ng-content *ngIf="isClient"></ng-content>

DI を利用した切り分け

platformId での分岐作業を進めていくと、上記までの分岐ロジックはほとんどサービスクラスに集中してくることが分かります。そういった場合には、DI を利用してリファクタすることでコードの見通しが格段に良くなります。

具体例として、自分は Firestore を利用した DB サービスの切り分け等に利用しています。
実装イメージはこんな感じ。

db.client.service.ts
@Injectable()
export class DbService implements DbServiceInterface {
  public getDocument() {
    // リアルタイム通信で取得
  }

  public getCollection() {
    // リアルタイム通信で取得
  }
}
db.server.service.ts
@Injectable()
export class DbServiceForServer implements DbServiceInterface {
  public getDocument() {
    // 単発で取得
  }

  public getCollection() {
    // 単発で取得
  }
}
app.server.module.ts
@NgModule({
    ...
    providers: [
        ...
        { provide: DbService, useClass: DbServiceForServer },
    ],
})
export class AppServerModule {}

Firestore の売りとして WebSocket を介したリアルタム通信があるのですが、サーバーサイドでは呼び出し時点のデータだけ取得できればよく、リアルタイム通信はむしろ邪魔です。
そのため Firestore のデータ取得部分を共通のインターフェースでサービスとして切り出しました。
そしてクライアント側はリアルタイム通信で取得するメソッド、サーバー側は単発で取得するメソッドをコールすることで、それらを束ねるサービスクラスではクライアントとサーバーの差異を意識することなく実装ができます。

ハイドレーション

ハイドレーションとは

ハイドレーションとはサーバーサイドでの通信結果やランダム・重い処理の実行結果を json として html に埋め込み、クライアントではその結果を流用することで瞬時に DOM を再現する手法です。
この工程を行うことで時間のかかってしまう処理をスキップすることができ、結果として TTI までの時間が短縮されます。
また副次的な効果として画面のチラつきを抑制し、シームレスな CSR 移行を実現できます。

Hydration (1).png

TransferState API

Angular でのハイドレーションには、@angular/platform-browser/TransferState API を使用します。
この API を利用することでサーバー側でキーに対して登録した値を index.html に埋め込み、クライアント側でその値を復元して取得する流れを簡単に実装することができます。

下準備

ServerTransferStateModuleBrowserTransferStateModule をサーバー側とクライアント側のモジュールに import します。

app.server.module.ts
@NgModule({
  imports: [
    ...
    ServerTransferStateModule
  ],
  ...
})
export class AppServerModule {}
app.browser.module.ts
@NgModule({
  imports: [
    ...
    BrowserTransferStateModule
  ],
  ...
})
export class AppBrowserModule {}

State Key の作成

サーバーの結果をブラウザに転送する際に登録及び参照を行うためのキーの発行には @angular/platform-browser/makeStateKey を利用します。

export const ARTICLE_DETAIL_STATE_KEY = makeStateKey('ARTICLE_DETAIL');

サーバー側で登録

article-detail.component.ts
async ngOnInit() {
  const article = await this.articleService.getCurrentItem();
  ...
  if (isPlatformServer(this.platformId)) {
    this.transferState.onSerialize(STATE_KEY, () => {
      return article;
    });
  }
}

クライアント側で取得

article-detail.component.ts
async ngOnInit() {
  const article = this.transferState.get<Article>(STATE_KEY, null);
}

TransferState API の汎用化

TransferState API を使うことで比較的簡単にハイドレーションができると言っても、ソースコードのあちこちに if (this.isServer) だの this.transferState.get が入り込んでしまうと変更に弱い上にコードの見通しが悪くなってしまいます。

そこで下記コードを

article-list.component.ts[before]
ngOnInit() {
  this.articles$ = this.articleDb.findList();
}

この程度の手間で Universal 化できるようにしました。
stateKey を作る際にクライアントとサーバーで共通する unique な文字列が必要なのですが、ベストプラクティスが見つからなかったためコンポーネントのタグ名をキーにすることが多々あります。コンポーネントのタグは DI するだけで下準備なしに参照でき、フレームワーク側でユニーク性を担保してくれるためキーにうってつけでした。

article-list.component.ts[after]
constructor(
  private elementRef: ElementRef,
  ...
) {}

ngOnInit() {
  this.articles$ = this.transferStateService.getItems(
    this.elementRef.nativeElement.tagName,
    this.articleDb.findList()
  );
}

共通部分の処理はこんな感じです。
若干やることが変わるため getItemgetItemsgetValue の三種類を定義し使い分けています。

transfer-state.service.ts
    public getItems<T>(baseString: string, stream$: Observable<T[]>): Observable<T[]> {
        const key = `${baseString}-ITEMS`;
        this.transferStateKeys[key] = this.transferStateKeys[key] || makeStateKey(key);
        const serverResult = this.get<T[]>(this.transferStateKeys[key]);

        if (serverResult) {
            this.remove(this.transferStateKeys[key]);

            return of(serverResult);
        }

        return stream$.pipe(map(items => this.setServerValue(key, items)));
    }

    private setServerValue<T>(key: string, value: T): T {
        if (this.isServer) {
            this.onSerialize(this.transferStateKeys[key], () => value);
        }

        return value;
    }

NgRx でのハイドレーション例

NgRx の転送は上記までの基本と Root の State をセットするアクションを定義することで実装できます。

root-store.module.ts
export const SET_ROOT_STATE_TYPE = '[Init] SetRootState';
export const NGRX_STATE = makeStateKey(NgrxTransferStateKey);
export const MetaReducers: MetaReducer<fromRoot.State>[] = [stateSetter];

export function stateSetter(reducer: ActionReducer<any>): ActionReducer<any> {
    return function(state: any, action: any) {
        if (action.type === SET_ROOT_STATE_TYPE && action.payload) {
            return action.payload;
        }

        return reducer(state, action);
    };
}

@NgModule({
    imports: [
        StoreModule.forRoot(fromRoot.Reducers, { metaReducers: MetaReducers }),
        ...
    ],
    ...
})
export class RootStoreModule {
    constructor(
        @Inject(PLATFORM_ID) private platformId: Object,
        ...
        private readonly store: Store<fromRoot.State>,
        private readonly transferState: TransferState,
    ) {
        ...
        if (isPlatformBrowser(this.platformId)) {
            this.onBrowser();
        } else {
            this.onServer();
        }
    }

    private onServer() {
        ...
        this.transferState.onSerialize(NGRX_STATE, () => {
            let state;
            this.store
                .subscribe((saveState: any) => {
                    state = saveState;
                })
                .unsubscribe();

            return state;
        });
    }

    private onBrowser() {
        const state = this.transferState.get<any>(NGRX_STATE);

        this.transferState.remove(NGRX_STATE);
        this.store.dispatch({ type: SET_ROOT_STATE_TYPE, payload: state });
    }
}

参照:https://github.com/ngrx/platform/issues/101#issuecomment-351998548

その他の Tips

初回アニメーションの無効

ページを移動した時やページ内で新しい要素が出現する際、それらの挙動をわかりやすく伝えるためにアニメーションを使うことが多々あると思います。最近マイクロインタラクションというワードもバズっていますし尚更ですね。

しかしそういった処理を普通に書いたままだと SSR から CSR に移行する際に非常に強い違和感を覚えます。ハイドレーションをしないでクライアントサイドでも API 通信が走り、その間に一瞬チラつく現象に近い違和感です。そのチラつきの部分がアニメーションに置き換わったと言えばよいでしょうか。

その現象がわかりやすく発生しているのがとあるメジャーなシードプロジェクトです。一旦描画されたものが一瞬消え、その直後にスライドインアニメーションでページが表示されます。(発生しているのは 2018/11 時点)
http://ng-seed.fulls1z3.com/

この現象を食い止めるためにはサーバーサイドレンダリングされた際、ページを移動するまでアニメーションを無効にしなければなりません。調べてみたところ、そういった実装には @angular/animations[@.disabled] が使えそうでした。現在はこれでページを移動するまでアニメーションを無効にしていますが、もしかするともっと良い方法があるかもしれません。

グローバル css が反映されない

Angular で SSR する際、コンポーネントに付属する css はインラインで展開され html に注入されます。
しかし、angular.json の styles に登録したグローバルな css は js 実行時に展開されるため、反映が遅れてしまいますし、SSR をする目的の一つである js が満足に実行できない状態でも閲覧可能というメリットが薄れてしまいます。

この問題を発見した当初、ビルドが完了した後に index.html 内の link タグに指定されているの css ファイルの中身で置換してみましたが、ngsw のハッシュ値がずれるためキャッシュコントロールが正常に機能しなくなってしまいました。どうやらビルド結果をいじってはいけないようです。

さほど影響もないため一旦放置していたのですが、先日の Node 学園で inline-style 用のコンポーネントを作成すれば良いという情報を入手。現在はこの解決策のおかげで js を off にしていてもページレイアウトが崩れることなく最低限の閲覧はできるようになりました。

参照:https://github.com/Angular-RU/angular-universal-starter/commit/dbb413b5422f23ba50b812522168ae7497b5d9ef

initialNavigation

RouterModule.forRootinitialNavigationenabled にしておかないと遅延読込するルーティングが CSR になり SSR を行う旨味が激減します。

app.module.ts
@NgModule({
    imports: [
        ...
        RouterModule.forRoot(ROUTES, { initialNavigation: 'enabled' }),
    ],
    declarations: [AppComponent],
    bootstrap: [AppComponent],
})
export class AppModule {}

stylePreprocessorOptions

stylePreprocessorOptions は css で @import する際のパスを短縮するオプションです。
登録されたパス配下のファイルはディレクトリ部分の記述無しで呼び出すことができます。

hoge.component.scss
@import 'variables';

stylePreprocessorOptions を使っている場合は server のビルド設定にも追加しておいてください。

angular.json

                "build": {
                    "builder": "@angular-devkit/build-angular:browser",
                    "options": {
                        "styles": [{
                                "input": "node_modules/@angular/material/_theming.scss"
                            },
                            {
                                "input": "src/client/styles/main.scss"
                            }
                        ],
                        "stylePreprocessorOptions": {
                            "includePaths": ["src/client/styles"]
                        },
                    }
                },
                "server": {
                    "builder": "@angular-devkit/build-angular:server",
                    "options": {
+                        "stylePreprocessorOptions": {
+                            "includePaths": ["src/client/styles"]
+                        }
                    },

本番ビルド時のメモリ不足

SSR アプリケーションをある程度の規模で作っていると本番ビルド時にメモリ不足で落ちてしまうことがありました。
そのため、「中規模以上の規模のプロジェクト」あるいは「増築を繰り返した場合にこの問題に陥ってハマりたくない」という方は、npm scripts に登録する ng コマンドを下記形式に変更しておくと良いかもしれません。

package.json
{
  "scripts": {
    "build:dev": "node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng run project:udk:production"
  }
}

tsconfig.server.json の target 変更

サーバーサイドでは最新の JavaScript が問題なく動作するため、target を es5 のままにする必要はありません。
target のバージョンを上げることで無駄なコードを生成する必要がなくなり、bundle サイズが 1 割程減少します。

tsconfig.server.json
{
    "compilerOptions": {
        "target": "es2018",
        ...
    },

NoopAnimationsModule

サーバーサイドではアニメーションを実行する必要がないため、app.server.module.ts には NoopAnimationsModule をインポートして無効にしておきます。

app.server.module.ts
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
...
@NgModule({
    imports: [
        NoopAnimationsModule,
        ...
    ],
    ...
})
export class AppServerModule {}

つらみ

やって良かったことは結果として最初に書いたので、辛かった点も書き残しておこうと思います。

ググれない

とにかくサンプル以外の情報がネット上に存在しませんでした。もちろん Qiita にも TransferState が実装された Angular5 以上の記事で有用なものは存在しません。
しょうがないので github で本家や最近更新された angular universal を使用しているリポジトリを一つずつチェックして不足部分の知識を補完していきました。大変でしたがそのおかげでいち早く nestjs に巡り会えたりもしたので良い面もありました。

そんな状況でしたが最近立て続けに素晴らしい発表があったりして分散していた SSR・Universal 周辺の情報がまとまりつつあります。そのため、半年前よりは大分始めやすい環境にはなっていると思います。
すでに何回か参照しているものありますが、直近で特に参考になったスライドを今一度ここにまとめておきます。

フレームワーク本体の破壊的変更

これは完全に自分が悪いのですが、プロジェクトの Universal 化を始めたタイミングが最悪でちょうど Angular v5 ⇢ v6 に切り替わるくらいの時期でした。このときに何があったかというと RxJS の大幅な仕様変更を取り入れたことが原因で本体や依存ライブラリがしばらく不安定となりました。
Universal 状態で使用する人は、CSR で使用する人の数に比べて圧倒的に少ないため、サーバーサイドのみで発生するバグの発見・解消は通常よりも大分遅くなります。

そういったバグはしばらくすると勝手に直っているので、厄介そうなバグを踏んだら迂回して別の場所を実装したりしていました。結局その時踏んだバグは、現在では全て解消しています。
今更詳しく振り返ってもノイズでしか無いため、「Angular 本体に大きな変更が入る時期には、Universal 化を始めないほうが良いかも」という教訓だけ残しておきます。
(v8 の ivy は下位バージョンとの完全な互換性を約束しているため v6 特有の問題になるかもしれません)

ビルド時間が長い

プロジェクトの規模が大きくなるにつれて、最初はほぼ 0 秒だったビルド時間が伸びてきました。自分のプロジェクトでは 200 コンポーネント弱で 40 ~ 50 秒ほどかかってしまいます。この問題については次世代レンダリングエンジンの ivy が解決してくれるみたいなので Angular v8 に期待しています。

おわりに

今回は Universal 化を始めてから一段落つくまでに詰まった部分をなるべく思い出しながらまとめてみました。とても長くなってしまったので、ちょくちょく漏れている・誤っている点もあるかもしれません。そういった部分は見つけ次第アップデートしていこうと思っています^^;

これから Universal 化を始める方のお役に少しでも立てたなら、それはとっても嬉しいなって。

※ 万が一記事内の図が使いたい という方がいましたら許可なくご利用いただいて大丈夫です
(ただし直下に参照リンクが付いていないものに限ります


5日目は @shioyang さんです。よろしくお願いしますm(__)m

196
141
1

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
196
141