Angular2のServer Side Renderingに触れてみる

  • 144
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

今回のエントリでは、 Angular2 とServer Side Rendering(以下SSR)について書きたいと思います。

Angular2でSSRを実現するためのモジュール群は angular-universal で開発されています1
コーディングレベルでのモジュール利用方法についても記載しますが、APIや利用可能なオプション等の詳細については日々刻々変化が予想されます。
今日時点では、Angular2の開発チームが目指しているゴールと、それを支えているアーキテクチャについて理解しておいた方が今後の変化に柔軟に対応できると考えています。今回のエントリでも、この点を踏まえて書いていくように努めます。

前半でAngular UniversalのDesign Docを交えながらSSRの仕組みに関する話を、後半でExpressと連携させてAngular Universalを動作させる話を書いています。手を先に動かしたい人は 今すぐAngular Universalを触りたい人の為に から読んで下さい。

対象読者

このエントリでは下記を対象読者とします。

  • Angular2をTypeScriptで触ったことがある人
  • Angular2にはDIの機構があることを知っている人
  • Angular2のRouterを触ったことがある人

Qiitaでいうのであれば、下記の投稿あたりは目を通したことがあれば充分です:

Why SSR?

そもそも、どうしてSSRが必要なのでしょうか。

Design DocのUse Casesに、Angular2開発陣が想定しているSSRのユースケースが記載されています。

ざっくり要約すると下記となります(括弧内はDesign Docのtitle):

  • 性能の観点(Perceived load time, Actual load time, Client side performance)

    • エンドユーザーが直帰してしまわないようにするためにも、初期ビューは可能な限り早く表示すべきです。
    • クライアントWebアプリにバンドルされている機能の大半は初期描画には必要ないため、初期ビューの描画後にゆっくりローディングすれば良いはずです。
    • 大半のケースでは初期ページビューの後に発生するユーザーイベントはAPI 呼び出し + DOMの部分書き換えで問題ありませんが、例えば可視化処理等で大量データからシンプルなグラフを描画すれば良い場合は、部分レンダリングをSSRで賄うというのも1つの選択肢でしょう。  
  • クローラー観点(SEO, Preview Link)
    Googleのクローラですら、クライアントサイドで変更された後のDOMを完全に捕捉することはできません。SSRであればクローラが完全なHTMLを知ることができます。また、同様の理由により、SNS等でアプリケーションのプレビュー付でリンクするケースでも、SSRであれば完全なHTMLをプレビューできます。
     

  • Browser support
    Server Sideで描画されたHTMLであれば、ブラウザのJavaScript実行エンジンの制約をうけることなくエンドユーザーが閲覧できます。また、一般的なスクリーンリーダがClient Sideで描画されるコンテンツよりも、Server Sideで描画されたコンテンツの扱いに長けているのでアクセシビリティの向上にも繋がります。

上記のユースケースを踏まえてDesign DocではAngular2のSSRに下記3つの要件を規定しています。angular-universalの機能を説明する上でも重要な内容なので、こちらも要約を貼っておきます:

  1. "It just works"
    兎にも角にもAngular2アプリがServer Side JavaScript環境で動くこと。常に完全で最適化されたビューである必要はないですが、開発者にSSRのベースラインが提供されており、明示的に開発者がDOMやwindowオブジェクトを参照しない限りにおいて、Client Sideと同じようにServer Sideでもアプリが描画されること。

  2. Seamless state transfer
    SSRで描画された初期ページビューからシームレスにWebアプリの起動がなされること。起動処理や起動完了までに発生し得るユーザーインタラクションが、ユーザーの画面を固めるようなことがあってはなりません。例えば、ユーザーが検索ボックスに何か入力したのであれば、入力された文字やフォーカス状態がWebアプリ起動後も引き継がれるべきです。

  3. Performance
    ユースケースでも言及したように、多くの開発者がSSRを求める理由として性能改善を挙げます。Angular2が明確な性能目標を持つことが重要です。測定における条件は下記とします:

  • Desktop browser on latest Macbook Pro
  • Latency < 10ms
  • Server is equivalent to one AWS t2 instance
  • Single request
  • Internet connection with > 11 Mbps download speed

