オフトゥン大好き

惰眠系プログラマの作業ログで( ˘ω˘ ) スヤァ…

Reactシングルページアプリケーションをサーバサイドレンダリングする

どうも、 @nukosuke です。

こちらはQiita Advent Calendar '17 React #1、3日目の記事です。

概要

この記事ではReactで構築したシングルページアプリケーション(SPA)をサーバサイドレンダリング(SSR)する過程をまとめました。

つかったもの

成果物

まずは出来上がったものから。こちらのリポジトリをcloneして以下のように起動できます。

$ npm run build:server # サーバjsをビルドします
$ npm run build:client # クライアントjsをビルドします
$ npm start            # localhost:3000で起動します

手順

ざっくりとした手順ですが、今回やったことを要約すると以下のようになります。

  1. KoaでReactコンポーネントをサーバサイドレンダリングする
  2. ReactRouterを使ってルーティング(URLに対して描画されるべきコンポーネントの定義)を行う
  3. Webpackでブラウザ側のJavaScriptをバンドルする
  4. バンドルしたJavaScriptをbodyの最後に差し込む

それでは順番に設定を見ていきます。

KoaでReactコンポーネントをサーバサイドレンダリングする

ディレクトリ構成は自由にできますが、僕はいつも以下のような構成にしています。参考程度に。

react-ssr-171203/
|- src/
    |- client.tsx # ブラウザJavaScriptのエントリポイント
    |- server.tsx # Nodeのエントリポイント
    |- components/ # Reactコンポーネントを入れておく場所
        |- Application.tsx # ReactRouterのルーティングを行うコンポーネント
|- build/ # トランスパイル済みのサーバサイドJavaScriptが入る場所。import(require)の都合上階層はsrc/の構造をそのまま移すようにしておく
|- public/javascripts/ # トランスパイル&webpack済みのブラウザJavaScriptが入る場所
|- webpack.config.js

まずはKoaを使ってReactコンポーネントレンダリングするHTTPサーバを立てます。KoaはExpressの後継として開発されているフレームワークです。

SSR用のモジュール react-dom/server から renderToString() APIを使って <Application/> コンポーネントをhtml文字列に変換し、それをbodyの中に埋め込んでいます。 <Application/> はこの時点では特に何も内容のないコンポーネントです。

import * as React from "react";

export default class Application extend React.Component<any, any> {
  render() {
    return (<div>SSRてすと</div>);
  }
}

ReactRouterを使ってルーティングを行う

次に、ReactRouterを使ってApplicationコンポーネントにルーティングを定義していきます。

デモということでちょっとサボりましたが、本来であれば <Route>component 属性に別のファイルからimportしたコンポーネントを渡します。

こうしてルーティングを定義したコンポーネント<StaticRouter> でラップします。Koaのコンテキスト(リクエスト情報など)からリクエストURLを渡すことで renderToString() で描画されるべき子コンポーネントを割り出し、よしなにレンダリングしてくれるようになるのです。 実際にReactRouterでラップしてSSRするサーバコードが次のようになります。

Webpackでブラウザ側のJavaScriptをバンドルする

さて、ここまでがサーバサイドでReactをレンダリングする手順でした。しかし、このままではただmuxとレンダリングエンジンがReactRouterとReactに置き換わっただけですのでここからはブラウザ側のJavaScriptを作っていきます。

まず、Webpackの設定を書きます。

"use strict";
const path = require("path");

module.exports = {
    entry: "./src/client.tsx",
    output: {
        filename: "application.js",
        path: path.join(__dirname, "public/javascripts")
    },
    resolve: {
        extensions: [".ts", ".tsx", ".js", ".jsx"]
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: {
                    loader: "ts-loader"
                }
            }
        ]
    }
};

client.tsx をエントリポイントとして、拡張子が ts or tsx のファイルをJavaScriptにトランスパイルし、 /public/javascripts 以下に application.js という名前で配置する設定です。

そして、 client.tsx を書いていきます。<Application/> はすでにさっきの手順で書いたので、今度はこれをブラウザ用の <BrowserRouter> コンポーネントでラップします。 ラップしたものを hydrate() APIに渡せば完了です。この際、SSRしたhtml文字列を囲んでいる <div> をhydrateに渡す必要があるので、idを設定しておいて document.getElementById() で取得します。

<!-- server.tsx -->
<div id="react-root">${markup}</div>

// client.tsx
document.getElementById("react-root")

ブラウザ側のコードは最終的にこんな風になります。

import * as React    from "react";
import * as ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Application from "./components/Application";

