スキルアップの目的でRailsとReactを使ってサーバーサイドレンダリングやってみた(Redux,react-routerも)

RailsとReactを使ってサーバーサイドレンダリングやってみた

スキルアップの目的でRailsとReactを使ってSSR(サーバーサイドレンダリング)やってみたいなと思い、調べながらやってみました。とりあえず動くものはできたので個人的にはいいかなと思うんですが、至らないとこも多々あると思うのでその辺りは多めにみていただければと思います。参考までに手順を載せますので何かのお役に立てれば嬉しいです。

Ads

Contents

環境について

rails (5.1.3)

はじめに

今回スキルアップの名目でやりたかったのは
– Ruby on RailsとReactでサーバーサイドレンダリング
– Reduxとreact-routerも使う

です。サーバーサイドレンダリングがおもなやりたいことなんですけど、それに組み合わせてReduxでstate管理&router処理をフロントにももたせたいっていう感じです。結論からいうとサーバーサイドレンダリングはあっという間にできました。実はreact-routerと絡めるとめんどくさかったです。

あと注意点としてはgit管理されていないとだめです。なので、適当にリポジトリを作って置くようにしてください。

インストールからブラウザ表示まで

とりあえず適当なローカルのディレクトリで

rails new .

これでRailsの環境ができました。

今回はreact_on_railsを使ってRailsにreactを乗っける感じで作ります。他にもあるがこれが良さそうでした。
foremanも使ってプロセスの起動を楽にします。

react_on_railsを追加する

