ぱろっと・すたじお

技術メモなどをまったりと / my site : http://parrot-studio.com/

React+jQuery+RailsのSPAをサーバサイドレンダリングに移行した件(その2:ブラウザ依存排除編)

というわけで、前回の続きです...φ(・ω・`)


<前回>
parrot.hatenadiary.jp

<サイト>
ccpts.parrot-studio.com

<修正したコード>
github.com


前回は「サーバサイドレンダリングSSR)」の概念的な話と、
そこから導かれる設計の概要、そしてSSRに移行するための段取りについて書きました

  1. ブラウザ系オブジェクトの排除(とりあえず実行時エラーを消す)
  2. 「仕様上の正しい動作」になるように修正
  3. インフラの調整

今回はこの一番最初、「ブラウザ系オブジェクトの排除」からやっていきます

STEP1:実行時エラーの消去

とにかくまずは「エラーが出ない」「何か画面が表示される」を目指します
前回、windowオブジェクトを排除すればエラーにならないのでは・・・という推測しましたが、
これを立証するのが最初のステップになります

(1) 二つの領域のjsと、それをつなぐProxy

具体的なRailsの話であり、前回書いた話になりますが、
Railsが扱うjsはざっくり2種類あります

Railsのassetsに属する「application.js」と、
webpackでまとめられ、SSRにも使われる「Reactを主体にしたjs」
(以下、アプリ名にあわせて「ccpts.js」と表記)です

前者は「app/assets/javascripts」、後者は「app/javascript」に存在し、
それぞれ別なルートでまとめられて一つになります*1

「application.js」は(HTMLのlayoutを経由して)クライアントのみで読み込まれますが、
「ccpts.js」サーバとクライアント双方で実行されます
この「ccpts.js」にwindowオブジェクト等が含まれるとまずいことになります(´-ω-)

ならば、windowに触れるコードをapplication.jsに追い出してしまおう・・・ということです

f:id:parrot_studio:20190124110650p:plain

まず、windowとかjQueryとか、ブラウザ系のオブジェクトに触るコードを、
「Browser」というオブジェクトに閉じ込めてしまいます

// app/assets/javascripts/browser.js の抜粋
// webpackを通さないので、TypeScriptではない

// クラス的に振る舞うグローバルオブジェクトを定義
Browser = {};

// window.location
Browser.thisPage = function () {
  return window.location.href;
}

// window.confirm
Browser.confirm = function (mes) {
  return (window.confirm(mes));
}

// jQueryに依存する処理の例
Browser.addSwipeHandler = function (div, callbackLeft, callbackRight) {
  if (!div) {
    return;
  }

  $(div).swipe({
    swipeLeft: (function (e) {
      e.preventDefault();
      callbackLeft();
    }),
    swipeRight: (function (e) {
      e.preventDefault();
      callbackRight();
    })
  });
}

元々React化した際、jQueryなどのライブラリは「application.js」に追いやられているので、
これでwindowに触るコードとライブラリが全て「application.js」に分離されたことになります *2

しかし、このままではReact側で生成したDOMと、これらの処理が連携できません

そこで、「クライアントサイドではBrowserに処理を委譲し、
サーバサイドでは適当な値を返すオブジェクト(≒クラス)」として、
「BrowserProxy」を定義します

// BrowserProxyの抜粋

// 他のコードから追い出されたdeclare
// ここにしかdeclareがないので、他のコードに外部依存がないことを保証できる
declare var window
declare var Browser

export default class BrowserProxy {

  // windowオブジェクト(とBrowserオブジェクト)の有無を判定
  public static isWindowDefined: boolean = (() => {
    if (typeof window === "undefined") {
      return false
    }
    if (typeof Browser === "undefined") {
      return false
    }
    return true
  })()

  // windowがなければ特定の値を返す例
  public static thisPage(): string {
    if (BrowserProxy.isWindowDefined) {
      return Browser.thisPage()
    }
    return "/"
  }

  // windowがあるときだけhandlerをセットする例
  public static addSwipeHandler(div, callbackLeft, callbackRight): void {
    if (BrowserProxy.isWindowDefined) {
      Browser.addSwipeHandler(div, callbackLeft, callbackRight)
    }
  }

}

このように、windowやBrowserが存在する(≒クライアントサイド)では、
まんまBrowserに処理を委譲していますが、
Browserが存在しない(≒サーバサイド)環境では何もしないか、適当な値を返しています

すでにTypeScript化しているので、外部参照している箇所は
「declare $」とか「declare location」とか、declare宣言されているので、
そこをBrowserとBrowserProxyにどんどん切り出していきます*3


呼び出す側はこんな感じです

// 関連する箇所だけ抜粋

export default class DatabaseTableArea extends ResultView<ResultViewProps> {

  private arcanaTable: HTMLTableElement | null = null

  // componentをマウントする時にハンドラをセット(ただし、クライアントならば)
  public componentDidMount(): void {
    Browser.addSwipeHandler(
      this.arcanaTable,
      this.handleLeftSwipe.bind(this),
      this.handleRightSwipe.bind(this)
    )
  }

}

この段階では、SSRと無関係に、ただコードを整理しているだけですので、
処理を切り出したらSSRなしで動作が変わらないことを確認・・・という感じで進めます

(2) 暫定的Cookie対応

ここまでの作業が終わったところで、試しにSSRに切り替えたところ、
「window not found」ではないエラーに変わりました

これはこれで一歩前進ですが、エラーの内容を調べたところ、
Cookieを読んで初期化するところで落ちてました(´-ω-)

この時点ではサーバサイドJS実行系におけるCookieの扱いがわからなかったのですが・・・

  • Cookieに触れたこと自体はエラーの原因ではない(ただ存在しなかったという挙動)
  • Cookieへの書き込みでは落ちていないように見える(この時点では挙動不明)

・・・ということで、サーバサイドでCookieのオブジェクトをparseし、
Reactの初期値として構造を丸ごと渡す・・・という強引な手にΣ(・ω・ノ)ノ

これはあくまで一時的な処置で、次のSTEP2で適切に置き換えられるのですが、
とにかくこの段階では「エラーを消す」(そしてBrowserProxy方式の妥当性を検証する)のが
最優先なので、無理矢理な手段をとりました


この修正により、ついにSSRでHTMLが吐き出され、
それっぽい画面が表示されました・・・が、
それは新たな戦いの始まりでしかなかったのです・・・

今回のまとめと次回予告

今回は実際の例として、Railsの文脈での作業について書いてみましたが、
一般的なポイントは以下です...φ(・ω・`)

  • jsを2つに分離する
    • 「クライアントでだけ実行されるもの」と「サーバ・クライアント双方で実行されるもの」
  • クライアントでだけ実行されるjsに、ブラウザ系オブジェクトを参照するコードを集める
  • ブラウザ系オブジェクトの処理を代行するProxy層を用意する

これで「表示」まで進むことができ、
SSR自体がいけそうな気がするレベルにはなったのですが、
進めていくと、「クライアント」と「サーバ」を別な形で意識させられることになります


ということで、次回「STEP2:仕様変更編」へ続きます(`・ω・´)


parrot.hatenadiary.jp

*1: 前者はsprockets、後者はwebpacker

*2: どこかで書いたと思ったら、ちゃんと書いてなかった気がするけど、とりあえずこのあたり https://parrot.hatenadiary.jp/entry/2016/02/28/113310

*3: declareについては以前の記事参照 https://parrot.hatenadiary.jp/entry/2018/08/29/171654