Angular UniversalでSSR/Prerenderするときに躓いたら確認したい4つの方針


2020/06/02

Angular Universal を利用すると、動的にWebページをクライアント側に送り出すサーバーサイドレンダリング(SSR)だけでなく、静的htmlファイルへプリレンダリングできます。

近年では、WebサイトのSEO対応の新しいスタンダードとしてSSRやSPAページのプリレンダリングなどが盛んに議論されるようになりました。

すこし残念ですがまだあまり日本ではこういうトレンドはそんなに話題になってはいません。

邦人のブログ運用から言うと、最初からWordpressのようなオールインワンの機能を持ったリッチなCMSサービスを利用してブログサイトを立ち上げるケースがほとんどであり、そもそもフルスクラッチでブログサイトを自作する人はかなり小数なのかも知れません。

兎角、Angular Universalに限らず、JS系のフレームワークで、SSR/Prerenderingさせるテクニカルな内容を解説されているサイトは海外勢の技術ブログやドキュメントの盛り上がりとくらべると、日本での盛り上がりは少ないと言わざるを得ません。

諸事情等ありますが、Angular Universalのネタは海外サイトの情報を仕入れて、自主勉強するより他はなさそうです。

最近読んだ
こちらのブログにAngular Universalを理解する上での4つの注意点をまとめられていたのが印象的でしたので、著者なりの言葉で咀嚼しながら自分用の防備録としてもこの記事に残しておきたいと思います。

Angular Universalを利用する際に、静的ページのプリレンダリング中に起こるおおよそのエラーは、サーバー(Nodejs)側とブラウザ(クライアント)側の仕組みの違いをあまり理解しないで、通常のAngularアプリケーションをビルドする感覚で起こっているといえます。

逆に言うとクライアント側のコードとサーバー側のコードをより意識的に別けて書くことで、Angular Universalをより良く活用できるはずです。

今回はSSR/プリレンダリングする時に吐き出されるエラーの傾向と、解決のためのおおまかな指針に関してまとめます。


その1 〜 Webブラウザのグローバルオブジェクトの扱い

ブラウザには標準的に備わっているグローバルオブジェクトもしくはブラウザオブジェクトという機能があります。

代表格の
windowdocumentlocationといったものがグローバルオブジェクトとして各ブラウザに標準的に備わっています。

通常のAngularのSPAでもDOMの仕組みを通じてhtmlを操作を可能としているのは、このグローバルオブジェクトの存在があるからと言えます。

対して、サーバーサイドレンダリングやプリレンダリングはNodejs(サーバー側)であらかじめ処理をしてから、クライアント側へレンダリングした結果を送り出すことを行っています。

Nodejsはブラウザと違って、DOMを操作するAPIは備わっていないので、いつものSPAをビルドする感覚でAngular Universalを使うと、以下ようなエラーに遭遇することがあります。

            
            window is not defined
document is not defined
setTimout is not defined
#...などなど
        
Angular Universalがビルドしている主体はサーバーサイド側です。

ブラウザー側の機能であるグローバルオブジェクトが何なのかNodejsにはコンパイル中に解決出来ないために起こるエラーのようです。

このエラーは
polyfill.tsにグローバル変数として与えても回避することは出来ません。

今日日、サードパーティー製のnpmパッケージを利用しないプロジェクトなどは考えられないくらい何かしらのライブラリを活用されているかと思います。

ブラウザ上で動作することを想定して
windowdocumentをグローバルオブジェクトとして使っているライブラリーをAngular Universalでビルドしたい場合は、dominoを使う必要があります。

dominoは、既存のindex.htmlを内部から読み取って、nodejsで利用可能な擬似DOMをサーバー側に渡してくれるライブラリです。

プロジェクトに
dominoを導入するには、

            
            $ npm install domino
#OR
$ yarn add domino -S
        
でパッケージインストールします。

dominoをインストールしたら、server.ts(もしくは環境によってはmain.server.ts)に以下のようにコードを追加します。

            
            const domino = require('domino');
const fs = require('fs');
const path = require('path');

//ビルドした際に出力されたindex.htmlを擬似DOMテンプレートとして読み込む
const template = fs.readFileSync(path.join(__dirname, '.', 'dist', 'index.html')).toString();

//windowグローバルオブジェクトとdocumentグローバルオブジェクトを設定する
const window = domino.createWindow(template);
global['window'] = window;
global['document'] = window.document;

export { AppServerModule } from './app/app.server.module';
export { renderModule, renderModuleFactory } from '@angular/platform-server';
        
これで通常のSPAを出力するのと同じような感覚で、プリレンダリングが可能になります。


その2 〜 Angular Universalはできるだけ新しいバージョンを使う

ご存知のように、Angularのメジャーなバージョンアップが半年ごとにあり、頻繁にメソッドの用法・文法が変わっていきます。

その都度メソッドの呼び出しに細かい見直しがあったのを気づかずに、ブラウザビルドは問題なく通ったけれど、Angular Universalではコンパイルでエラーが起こる...という不可解な状況になることもあります。