Angular2のSSR機構は下記の目標を達成すべきです:

  • Server render time < 100ms
  • Perceived load time < 1s
  • Actual load time < 3s

Other Goals

ここまでDesign Docの内容を中心に見てきましたが、折角ですのでDesign Docには記載されていない内容についても考えてみたいと思います。

HTML Mail

僕は日頃の業務では、AngularJS 1.xでSPAを開発しています。

運営しているサービスではSPAで提供しているページとほぼ同様の内容を、エンドユーザにHTMLメールで通知する機能が存在します。まぁよくある機能ですよね。

しかし、Angular1はangular.element(jqLite)にどっぷり依存しているためブラウザが無いと動作しないですし、Serviceについても$window$httpといった組み込みサービスもブラウザでないと動作しません。すなわち、Universalではありません。
結果、頑張って作ったDirectiveはブラウザでの利用にとどまり、HTMLメールについては完全に別実装となってしまっています。最終的に生成されるHTMLの構造は大部分が同じなのに、です。

SSRがAngularで実現できれば、メールとSPAのWebアプリでComponentやServiceを使いまわすことだって出来るはずです。

API Documents

もう1つ思い付いた活用例は、SSRの機構を静的なHTMLジェネレータとして利用するパターンです。

AngularJS 1.xでは自作したAngularJSのDirectiveやService, ProviderのngDoc APIドキュメントを生成するには、dgeniという生成器が用いられています(残念ながらそれほど流行したとは言えないライブラリのため、AngularJS 1.xを知っていてもdgeniまでは知らない人も多いかもしれません)。

dgeniで動作するテンプレートエンジンはNunjucksというAngularJSとは全く関係のないライブラリでした。AngularJSのドキュメントなのに。
別にAngular2でCMSを組めとまでは言わないまでも、ドキュメント生成器のようなちょっとしたツールをAngular2で作れるのは魅力的だと思いませんか?

Modules in Angular Universal

ここからは本エントリの主役、angular-universalが提供する機能を中心に書いていきたいと思います。

https://github.com/angular/universal/tree/master/modules 配下で各種モジュールの開発が進められており、2016.03時点では下記モジュールが含まれています(括弧内は対応するnpm module名):

  • universal(angular2-universal):
    SSR機構のコア。先述した要件 1.の大半が含まれます。現時点ではドキュメントの類がほぼ皆無なため、使い方を詳しく追いたい時はこのモジュールのソースコードを読むのが一番理解の助けになりました。
  • express-engine, hapi-engine, grunt-prerender, gulp-prerender, webpack-prerender:
    上記のコア機能をNode.jsの様々なツール上で動かすためのアダプタ。モジュール名を見ると何となく想像が付きますね。今回のエントリでは一番使いそうなexpress-engineについてのみ書きます。
  • preboot:
    先述した要件 2.の為の機能。詳細は後述します。
  • pollyfills(angular2-universal-pollyfills):
    ぽりふぃる。読み込ませるだけの代物なので、こいつについては特に語りません。

How to implement Universal Rendering

AngularJS 1.xでSSRの実現が難しかったのは、UIの描画関連機能がjQuery(jqLite)と密に結合しており、DOM(レンダリングエンジン)が無い環境での動作が困難だったためです。
一方、Angular2ではUI描画関連機能も含めて実行環境依存の機能に徹底してアダプタパターンを採用しているため、動作環境を問わないUI描画が実現できるようになっています。

アプリケーションは直接意識しませんが、Client Side, Sever Sideでアダプタが適切に差し替えられます。およそ下記のようなスタックイメージです:

universal_stack01.png
(紫: アプリケーション, 青: angular2, 緑: angular-universal, グレー: 実行環境)

  • Renderer: UI描画において最も抽象度の高いclass. UI コンポーネントに対する操作を行う(createElementやeventハンドラ等)。その気になればCanvasだって扱える代物です。

  • DomRenderer: 要素実体としてDOMを採用したRenderer実装。DOMへのアクセスはDomAdapterクラスを経由するように実装されています。

    • NodeDomRenderer: DomRendererを継承して少しカスタマイズされたNode.js向けRenderer実装です。
  • DomAdapter: W3Cの定義とは異なるものの、DOM APIをまとめたAbstract Class。

    • BrowserDomAdapter: ブラウザ向けDomAdapter実装。document 等、ブラウザ固有の機能に直接触ることを許された存在です。
    • Parse5DomAdapter: Server Side向けDomAdapter実装。その名の通り、parse5を利用してDOM treeの構築を行います。