ReactDOM.hydrate(
  <BrowserRouter>
    <Application/>
  </BrowserRouter>,
  document.getElementById("react-root") // コンポーネントを吊るす対象
);

バンドルしたJavaScriptをbodyの最後に差し込む

最後に、SSR後にwebpackしたJavaScriptを読み込むようにします。これで一番最初のSSR以降、レンダリングは全てブラウザ側に丸投げできるようになります。

koa-static というKoaのプラグインを使用してWebpackでバンドルしたJavaScriptをブラウザに配ります。Webpackの出力先である /public を静的ファイル置き場としてマウントし、<body> の最後に <script> を差し込みました。

最終的にサーバ側は次のようなコードになります。

動作確認

npm start でサーバを起動してブラウザから localhost:3000 にアクセスしてみます。

ブラウザ側ではサーバで構築されたDOMと hydrate() APIによってブラウザレンダリングされるDOMとの差分をチェックし、差分がある場合のみコンポーネントの再描画を行います。通常、SSRされたDOMはブラウザJavaScriptの読み込みが終わった時点では差分がないため再描画は発生しません。

一番最初に表示されるページはサーバサイドでレンダリングされたもので、リンクにより遷移したページはReactRouterによってサーバへのアクセスなしで描画されたものです。

今回やりたかったけどできなかったこと

  • Reduxの導入 (redux, react-redux, react-router-reduxとかめちゃくちゃパッケージが増えて正直しんどい)
  • テスト周り (Jestというテストライブラリが流行っている(?)らしいので時間があれば試してみたい)

react_on_railsでreduxを使う

前回の続きでreduxを導入しました。

必要なnpmを追加

redux-thunkは非同期アクションを実現するパッケージなのであまり関係ありませんが、後々入れることになると思うのでついでに入れておきます。

yarn add redux react-redux react-router-redux redux-thunk

コードにstoreを追加する

サーバサイドのルーター

ブラウザのルーター

コントローラにもstoreを追加する

  # application_controller.rb
  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
      redux_store(
        "store",
        props: common_props.merge(props).as_json,
      )

      render(
        html: view_context.react_component(
          "Router",
          prerender: true,
        ),
        layout: true,
        status: status,
      )
    end
  end

テンプレートに<%= redux_store_hydration_data %>を追加。

reducerを追加

propsとrailsContextをinitialStateにぶち込み、react-router-reduxの提供しているルーティングに関するreducerと後で作るその他のreducersを全部放り込んでcombineReducersでまとめます。

  const initialState = {
    ...props,
    railsContext,
  };

  const reducer = combineReducers({
    ...reducers,
    routing: routerReducer,
  });

この時点でrailsContextReducerがないとエラーが発生するので、こいつを作っていきます。とはいっても最初のロード以外でこいつが処理を行うことは基本的にありませんのでstateをそのまま返すだけのものです。

export default function railsContextReducer(state = {}) {
  return state;
}

propsを出力すると以下のようにroutingrailsContextが含まれていることがわかります(reduxのconnectstateをそのままpropsマッピングした場合の出力)。

https://gyazo.com/b3f404fd17c025ff780762a3c04a0e97

関連記事

nukosuke.hatenablog.jp

react_on_railsとreact-routerでシングルページアプリケーションをサーバサイドレンダリングする

やりたいこと

rails + react + react-router (ゆくゆくは + redux)な環境でサーバサイドレンダリング可能なシングルページアプリケーションを作成したい。自分でも「何言ってんだお前」って感じだけど、やりたいんです。

react_on_rails

rails、react間のインテグレーションを実現するgemはreact-railsっていうのとreact_on_railsっていうのがある。react-railsの方が情報量が多かったけど、application.js

//= require react
//= require react_ujs
//= require components

ってぶちまける感じが好きじゃないのでちょっといじって投げ捨てました。そういうわけでreact_on_railsを使います。

gem 'react_on_rails', '~> 6'

インストールする。未コミットの差分があるとインストールできないので注意。

bundle exec rails g react_on_rails:install

コントローラにReactコンポーネントレンダリングするメソッド生やす。

