Isomorphic Architecture を実装してるときの細かいアレコレ

Isomorphic あらため Universal ?

一時期火がついていた Isomorphic について。各自がプロダクションの事例を作り上げる潜伏期に入ったのか、はたまた本当に一過性で火が消えたのか、コモディティ化を遂げたのか分かりませんが、あまり耳にすることがなくなった印象です。

そんな中ですが先日、Universal JavaScript — Medium が公開され、なるほど Universal ってキモチになったので、タイトルに反して以下は Universal と呼称します。

今回の話題にするのは module レベルではなく Universal な JavaScript architecture のほうです。アーキテクチャのレベルで Universal なコードが役立つ代表的ケースは SPA (Single Page Application) と SSR (Server Side Rendering) の両立でしょう。

Universal 3大要素がスタートライン

yahoo/fluxible による SPA + Server Rendering の概観 ::ハブろぐ でも述べましたが、Universal な SPA + SSR のためには次の3要素が Universal でなければなりません。

  • Routing
    • Client -> 分岐 + History とスクロール位置の管理
    • Server -> 分岐 + Express 的ふるまい
  • Fetching
    • Client -> window.XMLHTTPRequest
    • Server -> require('http')
  • Rendering
    • Client -> DOM
    • Server -> HTML string

これらを如何にして Universal なコードにもっていくか、ということですが前述の記事のとおり自分がいま進めているプロジェクトでは facebook/react と、yahoo/fluxible を筆頭に Yahoo 謹製品で固めています。(詳しくはそっちの記事を)

問題は、実際に設計したときのアレとかソレ

実際に作り始めてみると前述したような青写真だけでは語りきれない、細々とした問題の解決が必要になります。

今回は、先人として(後人がいるかどうか甚だ怪しいのだけど)、そんないくつかの React を使った Universal なアレコレについてトピック毎に書き記します。

アレとかコレとか

だいぶ前ですが isomorphic tokyo meetup - connpass とかに行ったら、突っ込んだ話も聞けたのかなぁと惜しむところですが、以下のトピックは React + Fluxible に依存してるところも大きいので、ちょっと違うかも。とりあえず書き出します。

Configuration の共有

Universal なので設定ファイルも相当量を共有します。ただし設定にはクライアントに露出しても良い設定と悪い設定があるので、単純に browserify でバンドルするのはあまり望ましくありません。よって、今回はクライアント向けのホワイトリスト処理をした上で Fluxible Context などと同じように HTML 上に JSON.stringify した状態で渡すことにしました。

// server.js の HTML 生成部
const html = React.renderToStaticMarkup(documentFactory({
  __config__  : 'window.__CONFIG__=' + serialize(whitelisted) + ';',
  __context__ : 'window.__CONTEXT__=' + serialize(context) + ';'
}));

こんな感じで受け渡したモノを...

// config.js(雑)
if (process.title === 'browser') {
    return assign({}, window.__CONFIG__);
} else {
    // サーバーのときは普通に、requireして環境別オーバーライドすればよい
    // これがクライアントに渡す設定オブジェクトの素材にもなる
    const NODE_ENV = process.env.NODE_ENV || 'local';
    const all = require('./config/' + 'base');
    const env = require('./config/' + NODE_ENV);
    return assign({}, all.default, env.default);
}

こんな感じで設定オブジェクトを返してくれるモジュールで取り扱うイメージ。

UserAgent の評価

これはサバクラが別々で UserAgent を評価しても良さそうなものですが、サーバーサイドレンダリング直後にあるクライアント側 React の mount & render を考えると、サーバーサイドで評価済みのオブジェクトが最初から欲しかったので、ApplicationStore にセットして Context として渡すことにしました。

// server.js のリクエスト処理あたり抜粋
import userAgent from 'express-useragent';
server.use(userAgent.express());
server.use(function(req, res, next) {
  const context = app.createContext();
  const useragent = req.useragent;

  context
    .executeAction(updateUserAgent, { userAgent : useragent })
    .then(() => context.executeAction(navigateAction, { url : req.url }))
    .then(function() {
       // Reactレンダリング処理...
    });
});

Fluxible の例ですがこんな感じ。Express の middleware として登録された UserAgent 評価モジュールの結果を updateUserAgent に渡して、奥にある ApplicationStore に保持させています。

ブラウザオブジェクトに触れるコードの取り扱い

当たり前ですが node で window とか document に触れると死にます。以下 browserify 前提です。

  • 一部のコードなら if (process.title === 'browser') で処理を分岐する
  • モジュール単位なら package.json に browser フィールドで丸ごとすげ替える
  • node で実行されないライフサイクルで遅延実行&遅延 require する(微妙っぽい)

あたりの対処を行っています。CommonJS 対応で require できるくせに即 document を参照て自壊する videojs には殺意しかなかった。