なお、DomAdapterはDIの管理外に置かれており、var DOMという変数を介してDomRendererがアクセスします。従って誰かがvar DOMDomAdapter 実体を設定する必要があるのですが、Client Sideではbootstrap関数経由でこの処理が行われます。一方、Server SideではAngular Universalが提供するBootloader classが同様の役割を担っています。
先ほどの図にBootloaderも含めると、次のような感じでしょうか:

universal_stack02.png

今すぐAngular Universalを触りたい人の為に

angular/universal-starter というレポジトリをcloneするのが一番オススメです。
angular-universalのコミッタである PatrickJS 自身が作成したレポジトリのため、angular2, angular-universalの頻繁なバージョンアップにもちゃんと追いつきがなされています。

ただ時折、git clone & npm installしただけだと動作しないcommitがmasterとなっていることがあります(僕も2回ほど遭遇しました)。

このため、forkして動く状態で止めたやつを作っておきました:
Quramy/universal-starter

  • angular2(@angular/core): 2.0.0-rc.1
  • zone.js: 0.6.12
  • angular2-universal: 0.100.3

以降の説明でも、fork版レポジトリをcloneした前提でコードの説明をしていきます。

とりあえず動かす

clone, npm install, npm start すればwebpackがbundle(serverとclient両方)を作ってexpressが起動します。 http://localhost:3000 にアクセスすれば、レンダリングされている筈です。

アプリ本体のメインコンポーネントのコードは下記ですが、App classのnameというプロパティが<span>タグにinterpolateされています。

src/app/app.component.ts
/* 一部抜粋 */
@Component({
  selector: 'app',
  directives: [
    ...ROUTER_DIRECTIVES,
    XLarge
  ],
  styles: [`
    .router-link-active {
      background-color: lightgray;
    }
  `],
  template: `
  <div>
    <nav>
      <a [routerLink]=" ['./Home'] ">Home</a>
      <a [routerLink]=" ['./About'] ">About</a>
    </nav>
    <div>
      <span x-large>Hello, {{ name }}!</span>
    </div>

    name: <input type="text" [value]="name" (input)="name = $event.target.value" autofocus>
    <main>
      <router-outlet></router-outlet>
    </main>
  </div>
  `
})
@RouteConfig([
  { path: '/', component: Home, name: 'Home', useAsDefault: true },
  { path: '/home', component: Home, name: 'Home' },
  { path: '/about', component: About, name: 'About' },
  { path: '/**', redirectTo: ['Home'] }
])
export class App {
  name: string = 'Angular 2';
}

このアプリでは、上記のコンポーネントがServer Sideでレンダリングされた後、Client SideのSPAとして起動されるようになっています。いわゆるIsomorphicなアプリケーションというやつですね。

試しに"About"のリンクをクリックすると、Angular2のRouterでページが遷移することを確認できます。

さて、本当にSSRでレンダリングされたのかを確かめるために、Chrome開発者ツールのNetworkペインで確認してみます。

localhost_3000_と_universal-starter_app_component_ts_at_master_·_angular_universal-starter.png

上の図は決してElementsペインではありません。Serverから返却されたResponceのキャプチャです。最初からnameプロパティが描画されているのが分かるかと思います。

express-engine におけるSSRの基本

さて、もう少しSSRの仕組みを追いかけてみることとしましょう。

下記のコードがHTTPリクエストをExspressで受けとってAngular Universalに連携している部分です。

src/server.ts
/* 一部抜粋 */

// Express View
app.engine('.html', expressEngine);
app.set('views', __dirname);
app.set('view engine', 'html');

function ngApp(req: express.Request, res) {
  let baseUrl = '/';
  let url = req.originalUrl || '/';
  res.render('index', {
    directives: [ App, HtmlHead, ServerOnlyApp],
    platformProviders: [
      provide(ORIGIN_URL, {useValue: 'http://localhost:3000'}),
      provide(BASE_URL, {useValue: baseUrl}),
    ],
    providers: [
      provide(REQUEST_URL, {useValue: url}),
      NODE_ROUTER_PROVIDERS,
    ],
  });
}