特にサードパーティのnpmパッケージを利用するときに、そのメンバ関数からネイティブDOMにアクセスするようなライブラリを扱うときに注意が必要となります。

ElementRefクラスのnativeElementを介したネイティブDOMの操作を一例を挙げてみます。

とあるサードパーティ・ライブラリからネイティブDOMを操作するメソッドを実装した
AwesomeRenderServiceというクラスを作って、何かのコンポーネントで使うとします。

            
            import { Component, Input, OnInit, ViewChild, ElementRef } from '@angular/core';

import { AwesomeRenderService } from './awesome-render.service';

@Component({
    selector: 'awesome-dom',
    template: `
    <span #tex_area></span>
    `,
    styleUrls: ['./awesome-dom.component.scss']
})
export class AwesomeDomComponent implements OnInit {

    @Input() data: any;

    @ViewChild('tex_area', {
        read: ElementRef,
        static: true
    }) elementRef: ElementRef;

    constructor(private awsomeRenderer: AwesomeRenderService) {}

    ngOnInit() {
        if (this.data) {
            // AwesomeRenderServiceクラスのrenderメソッドの引数にネイティブDOMを指定する
            this.awsomeRenderer.render(this.data.text, this.elementRef.nativeElement);
        }
    }
        
としてDOMを扱うパターンが最近のangularでは多いかと思います。

ただし、
ElementRefクラスのnativeElementのメンバープロパティは@angular/core6.1以前のバーションで一部の機能が利用出来ないようになっています。

Angular Universalでサーバービルドしたときにコンパイルエラーが生じる可能性があります。

もしそれより以前のangularでもDOM操作しなければならない場合には、後方互換の手段で
Renderer2を使わないといけなくなるかも知れません。

DOM周りの実装すべてを
Renderer2ベースで書き換える必要が出てくると中々大変です。

結局、苦労して後方バージョンのサポートするくらいであれば、プロジェクトの
angular universalのバージョンに合わせて、angular cliをアップグレードすることを検討した方がベターです。


その3 〜 メモリーリークの確認

主にメモリーリークを確認するのは、通常のAngular SPAの開発のとき同様、SSRやプリレンダリングで開発するときも有効な手段です。

JSヒープをモニタリングすることによって、Universalアプリケーションがメモリリークを起こしているかどうかを評価することが出来ます。

Chrome DevToolsによるパフォーマンス測定の方法に関しては、以下のサイトで詳しく解説されているのでご参考いただくとして、本ブログでの詳細の説明は割愛します。

フロントエンドのパフォーマンス改善とメモリリーク対策の方法

Universalアプリケーションに限らず、Angularアプリケーションの主要なメモリリークの原因となるのは、実装したサービスクラスのサブスクリプションインスタンスを
unsubscribe()し忘れによるゾンビサービスの増殖です。

Rxjsで
unsubscribeするテクニックはいくつかあるのですが、メモリリークが発生する場合にはまず自身のサービスクラスでサブスクリプションしているところを中心に原因を探してみましょう。


その4 〜 遅延ロードの利用

これは主にSSRを使う時の注意点です。

サーバーサイドでレンダリングする際には、クライアント側からのリクエストを受け取ってからサーバー内部で様々な処理がされてから、レンダリング後のindex.htmlなどの結果がレスポンスとして返ってくる出力を受け取るという一連の流れでアプリケーションが動作しています。

問題になるのは、クライアント側からサーバー側での処理に時間的な負荷のかかるリクエストを、非同期処理させてしまうことです。

SSRする際にはサーバー側からのレスポンスを同期的に受け取る仕組みが必要ですし、リクエストの負荷が大きいとサーバー側の計算資源が枯渇してパンクしてしまいます。

そもそもSSRの目的は、クライアント側(ブラウザ)でのリソースの読み込み時間や描画時間を軽減させ、より高速・快適にWebページをレンダリングする仕組みですので、サーバー側で長い処理時間が掛かってしまうと本末転倒です。

そのような場合には遅延ロード(Lazy Loading)によって細かい粒度の処理リクエストに小分け・後出しすることで、サーバー側の負荷を軽減させるのがベターです。

クライアント側からのクリックやスクロールなどのイベントで
HttpClientリクエストが発生するようにしてもいいのですが、IntersectionObserver APIによる遅延ロードを検討すると見栄えの良いUI/UXに仕上がるかと思います。

下の参考サイトなどに実装方法が解説してあるので、興味があればご参照ください。

世界を平和に導く最強のlazy loadを構築した話


まとめ

最近ではangularに限ったことではないのですが、vuejsやReactでもUniversalアプリケーション対応が盛り上がってきているように感じます。

サーバーサイドレンダリングやプリレンダリングはまだまだ発展途上な技術な上に、フロントエンドとバックエンドの知識が混じり合うようなこともあり、コードの実装が分かりにくいのが現状です。

とはいえ、一度慣れてしまうとWebサイトの運営に多大なベネフィットを生み出す技術であると言えます。

今後も気が向く限りUniversalアプリケーションの話題を紹介できたら良いと考えております。


参照サイト

The biggest Angular Universal gotchas, and how to avoid them

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。