Ruby on Rails on React on SSR on SPA

amakan での設計を例に、RailsでSingle-Page Applicationをつくるときの自分のやり方をまとめてみます。

Gem

JavaScriptで書かれたReactのコンポーネントからHTMLを生成する」というのをRubyでやるために、RubyのV8エンジン実装であるmini_racerというGemを使う。この処理を楽に実行するために、react_on_railsというGemも使う。

gem "mini_racer"
gem "react_on_rails"

View

body要素内のHTMLは全てReactで生成するので、layout以外にviewのテンプレートは存在しない。

Controller

  1. 初回リクエストの場合はHTMLを返す
  2. ページ遷移時に呼ばれるリクエストの場合はJSONを返す
  3. 外部サイトからブラウザバックで戻ってきたときにJSONを見せない

という要求に対応するため、render の代わりにこれをラップしたメソッドを使う。

class ApplicationController < ActionController::Base
  private

  def action_path
    "#{controller_path}##{action_name}"
  end

  def common_props
    {
      actionPath: action_path,
      currentUser: current_user,
    }
  end

  def render_for_react(props: {}, status: 200)
    if request.format.json?
      response.headers["Cache-Control"] = "no-cache, no-store"
      response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
      response.headers["Pragma"] = "no-cache"
      render(
        json: common_props.merge(props),
        status: status,
      )
    else
      render(
        html: view_context.react_component(
          "Router",
          prerender: true,
          props: common_props.merge(props).as_json,
        ),
        layout: true,
        status: status,
      )
    end
  end
end

react_on_railsの提供しているhelperメソッド react_component を利用して、Router というReactのコンポーネントでHTMLを生成している。この部分でServer-Side Renderingしているということです。prerender: true というオプションを付けているので、RubyのV8エンジン実装を利用してHTMLが生成される。

以下は利用例。普段明示的あるいは省略して暗黙的に実行している、render の代わりに利用する。

class UsersController < ApplicationController
  def index
    render_for_react(
      props: {
        users: current_community.users.order(:created_at),
      },
    )
  end

  def show
    render_for_react(
      props: {
        user: find_user_from_request!,
      },
    )
  end
end

JavaScript

react_on_railsの提供しているメソッド react_component は、まず予め設定しておいたパスに配置されたJavaScriptのファイルから、コンポーネントの定義を読み込んだ上で、指定された名前のコンポーネントを描画する。そのため、我々はJavaScriptのファイルを設置して、その中でコンポーネントを登録する必要がある。クライアントサイドとサーバサイドでは同じJavaScriptのコードを共用する。サーバサイドでは public/assets/main.js が読み込まれるように指定し、同時にこのファイルを配信してクライアントサイドからも利用する。

共用するJavaScriptコードのエントリポイントがこれ。

// client/entry_points/main.js
import ReactOnRails from "react-on-rails";
import Router from "../components/Router";

ReactOnRails.register({ Router });

実際にはwebpackを利用してコンパイルして public/assets/main.js に配置しているが、そこはどうでもいいので詳細は割愛する。react-on-railsというnpmのパッケージを利用して、ReactOnRailsというシングルトンにReactのコンポーネントを名前と共に登録している。さきほどコンポーネントを登録すると言っていた部分がこれ。ここで登録しておくと react_component で呼び出せるようになる。

Router

Routerコンポーネントの実装 (の一部)。actionPathを元に利用すべきComponentを選択しているだけ。つまりactionPathを変えれば画面が遷移する。リンクをクリックしたときに、XHRでサーバと通信してレスポンスをRouterのstateに入れれば、画面内に描画される要素が変わり、画面遷移が起きたということになる。

import Categories from "../components/Categories";
import Category from "../components/Category";
import Community from "../components/Community";
import User from "../components/User";
// ...

export default class Router extends React.Component {
  constructor(...args) {
    super(...args);
    this.state = {
      rootProps: this.props,
    };
  }

  getComponent() {
    switch (this.state.rootProps.actionPath) {
      case "bookmarked_topics#index":
      case "communities#show":
      case "unread_topics#index":
        return Community;
      case "users#show":
        return User;
      case "categories#index":
        return Categories;
      case "categories#show":
      case "category_archived_topics#index":
        return Category;
      // ...
    }
  }