// Routes with html5pushstate
app.use('/', ngApp);
app.use('/about', ngApp);
app.use('/home', ngApp);

ExpressのRendering EngineにAngular Universalが提供している expressEngine を渡して、あとはExpressのRouting設定をしているだけです。
Expressにおけるres.render(...)の第1引数であるテンプレートファイルの中身は下記のようにしています。

src/index.html
<!doctype html>
<html lang="en">
  <head>
    <html-head></html-head>
  </head>
  <body>
    <app>
      Loading...
    </app>

    <server-only-app>
      Loading...
    </server-only-app>
    <script async src="/dist/client/bundle.js"></script>
  </body>
</html>

ここでは、html-head, app, server-only-app という3つのセレクタ(すなわち、セレクタに対応するAngualr2のComponent)を描画するようにしています。

res.render(...) の第2引数がAngular2 アプリのbootstrapコードです。
このメソッドは angular-universal-preview/Bootloader classに動作を移譲しているのですが、下記の引数を受け付けるという意味ではClient Sideにおける起動コード(bootstrap from 'angular2/platform/browser)と、それほど変わらないように見えますね。

  • directives: RenderingすべきComponent
  • platformProviders, providers: DIがSSRのコンテキストで利用するprovider達

注意すべきはprovidersの中身でしょうか。アプリがRouterを使っているのでRouter用のProviderが必要となります。NODE_ROUTER_PROVIDERS はangular-universalが提供するRouter向けのProviderです。
Client SideでRouterが動作する際は、ブラウザのlocationやHistory APIを利用してルーティングを行いますが、Node.js環境ではこれらのAPIは利用できません。この違いをangular-universalは吸収してくれます。具体的にはNode.js環境でも動作するRouterを提供してくれています。とは言え、BASE_URLREQUEST_URLといったuniversal特有の情報を開発者がセットする必要があります。

なお、platformProvidersproviders として似たような名前のキーが2種類登場していますが、BASE_URLのようにrequestに依存しない情報はplatformProvidersに記載し、それ以外はproviders に記載します。

Keep Your Application Universal

先述したようにAngular2ではRendererが抽象化されており、ブラウザ環境とNode.js環境では別個のRenderer実体(DomAdapter)が動作しています。

基本的に開発者がそれぞれのRenderer実体を意識する必要は無いですが、Universalなアプリケーションを目指すのであれば要素の操作はRendererのAPIを経由するようにしましょう。
特にDirectiveを自作するとElementRefからDOM要素へアクセス出来ますが、アプリケーションをUniversalに保ちたければElementの操作はRendererに移譲するようにした方がよいです。

src/app/app.component.ts
@Directive({
  selector: '[x-large]'
})
export class XLarge {
  constructor(element: ElementRef, renderer: Renderer) {
    renderer.setElementStyle(element.nativeElement, 'fontSize', 'x-large');
  }
}

下記のように書いてしまうと、何が起こるか分かったものではありません。

@Directive({
  selector: '[x-large]'
})
export class XLarge {
  constructor(element: ElementRef) {
    element.nativeElement.styles.fontSize = 'x-large';
  }
}

Design Docでも述べられていることですが、Angular Universalの動作が保証されるのは「明示的に開発者がDOMやwindowオブジェクトを参照しない限り」においてです。

document, location, navigator 等も直接利用しないのは勿論ですが、3rd party製のAngular2 ServiceやDirectiveもUniversalに動くように作られている必要がありますので、注意が必要です。
自分でServiceやDirectiveを開発する限りにおいては、実行環境依存箇所はProviderを別々に切り出して適切な実装が注入されるようにDIを組めば良いわけですが、3rd Partyライブラリで直接ブラウザのAPIを参照されてしまうと、PR出して直してもらう以外に手の施し様が無い気がしています。。。

API Call in SSR

IsomorphicなWebアプリを開発するに当たって、避けて通れない話題が「どのようにAPI呼び出し部分を統合するか」です。

世の中におけるSPAの大半は、Ajaxでバックエンドに用意されたAPIと通信を行い、その結果をコンポーネントに描画するようになっています。そしてAPI呼び出しタイミングの多くはコンポーネントの初期化時です。
例えば、ユーザのアカウント管理がされてるアプリケーションであれば、ユーザのアバターや「ようこそQuramyさん」のような文言がログイン直後から描画されていなくてはなりません。

我々が必要としている機能はどのようなものでしょうか?IsomorphicなWebアプリにおけるAPI呼び出しの要件には下記が挙げられます:

  1. アプリケーションにおけるAPI呼び出し部分は極力UniversalなJavaScriptで記述したい
  2. SSRにてAPI呼び出しが必要な場合、RendererはAPIの情報取得結果を待つ必要がある
  3. SSRで既にGETしてComponentに突っ込んだ情報は、Client Sideでのアプリ起動時にも取得済みとして扱いたい

HTTP_PROVIDERS in Angular Universal

Angular2には標準的なAjax機能としてHttp Serviceが提供されています。ブラウザ環境であれば HTTP_PROVIDERSangular2/httpモジュールからimportしてProviderとして設定してあげれば利用可能になります。

一方、Angular UniversalにもNODE_HTTP_PROVIDERS というProviderが含まれています。

src/server.ts
import { NODE_HTTP_PROVIDERS } from 'angular2-universal';

/** 中略 **/

function ngApp(req: express.Request, res) {
  let baseUrl = '/';
  let url = req.originalUrl || '/';
  res.render('index', {
    directives: [ App, HtmlHead, ServerOnlyApp],
    platformProviders: [
      provide(ORIGIN_URL, {useValue: 'http://localhost:3000'}),
      provide(BASE_URL, {useValue: baseUrl}),
    ],
    providers: [
      provide(REQUEST_URL, {useValue: url}),
      NODE_ROUTER_PROVIDERS,
      NODE_HTTP_PROVIDERS,
    ],
    async: true,
    preboot: false,
  });
}

/** 中略 **/

// ExpressのAPIエンドポイントを作成
let router = express.Router();
router.get('/message', (req, res) => {
    res.send('XHR message!');
});
app.use('/api/v1', router);

ポイントは次の3点です:

  • NODE_HTTP_PROVIDERS をproviderとして設定することで、Node.js環境でも Http serviceが利用可能になる
  • クライアントから見たexpressのURLをORIGIN_URLにDIしておく
  • renderのオプションでasyncを有効にすることで、非同期な呼び出しを含むSSRが可能

この状態でアプリケーション側のコードにて初期化系のLifeCycle Hookを使うと、非同期APIを実行した後のComponentが描画されます。

src/app/app.component.ts
@Component({
  selector: 'about',
  template: `
  About
  <span>{{xhrMessage}}</span>
  `
})
export class About implements OnActivate, OnInit {
    private xhrMessage: string;
    constructor(private http: Http) {
    }

    ngOnInit() {
      return this.http.get('/api/v1/message').subscribe(res => {
        this.xhrMessage = res.text();
      });
    }

    // こちらも可
    routerOnActivate() {
      return this.http.get('/api/v1/message').subscribe(res => {
        this.xhrMessage = res.text();
      });
    }
}

ngOnInit, routerOnActivate はそれぞれ、Component, RouterのLifecycle Hookメソッドです。若干利用用途は異なりますが、ざっくりと「初期化直後の処理」を実装する箇所として利用することが出来ます。

先ほど「非同期な呼び出しを含む」という曖昧な書き方をしましたが、Bootloader classの実装を見るとNgZoneonStable イベントでトリガを引いていました。イベントハンドラの中で、完了していないHttp呼び出しの個数をカウントし、カウント数が0となって初めて描画を実行する仕組みのようです。

preboot

preloadとかprecacheとか、いい加減pre~~という呼称にうんざりしてきたかも知れません。最後にprebootの事を少し書いておきます。

prebootモジュールはDesign Docに記載されている「SSRで描画された初期ページビューからシームレスにWebアプリの起動がなされること」というGoalを達成するために作られた機能です。angular-universalのレポジトリで開発されていますが、prebootモジュールはAngular2の機能に一切依存していないため、他のフレームワークによるSSR(たとえばReactとか)でも利用することが出来ます。

典型的なprebootのユースケースは下記のようになります:

  1. Web Server にHTTPアクセス
  2. Server Sideで初期ページのHTMLを生成して返却
  3. ブラウザで初期ページを描画(この時点では只の静的HTML)
  4. チェックボックス操作等のユーザイベントの補足開始(preboot)
  5. Clientアプリケーションの起動(bootstrap)
  6. 4.~5.の間に捕捉したユーザイベントをplayback
  7. アプリ起動前のイベントが処理される

太字部分がprebootモジュールの主な仕事です。例えるのであればBrowserSyncと似ています。BrowserSyncはネットワーク越しの別ブラウザとユーザーイベントを同期しますが、prebootは同一のブラウザにおいて、時間を跨いでユーザーイベントを同期します。

触ってみた方が分かりやすいと思います。HTMLが描画されてからClient SideでAngular2が起動するまでにある程度(数100msec〜数秒)の時間を要する場合をシミュレートしたいので、setTimeoutで起動を遅延させます:

src/client.ts
setTimeout(() => {
    bootstrap(App, [
        ...ROUTER_PROVIDERS
    ]).then(() => {
        prebootComplete();
    });
}, 2000);

Express側のrenderにも少し手を加えます:

src/server.ts
function ngApp(req: express.Request, res) {
  let baseUrl = '/';
  let url = req.originalUrl || '/';
  res.render('index', {
    directives: [ App, HtmlHead, ServerOnlyApp],
    platformProviders: [
      provide(ORIGIN_URL, {useValue: 'http://localhost:3000'}),
      provide(BASE_URL, {useValue: baseUrl}),
    ],
    providers: [
      provide(REQUEST_URL, {useValue: url}),
      NODE_ROUTER_PROVIDERS,
      NODE_HTTP_PROVIDERS,
    ],
    async: true,
    // preboot: false,
    preboot: {
      appRoot: 'app',
      uglify: false,
      debug: true
    }
  });
}

express-engineの場合、prebootの設定はrenderメソッドの引数として記述することができます。

まずはpreboot: false の状態でlocalhost:3000にアクセスしてみてください。
今回のサンプルには、<input> 要素が1つ(App.prototype.name) が含まれています。SSRにより、初期値がバインドされた状態で描画されているはずです。Client SideでAngular2が起動する前にこの値を適当に書き換えて見てください。
Client Side側のAngular2起動後、値が Angular 2に戻ってしまったはずです。

次に上記のコード例のようにprebootを有効にした状態でアクセスして、同様の操作をしてみてください。
先ほどと異なり、Angular2の起動完了後にもタイプした値は残り、"Hello, Angular 2!"のspan要素への内挿も行われたはずです。

今回はprebootをdebugモードで起動しているので、ブラウザで開発者ツールを開くとprebootがキャプチャしたeventがconsoleログに出力されているはずです(恐らく、focuskeyup イベントがキャプチャされていると思います)。

今回はprebootでplaybackする例で説明しましたが、例えばイベント発生時にspinnerを表示してClient SideでのAngular2 bootstrap完了時にspinnerを解除する、といった使い方も考慮されています(freeze strategy)。

まとめ

色々と書いていたら結構長くなってしまいました。今回のエントリで書いてきたことをまとめると下記となります:

  • Angular2は実行環境依存箇所をアダプタとして切り出すことで、Universalに動作するアプリケーションのフレームワークとなっている
    • Angular UniversalはNode.js向けのRendering Engineと、各種Node.jsのツールからRendering Engineを呼びだすモジュールを提供している
    • nativeElementを直接操作しない。操作が必要であればRendererのAPIを用いる
    • RoutingやAjax機能は、対応するProviderをNode.js用に差し替える事で、各Serviceに依存したアプリケーションがUniversalに動作するようにする
  • Angualr2では「SSR描画結果からSPAの起動完了をシームレスに統合すること」が考慮されている
    • このためにprebootが開発されている。prebootを利用すると、イベントのキャプチャ&プレイバックが実現できる

最後に。困ったらコード読め。話はそれからだ


  1. モジュール名にも含まれている"Universal"という単語についてですが、この用語と"Isomorphic"という言葉の関係については、Universal JavaScript を読んでおくと良いでしょう。