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で起動します
手順
ざっくりとした手順ですが、今回やったことを要約すると以下のようになります。
- KoaでReactコンポーネントをサーバサイドレンダリングする
- ReactRouterを使ってルーティング(URLに対して描画されるべきコンポーネントの定義)を行う
- Webpackでブラウザ側のJavaScriptをバンドルする
- バンドルした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
を出力すると以下のようにrouting
とrailsContext
が含まれていることがわかります(reduxのconnect
でstate
をそのままprops
にマッピングした場合の出力)。
関連記事
react_on_railsとreact-routerでシングルページアプリケーションをサーバサイドレンダリングする
- やりたいこと
- react_on_rails
- コントローラにReactコンポーネントをレンダリングするメソッド生やす。
- npmを足しとく
- {server,client}Registration.jsx
- turbolinksを殺す
- 起動
- 参考になったページとか
- それなりに動くようになったもの
- 今後やりたいこと
やりたいこと
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.js
、app-bundle.js
、server-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-router
のLink
コンポーネントで遷移した時と、URL直だたきのときで全く同じページが表示されるはず。
違うページが表示されたりエラーが出る場合は、だいたいcontrollerを作ってなかったとか、routes.rb
でマウントし忘れてたとかいうオチ。
参考になったページとか
- Using React Router | react on rails
- react-webpack-rails-tutorial
- Ruby on Rails on React on SSR on SPA | r7kamura
それなりに動くようになったもの
今後やりたいこと
放映中のアニメリストを教えてくれる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> . . .
使い方
データは例のごとくAnnictから取得させていただくので、事前にAnnictの登録が必要。
Annict | アニクト - 見たアニメを記録して、共有しよう
Annictにデベロッパー登録する
Annictにはまだユーザがトークンを発行する機能が用意されていないので、デベロッパー登録を行って手動でトークンを吐き出させる。
登録したら[認可する]ボタンで認証コードを取得する。
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の波、きます!
国際化の波を感じる!🙏🙏🙏 / Fix some English grammar by magnonellie · Pull Request #653 · annict/annict https://t.co/X7qNLfL4PQ
— Koji Shimba (@shimbaco) January 10, 2017
Chatbotの波も感じる!!🙏🙏🙏 https://t.co/WcGOxiA7jW
— Koji Shimba (@shimbaco) January 10, 2017
国際化の流れPart2だ!🙏🙏🙏 / Fix some English grammar II by wopian · Pull Request #655 · annict/annict https://t.co/CEVXyrHsO6
— Koji Shimba (@shimbaco) January 11, 2017
@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に変わります。
参考文献
- Rails4/viewsのerbをSlimに一括変換するRubyワンライナー | もっちブログ - Webサービス開発や起業日記 / Labit 鶴田浩之の個人ブログ
- [Rails4,Rails5] erbファイルをSlimに一括変換するGemとRubyワンライナー - Qiita
- railsにslimを導入し、erbファイルをslimファイルに一括変更する方法 - Qiita