videojs のバージョンアップで browserify 対応がマシになってるぽいけど未検証...

<head> への操作または副作用の管理

<head> は本来、React で管理することができない(困難な?)スキマのゾーンです。<link rel="stylesheet"> などは普通に置きたいのですが、SPAとして画面遷移したときに Canonical URL や OGP、JSON-LD を動的に書き換えたいのです。

ちょっとお行儀が悪くみえますが、<body> 内の <div> にマウントされたコンポーネントから、直接 DOM API を通じて操作することにしています。このとき問題になるのは SSR のときと、DOM に対する副作用をもった処理の制御です。

static handleSideEffect(propsList) {

  pageTitle    = _getFromPropsList('pageTitle', propsList);
  // ...中略...
  linkingData  = _getFromPropsList('linkingData', propsList) || [];

  // update title
  document.title = pageTitle;

  // remove existing elements
  _removeAllWithDataFromReact();

  // insert html string as new elements
  let html = '';
  html += _createMetaElementAsString('og:title', null, pageTitle);
  // ...中略...
  html += _createLinkElementAsString('canonical', canonicalUrl);
  html += linkingData.map(_createJsonLdElementAsString).join('');
  document.head.insertAdjacentHTML('beforeend', html);
}
static rewind() {
  const expose = {
    pageTitle    : pageTitle,
    metaElements : [
      _createMetaElementAsComponent('og:title', null, pageTitle),
      // ...中略...
    ],
    linkElements : [
      _createLinkElementAsComponent('canonical', canonicalUrl)
    ],
    scriptElements : linkingData.map(_createJsonLdElementAsComponent)
  };
  this.dispose();
  return expose;
}
render() {
  return null; // 自身は何も吐かない
}

参考にしたのは gaearon/react-side-effect のアプローチ。不特定多数のコンポーネントが干渉する副作用を static メソッドで一箇所に集約して処理するスタイルです。サーバーレンダリング用に同等のコンポーネントを提供するメソッドも実装しています。

これを <Metadata> コンポーネントとしてルートのほうに置いておくことで、<head> 内を管理しています。細かく説明するとサンプルが長くなるので元ネタのほうのリポジトリをご覧いただければ。

初期に使うデータ量は抑える

画面の状態をサーバーからクライアントに引き継ぐとき、Fluxible Context としてサーバーで React レンダリングに使用したデータをシリアライズしてHTMLに埋め込みます。

このとき、バックエンドの API やデーターベースから取得してきたデータをガンガン入れ込むと <body> 以下の要素とあわせて HTML が容易に100〜200KB を突破します。操作可能な画面を素早く提示することが目的なので、初期のデータ量はほどほどにして、追加分はクライアント実行時に補充するほうがよいでしょう。

副次的に気づいたのは、シリアライズデータや react-id に圧迫されてるとはいえ、コンポーネント化された HTML はなかなか gzip の圧縮効率が高いようです。ブラウザ側のメモリをどれくらい喰らうかという問題はありますが、ネットワーク的なペイロードについてはウッとなっても、まずは gzip サイズを計測してみるべきです。

Store のライフサイクル管理

Universal ほとんど関係なくて、単に SPA 的に API などから取得したデータの扱いどうしようかな、という一般的な悩み。

作っているページは実に Web ページ然としたデザインなので、Store でガッツリ作り込む場合、どのページで取得したデータなのかを保持しないと「戻る」などで画面を再現したときの整合性を担保できません。そうなると保持しなければならない状態が実に多くなってきます。

じゃあ、Store の内部データはジャブジャブ使い捨てて、画面ごとのコンポーネントツリーを丸ごとキャッシュしてスタックしておけばいいのでは?という説もあります。以前に作っていた SPA はこっちのアプローチでした。

サーバーからデータの状態を引き継いでいる都合、まずは Store 内の状態制御をがんばっていますが、無理そうなら後者でやろうかなぁ...という感じです。

おしまい

他にも色々あるのですが、お仕事的に当たり障りのないこのあたりで。

Universal Architecture による SPA + SSR は、技術的には過渡期の歪なキメラっぽさが拭いきれませんが、昨今の Web フロントエンドにしては珍しくビジネス的な説得力があります。いわく

  • SSR なのでSNSや検索からの流入による初期表示が速い
  • SPA なので回遊時のページ遷移も速い
  • SSR なので古いブラウザでも CSS のデグラレーション具合でわりと見られる
  • SPA なのでダイナミックなトランジションもできる

あたりが説得材料でしょうか。その分、設計の負荷は高いですしその他、経験したことのない未知の細かい判断を問われることも少なくありません。長期的な保守性がどうなのかも、まだそこまで入っていないので未知の領域です。それでも、それなりのリターンを見込める選択肢が目の前にあるだけで十分に儲けものです。

決して万人にオススメするものではありませんが、全体設計の経験としては十分に面白い部類だと思います。