react_on_railsのgemをインストールするのでGemfileに追記して、`bundle install’します。

gem 'react_on_rails'
gem 'foreman'

react_on_railsのgeneratorを使ってReactをインストールします。

rails g react_on_rails:install

するとGitにコミットしろと出るのでしょうがないからコミット&プッシュします。

ERROR: You have uncommitted code. Please commit or stash your changes before continuing
ERROR: react_on_rails generator prerequisites not met!
rails g react_on_rails:install

無事インストールが完了すると以下のファイルが出来上がっています。はじめからwebpack.configがあったりpackage.jsonに必要なパッケージが記載されていたりと至れり尽くせり。

route  get 'hello_world', to: 'hello_world#index'
append  .gitignore
create  client/app/bundles/HelloWorld/components
create  client/app/bundles/HelloWorld/containers
create  client/app/bundles/HelloWorld/startup
create  app/controllers/hello_world_controller.rb
create  config/webpacker_lite.yml
create  client/.babelrc
create  client/webpack.config.js
create  client/REACT_ON_RAILS_CLIENT_README.md
create  app/views/layouts/hello_world.html.erb
create  config/initializers/react_on_rails.rb
create  Procfile.dev
create  client/package.json
append  Gemfile
create  client/app/bundles/HelloWorld/components/HelloWorld.jsx
create  client/app/bundles/HelloWorld/startup/registration.jsx
create  app/views/hello_world/index.html.erb

で、実はGemfileに以下の記述が勝手に追記されています。必要なものになるのでまたbundle installします。

gem 'mini_racer', platforms: :ruby
gem 'webpacker_lite'

今度はフロントで必要なものをインストールします。clientディレクトリに移動をして

$ yarn

これでフロントサイドで必要なReactなど諸々がインストールされます。簡単ですね。

ブラウザで確認する

ここまでやったら一旦ブラウザで見てみましょう。foremanを入れているので軌道が簡単です。rootディレクトリに移動をして

foreman start -f Procfile.dev
http://localhost:3000

「Yay! You’re on Rails!」と出ればOKです。

Hello worldをみてみる

rails g react_on_rails:installをすると勝手にhello_worldという出来上がっています。とりあえずそのページをみてみましょう。

http://localhost:3000/hello_world

なにやらinput要素などがすでにありますね。次にRails側のapp/views/hello_world/index.html.erbをエディターで開いてください。

<%= react_component("HelloWorld", props: @hello_world_props, prerender: true) %>

するとこんな感じで書かれていますね。実はすでにサーバーサイドレンダリングはできる状態です。prerenderというのがあってこれをtrueにするだけでできます。
(falseの場合はtrueに変えてくださいね。)

ブラウザからソースを見てみるとこんな感じです。サーバーからレンダリングされているのがわかります。

<div id="HelloWorld-react-component-c6affa65-0f1a-4e9d-ad58-6162bacda2e0"><div data-reactroot="" data-reactid="1" data-react-checksum="-1916372543"><h3 data-reactid="2"><!-- react-text: 3 -->Hello, <!-- /react-text --><!-- react-text: 4 -->Stranger<!-- /react-text --><!-- react-text: 5 -->!<!-- /react-text --></h3><hr data-reactid="6"/><form data-reactid="7"><label for="name" data-reactid="8">Say hello to:</label><input type="text" id="name" value="Stranger" data-reactid="9"/></form></div></div>

ちなみにサーバーサイドレンダリングしていないとwrapする要素だけがあって中身はなにもありませんね。

<div id="HelloWorld-react-component-005d8215-41b0-4a8f-a067-4a490ebe5dab"></div>

っとここまでやった段階で今回の主な目的のサーバーサイドレンダリングはできちゃいました。あっという間。

Reduxでstate管理してみる

Reduxを入れてみます。それだけでもよかったんですが、ディレクトリ構成も若干変えようかなと。
個人的にactionとreducerが別れているのが若干やりにくくて、まあ、慣れの問題なんですけど、基本的に自分で開発するときはその辺をまとめるようにしています。

詳しくは下記で
React+ReduxプロジェクトでWEBサービスをローンチするときに参考になる記事 10選 | ichimaruni-design

ディレクトリ変更とファイル名の変更

今回のディレクトリ構成は以下のようにしました。redux/modules内のファイルでactionをreducerをまとめたものを収納します。

└── App
    ├── containers
    │   └── HelloWorld.js
    ├── redux
    │   ├── ConfigureStore.js
    │   ├── RootReducer.js
    │   └── modules
    │       └── HelloWorld.js
    └── startup
        ├── HelloWorldApp.js
        └── registration.jsx

ついでにbundleするjsファイルがHelloWorldが気に入らないのでAppに変更します。

client/app/bundles/HelloWorldとなっているのでclient/app/bundles/Appに変更

次にclient/webpack.config.jsでbundleするファイルのパスを修正します。

entry: {
  'webpack-bundle': [
    'es5-shim/es5-shim',
    'es5-shim/es5-sham',
    'babel-polyfill',
    './app/bundles/App/startup/registration', // Appに変更
  ],
},

Reduxのインストール

Reduxのインストールをするのでclientディレクトリに移動をして

yarn add react-redux redux

Redux用のコーディング

Rails側

app/views/layouts/application.html.erb

ここは後々行うSSRに必要な記述を追加しておきます。
redux_store_hydration_dataヘルパーを呼び出し、redux(store)にアクセスできるようにします。

<%= yield %>の下に追加

<body>
  <%= yield %>
  <%= redux_store_hydration_data %> // 追記
</body>

app/controllers/hello_world_controller.rb

# frozen_string_literal: true

class HelloWorldController < ApplicationController
  layout "hello_world"

  def index
    @hello_world_props = { helloWorld: {name: "Stranger" }}
  end
end

@hello_world_propsapp/views/hello_world/index.html.erb内で呼び出されます。

<%= react_component("HelloWorld", props: @hello_world_props, prerender: true) %>

それによりReact側でもstate.helloWorld.nameで呼び出せるようになります。

/containers/HelloWorld.js

import {bindActionCreators} from 'redux'
import React, {Component} from 'react'
import {connect} from 'react-redux'
import PropTypes from 'prop-types'

class HelloWorld extends Component {
  static propTypes = {
  }

  constructor(props) {
    super(props)
  }


  render() {
    return (
      <div>
      Hello {this.props.name} !!
    </div>
  )
  }
}


function mapStateToProps(state) {
  return {
    name: state.helloWorld.name
  }
}

function mapDispatchToProps(dispatch) {
  return bindActionCreators(Object.assign({}), dispatch)
}

export default connect(mapStateToProps, mapDispatchToProps)(HelloWorld);

redux/modules/HelloWorld.js

この辺のものはなにも使っていませんが、サンプルとして載せておきます。

export const HELLO_WORLD_NAME_UPDATE = 'HELLO_WORLD_NAME_UPDATE'

export function updateName(text) {
  return {
    type: HELLO_WORLD_NAME_UPDATE,
    text: text
  }
}

// Reducer
export function helloWorldReducer(state = {name: ''}, action) {
  const {type} = action
  switch (type) {
    case HELLO_WORLD_NAME_UPDATE:
      return Object.assign({}, state, {name: action.text})
    default:
      return state
  }
}

redux/ConfigureStore.js

import { createStore, applyMiddleware, compose } from 'redux'
import RootReducer from './RootReducer'

const configureStore = (railsProps) => (
  createStore(RootReducer, railsProps)
)

export default configureStore

redux/RootReducer.js

import {combineReducers} from "redux"
import {helloWorldReducer} from "./modules/HelloWorld"

export default combineReducers({
  helloWorld: helloWorldReducer,
})

startup/HelloWorldApp.js

import React from 'react'
import { Provider } from 'react-redux'

import configureStore from '../redux/ConfigureStore'
import HelloWorld from '../containers/HelloWorld'

const HelloWorldApp = (props, _railsContext) => (
  <Provider store={configureStore(props)}>
    <HelloWorld />
  </Provider>
)

export default HelloWorldApp

startup/registration.js

jsxjsにします。ま、やらなくてもいいんだけど、これだけjsxなのは気持ち悪いんで。

import ReactOnRails from 'react-on-rails';

import HelloWorld from './HelloWorldApp';

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

ブラウザで確認

Hello Stranger !!と出ていれば成功です。

foreman start -f Procfile.dev
http://localhost:3000/hello_world

React-router使ってみる

ここが一番大変でした。サーバー側とフロント側でそれぞれビルドファイルが必要になります。
今回は、SampleページとHelloページを行き来できるようにします。

もろもろインストール&準備

sample用のコントローラを作るのでrootディレクトリで

rails generate controller Sample

次にreact-routerをフロントに入れるのでclientディレクトリで

yarn add react-router react-router-dom react-router-redux

app/controllers/sample_controller.rb

class SampleController < ApplicationController
  def index
    @sample_props = { sample: {name: "Sample" }}
  end
end

app/controllers/hello_world_controller.rb

ここではlayout "hello_world"となっているとsampleと同じlayoutを使えないので消します。
消しておけばsampleもhello_worldもapp/views/layouts/application.html.erbをlayoutを共通のものを使えます。

# frozen_string_literal: true

class HelloWorldController < ApplicationController
  // layout "hello_world" 消す

  ・・・
end

app/views/sample/index.html.erb

サーバサイドレンダリングできるようにしておきます。

<%= react_component("Sample", props: @sample_props, prerender: true) %>

config/routes.rb

rails側のルーターにもsampleを追記します。なくてもSPAとしては機能しなくもないですがsampleページでリロードするとエラーになります。

get 'sample', to: 'sample#index'

client/app/bundles/App/containers/HelloWorld.js

import {Link} from 'react-router-dom'してsampleへのリンクを作ります。

import {bindActionCreators} from 'redux'
import React, {Component} from 'react'
import {connect} from 'react-redux'
import PropTypes from 'prop-types'
import {Link} from 'react-router-dom'

class HelloWorld extends Component {
  static propTypes = {
  }

  constructor(props) {
    super(props)
  }


  render() {
    return (
      <div>
        Hello {this.props.name} !!
        <p><Link to="/sample">Sampleへ</Link></p>
      </div>
    )
  }
}


function mapStateToProps(state) {
  return {
    name: state.helloWorld.name
  }
}

function mapDispatchToProps(dispatch) {
  return bindActionCreators(Object.assign({}), dispatch)
}

export default connect(mapStateToProps, mapDispatchToProps)(HelloWorld);

containers/Sample.js

こちらも同じように

import {bindActionCreators} from 'redux'
import React, {Component} from 'react'
import {connect} from 'react-redux'
import PropTypes from 'prop-types'
import {Link} from 'react-router-dom'

class Sample extends Component {
  static propTypes = {
  }

  constructor(props) {
    super(props)
  }


  render() {
    return (
      <div>
        Sample {this.props.name} !!
        <p><Link to="/hello_world">Helloへ</Link></p>
      </div>
    )
  }
}


function mapStateToProps(state) {
  return {
    name: state.sample.name
  }
}

function mapDispatchToProps(dispatch) {
  return bindActionCreators(Object.assign({}), dispatch)
}

export default connect(mapStateToProps, mapDispatchToProps)(Sample);

redux/modules/Sample.js

必要最低限のreducerだけ用意しておきます。

// Reducer
export function sampleReducer(state = {name: ''}, action) {
  const {type} = action
  switch (type) {
    default:
      return state
  }
}

redux/RootReducer.js

RootReducerにroutingとsampleを追加します。

import {combineReducers} from "redux"
import {routerReducer} from 'react-router-redux'
import {helloWorldReducer} from "./modules/HelloWorld"
import {sampleReducer} from "./modules/Sample"

export default combineReducers({
  helloWorld: helloWorldReducer,
  sample: sampleReducer,
  routing: routerReducer
})

フロント側の実装

先ほどフロントとサーバーで別々のビルドファイルが必要と書きました。まずはフロント側のビルドファイルを作る準備をします。

/startup/ClientApp.js

import React from 'react'
import { Provider } from 'react-redux'
import configureStore from '../redux/ConfigureStore'

import {
  BrowserRouter as Router
} from 'react-router-dom'

import routes from './routes'

export default (props, railsContext) => (
  <Provider store={configureStore(props)}>
    <Router>
      {routes}
    </Router>
  </Provider>
)

client/app/bundles/App/startup/ClientRegistration.js

import ReactOnRails from 'react-on-rails'
import App from './ClientApp'

ReactOnRails.register({
  App
})

サーバー側の実装

次にサーバー側のビルドファイルを作る準備をします。

client/app/bundles/App/startup/ServerApp.js

import React from 'react'
import { Provider } from 'react-redux'
import { StaticRouter } from 'react-router'
import configureStore from '../redux/ConfigureStore'
import routes from './routes'

export default (props, railsContext) => {
  const store = configureStore(props)
  const { location } = railsContext
  const context = {}

  return (
    <Provider store={store}>
      <StaticRouter location={location} context={context}>
        {routes}
      </StaticRouter>
    </Provider>
  )
}

client/app/bundles/App/startup/ServerRegistration.js

import ReactOnRails from 'react-on-rails'
import App from './ServerApp'

ReactOnRails.register({
  App
})

共通で使うファイル

routes.jsは同じものが入るので共通で使えるように別ファイルにしておきます。

client/app/bundles/App/startup/routes.js

import React from 'react'
import {Route, Switch} from 'react-router-dom'

import HelloWorld from '../containers/HelloWorld'
import Sample from '../containers/Sample'

export default (
  <Switch>
    <Route exact path="/hello_world" component={HelloWorld}/>
    <Route exact path="/sample" component={Sample}/>
  </Switch>
)

webpackの修正

上記まででサーバー、フロントそれぞれのファイルを作りました。次にwebpackでそれぞれを選択してビルドできるように修正を加えます。

client/webpack.config.js

// For inspiration on your webpack configuration, see:
// https://github.com/shakacode/react_on_rails/tree/master/spec/dummy/client
// https://github.com/shakacode/react-webpack-rails-tutorial/tree/master/client

const webpack = require('webpack');
const { resolve } = require('path');

const ManifestPlugin = require('webpack-manifest-plugin');
const webpackConfigLoader = require('react-on-rails/webpackConfigLoader');

const configPath = resolve('..', 'config');
const { devBuild, manifest, webpackOutputPath, webpackPublicOutputDir } =
  webpackConfigLoader(configPath);

const config = {

  context: resolve(__dirname),

  entry: {
    vendor: [
      'es5-shim/es5-shim',
      'es5-shim/es5-sham',
      'babel-polyfill',
    ],
    client: [
      './app/bundles/App/startup/ClientRegistration',
    ],
    server: [
      './app/bundles/App/startup/ServerRegistration',
    ]
  },

  output: {
    // Name comes from the entry section.
    filename: '[name]-bundle.js',

    // Leading slash is necessary
    publicPath: `/${webpackPublicOutputDir}`,
    path: webpackOutputPath,
  },

  resolve: {
    extensions: ['.js', '.jsx'],
  },

  plugins: [
    new webpack.EnvironmentPlugin({
      NODE_ENV: 'development', // use 'development' unless process.env.NODE_ENV is defined
      DEBUG: false,
    }),
    new ManifestPlugin({ fileName: manifest, writeToFileEmit: true }),
  ],

  module: {
    rules: [
      {
        test: require.resolve('react'),
        use: {
          loader: 'imports-loader',
          options: {
            shim: 'es5-shim/es5-shim',
            sham: 'es5-shim/es5-sham',
          },
        },
      },
      {
        test: /\.jsx?$/,
        use: 'babel-loader',
        exclude: /node_modules/,
      },
    ],
  },
};

module.exports = config;

if (devBuild) {
  console.log('Webpack dev build for Rails'); // eslint-disable-line no-console
  module.exports.devtool = 'eval-source-map';
} else {
  console.log('Webpack production build for Rails'); // eslint-disable-line no-console
}

主な変更点としては

entry: {
  vendor: [
    'es5-shim/es5-shim',
    'es5-shim/es5-sham',
    'babel-polyfill',
  ],
  client: [
    './app/bundles/App/startup/ClientRegistration',
  ],
  server: [
    './app/bundles/App/startup/ServerRegistration',
  ]
},

output: {
  // Name comes from the entry section.
  filename: '[name]-bundle.js',

  // Leading slash is necessary
  publicPath: `/${webpackPublicOutputDir}`,
  path: webpackOutputPath,
},

entryをclientとserverで違うものを選択していますね。これでclient-bundle.jsserver-bundle.jsvendor-bundle.jsが生成されるようになります。

client/package.json

"build:production": "NODE_ENV=production webpack --config webpack.config.js",
"build:development": "webpack -w --config webpack.config.js"

config/initializers/react_on_rails.rb

rails側でも指定をします。

config.webpack_generated_files = %w( client-bundle.js server-bundle.js vendor-bundle.js )
config.server_bundle_js_file = "server-bundle.js"

app/views/layouts/application.html.erb

共通のテンプレートをapplication.html.erbにするのでこちらをReduxを使えるようになどの修正とclient-bundle.jsを呼び出せるようにします。使っていないapp/views/layouts/hello_world.html.erb削除しましょう。

<!DOCTYPE html>
<html>
<head>
  <title>ReactOnRailsWithWebpacker</title>
  <%= csrf_meta_tags %>
  <%= stylesheet_link_tag 'application', media: 'all'%>
</head>

<body>
  <%= yield %>
  <%= javascript_pack_tag 'client' %>
  <%= javascript_pack_tag 'vendor' %>
  <%= redux_store_hydration_data %>
</body>
</html>

app/views/hello_world/index.html.erb

<%= react_component("App", props: @hello_world_props, prerender: true) %>

app/views/sample/index.html.erb

<%= react_component("App", props: @sample_props, prerender: true) %>

あとはブラウザで確認してページ遷移ができていれば成功です。

foreman start -f Procfile.dev
http://localhost:3000/hello_world

とりあえず動くようになったもの

Takumi0901/rails-react-router-ssr

ここに作ったものを公開しますので、参考にしていただければと思います。
バージョンが新しくなったりするとできなくなったりする可能性もあるので、そのときは申し訳ないですが頑張っていただくしかないかなと・・・

参考にさせていただいた記事

いいなと思ったらシェアお願いします

Ads
ページの先頭へ