ここから拝借したコード。下記のコードはRouterコンポーネントレンダリングする。まだそのコンポーネントを登録してないので後で{window,global}.ReactOnRailsに登録しないといけない。

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  def home
    render_for_react()
  end

  private

  # ref: http://r7kamura.hatenablog.com/entry/2016/10/10/173610
  def common_props
    {
      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

npmを足しとく

# in client/
yarn add react-router react-helmet

{server,client}Registration.jsx

それぞれサーバサイドとブライザで実行するjsをwebpackでバンドルする際のエントリファイル。 react-router使わないならデフォルトで生成されるregistration.jsxだけでいいんだけど、使う場合はそれぞれ別のRouterを用意する必要があった。この辺で頭がおかしくなってコードが釈迦になりそう。

こっちはブラウザ用のコード。

// client/app/bundles/Application/startup/clientRegistration.jsx
import React from 'react';
import { Router as ReactRouter, browserHistory } from 'react-router';
import ReactOnRails from 'react-on-rails';
import routes from '../routes/routes';

const Router = (props, railsContext) => {
  const history = browserHistory;
  return (
    <ReactRouter history={history}>
      {routes}
    </ReactRouter>
  );
};

// This is how react_on_rails can see the Router in the browser.
ReactOnRails.register({
  Router,
});

こっちはサーバサイドレンダリング用のコード。routes.jsxは両方でimportして共有している。

// client/app/bundles/Application/startup/serverRegistration.jsx
import React from 'react';
import { match, RouterContext } from 'react-router';
import ReactOnRails from 'react-on-rails';
import Helmet from 'react-helmet';
import routes from '../routes/routes';

// for header title server side rendeting on first load
// ref: http://r7kamura.hatenablog.com/entry/2016/10/10/173610
global.Helmet = Helmet;

const Router = (props, railsContext) => {
  let error;
  let redirectLocation;
  let routeProps;
  const { location } = railsContext;

  match({ routes, location }, (_error, _redirectLocation, _routeProps) => {
    error = _error;
    redirectLocation = _redirectLocation;
    routeProps = _routeProps;
  });

  if (error || redirectLocation) {
    return { error, redirectLocation };
  }

  return (
    <RouterContext {...routeProps} />
  );
};

ReactOnRails.register({
  Router,
});

webpackの設定も書き換えておく。

// client/webpack.config.js
/**
 * from
 */
entry: [
    'es5-shim/es5-shim',
    'es5-shim/es5-sham',
    'babel-polyfill',
    './app/bundles/Application/startup/registration',
],
output: {
    filename: 'webpack-bundle.js',
    path: '../app/assets/webpack',
},


/**
 * to
 */
entry: {
    vendor: [
      'es5-shim/es5-shim',
      'es5-shim/es5-sham',
      'babel-polyfill',
    ],
    app: [
      './app/bundles/Application/startup/clientRegistration',
    ],
    server: [
      './app/bundles/Application/startup/serverRegistration',
    ]
},
output: {
    filename: '[name]-bundle.js',
    path: '../app/assets/webpack',
},

これでvendor-bundle.jsapp-bundle.jsserver-bundle.jsの3つが出力されるようになった。さらに、react_on_railsの設定ファイルも書き換えておく。さっき分けたサーバサイド用のjsファイルをExecJSで実行するように指定する。

# config/initializers/react_on_rails.rb
# from
config.webpack_generated_files = %w( webpack-bundle.js )
# to
config.webpack_generated_files = %w( app-bundle.js server-bundle.js vendor-bundle.js )

# from
config.server_bundle_js_file = "webpack-bundle.js"
# to
config.server_bundle_js_file = "server-bundle.js"

turbolinksを殺す

react-routerでトランジションするSPAという状況下で、いらない子になったturbolinksを殺す。さようなら。お前に苦しめられた日々、忘れないよ。 Universalの苦しみ、こんにちは。

// app/assets/javascripts/application.js
//= require vendor-bundle
//= require app-bundle
//= require jquery
//= require jquery_ujs

もちろんGemfileからも抹殺する。それからlayoutを書き換える。

<!-- app/views/layout/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <%= server_render_js("Helmet.rewind().title.toString()") %>
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag 'application', media: 'all' %>
  </head>
  <body>
    <%= yield %>
    <%= javascript_include_tag 'application' %>
  </body>
</html>

起動

# in app root dir
foreman start -f Procfile.dev

ブラウザのインスペクタを開いてconsoleにエラー吐いてなかったら多分大丈夫。ちゃんとなってればreact-routerLinkコンポーネントで遷移した時と、URL直だたきのときで全く同じページが表示されるはず。 違うページが表示されたりエラーが出る場合は、だいたいcontrollerを作ってなかったとか、routes.rbでマウントし忘れてたとかいうオチ。

参考になったページとか

それなりに動くようになったもの

今後やりたいこと

  • 今のところdeviseがSPA構成をガン無視していてつらい。コントローラとかビューをreact_componentヘルパ向けに書き直す必要がありそう。
  • ブラウザに配信するjsの出力先を/publicにしてsproketsを使わないようにする。jqueryとかもnpmを使う。
  • jbuilderを投げ捨ててrails-apiが搭載のactive_model_serializersをpropsのシリアライズに使いたい。
  • テストの知見を貯めたい。

放映中のアニメリストを教えてくれるhubot-annictを作った

とあるエンジニア集団が集うSlackにhubotを飼うことになってアニメ関連のコマンドが欲しいというissueを立てた。hubotはcoffeeがデフォルトだけどjavascriptを使えるので去年作ったannict.jsで現在のクールに放映中のアニメリストを表示するためのhubot scriptを作った。

概要

以下のように動作する。

hubot> anime now
hubot> Shell: <a href="http://project-itoh.com/">虐殺器官</a>
<a href="http://konosuba.com/">この素晴らしい世界に祝福を!2</a>
<a href="http://www.kuzunohonkai.com/">クズの本懐</a>
<a href="http://demichan.com/">亜人ちゃんは語りたい</a>
<a href="http://masamune-tv.com/">政宗くんのリベンジ</a>
<a href="http://chaoschildanime.com/">CHAOS;CHILD</a>
<a href="http://tv.littlewitchacademia.jp/">リトルウィッチアカデミア</a>
<a href="http://www.tbs.co.jp/anime/urara/">うらら迷路帖</a>
<a href="http://youjo-senki.jp/">幼女戦記</a>
<a href="http://sao-movie.net/">劇場版 ソードアート・オンライン -オーディナル・スケール-</a>
<a href="http://www.tbs.co.jp/anime/seiren/">セイレン</a>
.
.
.

https://gyazo.com/0b6876326dbad1a5cffa38c68ebd4943

使い方

データは例のごとくAnnictから取得させていただくので、事前にAnnictの登録が必要。

Annict | アニクト - 見たアニメを記録して、共有しよう

Annictにデベロッパー登録する

Annictにはまだユーザがトークンを発行する機能が用意されていないので、デベロッパー登録を行って手動でトークンを吐き出させる。

Annictアプリケーション登録

https://gyazo.com/c5731b3ccae4da5e26a38ff6d4436ae9

登録したら[認可する]ボタンで認証コードを取得する。

Annict APIのアクセストークンを取得する

コマンドラインから以下のようにcurlでトークンを取得する。起動時に使用するのでaccess_tokenフィールドをメモっておく。

$ curl -F client_id=<アプリケーションID> \
-F client_secret=<シークレットキー> \
-F grant_type=authorization_code \
-F redirect_uri=urn:ietf:wg:oauth:2.0:oob \
-F code= <認証コード> \
-X POST https://api.annict.com/oauth/token

hubot-annictのセットアップ

hubotのルートディレクトリでインストールする。

$ yarn add hubot-annict

その後、external-scripts.json'hubot-annict'を追加。これでインストール完了。

hubot起動

環境変数HUBOT_ANNICT_TOKENに取得したAnnictトークンを設定して起動する。

$ HUBOT_ANNICT_TOKEN="<annict_access_token>" ./bin/hubot

終わりに

Annictの波、きます!

@shimbacoさん、最近海外のアニメ記録サイト大手Kitsuの中の人と仲良くなってて、ワールドワイドな協定が結ばれて欲しいと個人的な願望を抱いている。

参考

関連記事

ウェブアプリ開発日誌:テンプレートエンジンをerbからslimに変えるあれこれ

erbは入門にはわかりやすいですが慣れてくると冗長です。slimが良いらしいのでerbから入れ替えてみます。

slimをインストールする

Gemfileにslimを追加してbundle installします。

gem 'slim-rails'

erb2slimで既存のテンプレートを変換する

$ gem install html2slim
$ for i in app/views/**/*.erb; do erb2slim $i ${i%erb}slim && rm $i; done

ワンライナーapp/views/以下のファイルをまとめて変換できます。一回使ったら間違ってERBファイルを生成してしまわない限り、もう使わないのでGemfileには書かなくていいです。rbenvなどを使用していて、エラーが出る場合はrbenv rehashしてください。

deviseを使用する場合などは、

自動生成されるファイルもslimにする

config/application.rbに以下を追記する。

module MyApp
  class Application < Rails::Application
    config.i18n.default_locale = :ja
    config.generators.template_engine = :slim
  end
end

これでrails gで生成されるビューファイルもslimに変わります。

参考文献

関連記事

nukosuke.hatenablog.jp