  render() {
    const Component = this.getComponent();
    return <Component {...this.state.rootProps}/>
  }
}

Link

リンクをクリックしたときに、XHRでサーバと通信してレスポンスをRouterのstateに入れたい。これを実現するために、全てのサイト内リンクについて、a要素の代わりに独自のコンポーネントを利用する。

export default class Link extends React.Component {
  static contextTypes = {
    onLinkClick: React.PropTypes.func,
  }

  onClick(event) {
    this.context.onLinkClick(event);
  }

  render() {
    return(
      <a onClick={this.onClick.bind(this)} {...this.props}>
        {this.props.children}
      </a>
    );
  }
}

これは利用例。

<header className="header">
  <Link href="/" className="header-logo">
    megaboard
  </Link>
</header>

Routerコンポーネントでcontext.onLinkClickを提供する。ついでに、以下の機能に対応しておく。

  • ページ遷移中にプログレスバーを表示する
  • ページ遷移後にページ上部にスクロールする
  • pushState/popStateを利用してブラウザの履歴を調整する
import { sendGet } from "../lib/client-methods";
import NProgress from "nprogress";

export default class Router extends React.Component {
  static childContextTypes = {
    onLinkClick: React.PropTypes.func,
  }

  componentDidMount() {
    window.addEventListener("popstate", () => {
      this.transitTo(document.location.href, { pushState: false });
    });
  }

  getChildContext() {
    return {
      onLinkClick: this.onLinkClick.bind(this),
      rootProps: this.state.rootProps,
    };
  }

  onLinkClick(event) {
    if (!event.metaKey) {
      event.preventDefault();
      const anchorElement = event.currentTarget.pathname ? event.currentTarget : event.currentTarget.querySelector("a");
      this.transitTo(anchorElement.href, { pushState: true });
    }
  }

  transitTo(url, { pushState }) {
    NProgress.start();
    sendGet(url).then((rootProps) => {
      if (pushState) {
        history.pushState({}, "", url);
      }
      this.setState({ rootProps });
    }).then(() => {
      window.scrollTo(0, 0);
      NProgress.done();
    }).catch(() => {
      NProgress.done();
    });
  }
}

title

初回リクエストで返却するHTML内にtitle要素を含めつつ、ページ遷移時にもtitle要素を変更する必要がある。そして、title要素は個々のコンポーネントの中に定義したい。これには react-helmet を使う。

import Helmet from "react-helmet";
import React from "react";

export default class TopPage extends React.Component {
  render() {
    return(
      <div>
        <Helmet title="Welcome"/>
        ...
      </div>
    );
  }
}

こうしておくと、クライアントサイドではこのコンポーネントが描画されたタイミングでtitle要素を自動的に設定してくれるため、ページ遷移時にはtitle要素が上手く設定される。

global.Helmet を用意しておくと、サーバサイドでもreact_on_railsserver_render_js を使って直近で描画されたtitle要素を取り出せるので、初回リクエストで返却するHTMLにはこれを使う。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <%= server_render_js("Helmet.rewind().title.toString()") %>
    <%= stylesheet_link_tag webpack_asset_url("main"), media: "all" %>
  </head>
  <body>
    <%= yield %>
    <%= javascript_include_tag webpack_asset_url("main") %>
  </body>
</html>

デモ

この設計で実装した amakan.net というサービスを公開しているので、あわよくば宣伝してください。

おわり

RailsでSingle-Page Applicationをつくるときの自分のやり方をまとめてみました。他の人がどう設計しているのかも気になるので、Single-Page Applicationを開発している人が居られましたら、是非記事を書いて知見を共有してください。

あわせて読みたい

RubyのモデルなどからJSONを生成するときの方法については、モデルからJSON生成するときこうやってます2016 - ✘╹◡╹✘ で説明しているやり方でやっています。あとJavaScriptの話だと、掲示板のJavaScriptこういう風に最適化しました - ✘╹◡╹✘ で説明しているやり方で最適化もしているので良ければどうぞ。