モチベーション
- Rails 5.1 がリリースされて webpacker が入りました
- 5.1 もある程度リリースが重なってきたので、まともに使えると思うので使ってみたい
- 2018/4/4 時点で 5.1.6 が最新 ( https://github.com/rails/rails/releases )
- ついでに API mode も試せるといいなぁ
というわけで、1時間くらいで試した記録を残しておくことにしました。
追加記事
- 今回の実装に JWT 認証を追加した記事を書きました
- Rails 5.1 API mode + webpacker + react + reactstrap な ToDO アプリに認証機能を追加する (sorcery gem で JWT)
対象読者
- Rails は触った事があるが、Rails 5.1 の webpacker は触ったことがない人
- フロントエンド技術を(軽く)触ってみたい人
- コードサンプルの意味を知るにはある程度 React やっておいた方がスムーズかもしれません
- 意味分からなくても後で調べればきっと分かるよ!
作業場所(Github)
実行環境
- Mac OS X (もしくは Linux でも)
- Ruby 2.5.1
- rbenv や rvm などでセットアップが終わっている前提とします
- bundler はインストール済み (
gem install bundler
)
デモ
-
機能
- タスクの作成
- タスクの削除
-
UI は Bootstrap 4 + reactstrap を利用
-
デモ上だと分かりづらいですが、 React で SPA っぽく動いています
-
POST
メソッドを Rails 側に送信してタスク作成 -
削除時には
DELETE
メソッドでタスクの削除を実現しています
プロジェクトの準備
Rails をインストール
プロジェクトディレクトリの作成 & bundle init
でGemfile の作成
$ mkdir rails_react_tutorial
$ cd rails_react_tutorial
$ bundle init
Gemfile に gem "rails", "~> 5.1.0"
を追記
+gem 'rails', '~> 5.1.0'
Rails を vendor/bundle ディレクトリ以下にインストール
$ bundle install --path=vendor/bundle
プロジェクトの初期化
インストールが完了したら、bundle exec rails new
コマンドでプロジェクトの初期化をします
$ bundle exec rails new . --webpack=react --api
各オプションの意図
-
--api
- Rails サーバ側自体は API モードで利用する
-
--webpack=react
- React を利用するので
webpack=react
を指定 - angular や vue.js なども指定できます
- React を利用するので
コマンド実行時に以下のような出力がされますが、上書きしても問題ないので Y で進めます
[XXXX@XXXXX:~/work/rails_react_tutorial]$ bundle exec rails new . --webpack=react --api
exist
create README.md
create Rakefile
create config.ru
create .gitignore
conflict Gemfile
Overwrite /Users/XXXXX/work/rails_react_tutorial/Gemfile? (enter "h" for help) [Ynaqdh] Y
インストールまで完了すると rails
コマンドなどが使えるようになっていると思います
興味がある方は ./bin/rake -T
などをしてタスク一覧を見てみると面白いと思います
React アプリを Rails 側で配信する環境を整える
- Rails 側は API mode で作成しているため、デフォルトでは HTML を配信しません
- React アプリを配信するためのページが必要なので、セットアップを行っていきます
Controllerを作成
$ ./bin/rails g controller Home index
config/routes.rb
に home#index
が root になるように設定
Rails.application.routes.draw do
+ root 'home#index'
get 'home/index'
HomeController を以下のように変更する
-class HomeController < ApplicationController
+class HomeController < ActionController::Base
+ include ActionController::RequestForgeryProtection
+ include ActionController::ImplicitRender
+ include ActionView::Layouts
+
+ layout "application"
+
def index
end
end
- ApplicationController を継承すると JSON を返してしまうので
ActionController::Base
を継承させます - 通常の ActionView を HomeController 内のみで動かすために
include ActionView::Layouts
などを追記しています
app/views/layouts/application.html.erb
を作成
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
<title>SampleView</title>
<%= csrf_meta_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
app/views/home/index.html.erb
を以下の内容で作成します
# まずはディレクトリを作っておく
$ mkdir app/views/home
<!-- この内容で app/views/home/index.html.erb を作成 -->
<%= javascript_pack_tag 'hello_react' %>
- hello_react というファイル名が出てきましたが、これは
app/javascript/packs
以下にあります - React でコンポーネントを作成する場合には以後
app/javascript/packs
に作成していきます
無事セットアップできたか確認する
Rails 側を起動します
$ ./bin/rails s
webpack-dev-server の起動が必要なため、別ターミナルなどで起動します
$ ./bin/webpack-dev-server
http://localhost:3000
にブラウザでアクセスして Hello React!
と表示されれば、成功です
アプリを作る
- やっと本筋に来ました
- まずは API 側で CRUD ができるエンドポイントを作って、その後にフロントエンド(React)側を作り込んでいきます
Rails API(バックエンド)側の実装
モデルを作成
# Task モデルを作成
$ ./bin/rails g model task title:string description:string
# migrate して Task を DB に反映する
$ ./bin/rails db:migrate
コントローラを作成
$ ./bin/rails g controller Api::V1::Tasks
Running via Spring preloader in process 15338
create app/controllers/api/v1/tasks_controller.rb
app/controllers/api/v1/tasks_controller.rb
に CRUD 用のアクションを一気に定義します
class Api::V1::TasksController < ApplicationController
before_action :set_task, only: [
:show, :update, :destroy
]
def index
render json: Task.order(created_at: :desc).all
end
def create
@task = Task.create!(task_params)
render json: @task
end
def show
render json: @task
end
def update
@task.update(task_params)
render json: @task
end
def destroy
@task.destroy
head :no_content
end
private
def task_params
params.permit(:title, :description)
end
def set_task
@task = Task.find(params[:id])
end
end
TasksController
にエンドポイントとなるアクションを一通り作ったので、 config/routes.rb
に resources :tasks
を追記します
namespace :api, {format: 'json'} do
namespace :v1 do
resources :tasks
end
end
API からデータを取得できるように rails console
でデータを登録しておきます
$ ./bin/rails c
Running via Spring preloader in process 17913
Loading development environment (Rails 5.1.6)
irb(main):001:0> Task.create(title: "foo", description: "bar")
curl http://localhost/api/v1/tasks
をして作成したデータが返ってくれば OK です
また、 Task エントリの作成は以下のコマンドで確認できます
curl -H 'Content-Type:application/json' \
-d '{"title":"title1","description":"desc1"}' \
http://localhost:3000/api/v1/tasks
React アプリ(フロントエンド側)の実装
- 見た目は無骨になりますが、Bootstrap の適用前に骨組みを作っていきます
- まずはディレクトリ構成を決めます
- ここでは最初に完成形の構造を晒してしまいます
-
app/javascript
以下にcomponents
ディレクトリを作成し、React のコンポーネントを格納するようにし、application.js
ではapp.jsx
を import するようにします
├── components
│ ├── app.jsx
│ ├── header.jsx
│ ├── task-form.jsx
│ ├── task-row.jsx
│ └── task-table.jsx
└── packs
└── application.js
まずは、 app/views/home/index.html.erb
を以下のように修正します
<div id='example-app'></div>
<%= javascript_pack_tag 'application' %>
上記の <div id=...
の箇所が React アプリケーションがレンダリングされる場所です。
補足ですが、<%= javascript_pack_tag ...
の箇所は、前述では hello_react
を試しましたが、React の最初の起点は app/javascript/packs/application.js
としたいので、application
にしています。
各コンポーネントのコードと解説
- ここではコードの例を示しつつ、インラインでコメントをしたほうが分かりやすいかな、と思ったので一気にコードを貼り付けています
console.log('Hello World from Webpacker')
import React from 'react';
import ReactDOM from 'react-dom';
import App from '../components/app';
document.addEventListener('DOMContentLoaded', () => {
ReactDOM.render(
<App />, // 独自に記述した App クラス (../components/app.jsx)を指定
document.getElementById('example-app') // example-app をマウントポイントにする
)
})
import React from 'react';
import Header from './header';
import TaskForm from './task-form';
import TaskTable from './task-table';
class App extends React.Component {
constructor(props) {
super(props);
// タスク一覧を格納する配列を state として初期化
this.state = {
tasks: [],
}
// タスクを取得するメソッドを this に bind
this.getTasks = this.getTasks.bind(this);
}
componentDidMount() {
// コンポーネントマウント時にタスク一覧を取得する
this.getTasks()
}
getTasks() {
// Rails 側の /api/v1/tasks に GET リクエストを送ってタスク一覧を取得
let request = new Request('/api/v1/tasks', {
method: 'GET',
headers: new Headers({
'Content-Type': 'application/json'
})
});
fetch(request).then(function (response) {
return response.json();
}).then(function (tasks) {
// 取得が完了したら state にセットする
this.setState({
tasks: tasks
});
}.bind(this)).catch(function (error) {
console.error(error);
});
}
render() {
const { tasks } = this.state;
return (
<div>
<Header title='Rails 5.1 + webpacker + React + Reactstrap Example' />
<div>
{/*
* TaskForm コンポーネント起因でタスクを作成した際に
* タスク一覧を再取得するために
* getTasks メソッドを props として渡す
*/}
<TaskForm getTasks={this.getTasks} />
{/*
* TaskRow (TaskTable の中) コンポーネント起因でタスクを削除した際に
* タスク一覧を再取得するために
* getTasks メソッドを props として渡す
* tasks はタスク一覧
*/}
<TaskTable tasks={tasks} getTasks={this.getTasks} />
</div>
</div>
)
}
}
export default App;
import React from 'react';
class Header extends React.Component {
constructor(props) {
super(props);
}
render() {
// <Header title=' ... で渡された値を表示
return (
<div>
<h1>{this.props.title}</h1>
</div>
)
}
}
export default Header;
import React from 'react';
class TaskForm extends React.Component {
constructor(props) {
super(props)
// 入力フォーム(input) 用の state をセットしておく
this.state = {
title: '',
description: ''
}
// タスクを作成するメソッドを this に bind する
this.createTask = this.createTask.bind(this);
}
createTask(event) {
// Rails 側の /api/v1/tasks を POST メソッドで叩き、タスクを作成する
let request = new Request('/api/v1/tasks', {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: JSON.stringify({
title: this.state.title,
description: this.state.description
})
});
fetch(request).then(function (response) {
return response.json();
}).then((task) => {
// タスク作成が成功したら、タスク一覧を再取得する
this.props.getTasks();
}).catch(function (error) {
console.error(error);
}).finally(() => {
this.setState({
title: '',
description: ''
})
});
// preventDefault でブラウザ起因の onSubmit イベントを打ち消す
// この記述が無いとページが遷移してしまう
event.preventDefault();
}
render() {
let { title, description } = this.state;
return (
<form onSubmit={this.createTask}>
<label>Title</label>
<input
type="text" value={title}
placeholder="Title"
onChange={(e) => {
this.setState({
title: e.target.value
})
}}
/>
<label className="mr-sm-2">Description</label>
<input
type="text" value={description}
placeholder="Description"
onChange={(e) => {
this.setState({
description: e.target.value
})
}}
/>
<input type="submit" value="Create Task" />
</form>
)
}
}
export default TaskForm;
import React from 'react';
import TaskRow from './task-row'
class TaskTable extends React.Component {
constructor(props) {
super(props)
}
render() {
const { tasks, getTasks } = this.props;
// 渡された tasks を map で回し、TaskRow コンポーネントとしてまとめてレンダリング
return (
<table>
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{tasks.map(function (task, index) {
return (
<TaskRow
key={index}
id={task.id}
title={task.title}
description={task.description}
getTasks={getTasks}
/>);
}.bind(this))}
</tbody>
</table>
);
}
}
export default TaskTable;
import React from 'react';
class TaskRow extends React.Component {
constructor(props) {
super(props);
this.deleteTask = this.deleteTask.bind(this);
}
deleteTask(id) {
// Rails 側の /api/v1/tasks/{taskID} を DELETE メソッドで叩き、Task の削除を実行する
let request = new Request(`/api/v1/tasks/${this.props.id}`, {
method: 'DELETE',
headers: new Headers({
'Content-Type': 'application/json'
}),
});
fetch(request).then(function (response) {
return response;
}).then(() => {
// DELETE 完了後に再度タスク一覧を取得
this.props.getTasks();
}).catch(function (error) {
console.error(error);
});
}
render() {
return (
<tr>
<td>{this.props.title}</td>
<td>{this.props.description}</td>
<td>
<a href="#" onClick={() => this.deleteTask(this.props.id)}>Delete</a>
</td>
</tr>
)
}
}
export default TaskRow;
reactstrap + bootstrap 4 導入
yarn
で reactstrap
と bootstrap
をインストールします
$ bundle exec yarn add reactstrap bootstrap@4.0.0
bootstrap を import するために、 app/javascript/packs/
以下に bootstrap.scss
を作成します
@import '~bootstrap/dist/css/bootstrap';
筆者はサクッと導入できると思っていましたが、ここで一工夫必要になります。
bootstrap 4 系は(まだ?) jQuery やその他の JS に依存しているため、 config/webpack/environment.js
を以下のようにする必要があります
const { environment } = require('@rails/webpacker')
const webpack = require('webpack')
environment.plugins.append('Provide', new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
Popper: ['popper.js', 'default']
}))
module.exports = environment
最後に app/views/layouts/application.html.erb
に以下のタグを追記します
+ <%= stylesheet_pack_tag 'bootstrap' %>
これで Rails 側から bootstrap が使えるようになりました。
上手く行っていれば、 React 側のタグに className
などで bootstrap 4 のクラスを書くと適用されると思います。
適用例として https://github.com/kaishuu0123/rails5.1-react-reactstrap-example にコードを配置しましたので、参考になれば幸いです
所感
- 意外と手軽にフロントエンド開発をすることができて驚いています
- 一人プロジェクトで使うには十分実用的
- コマンドが多いのでメモを残すのは結構辛かった(´-﹏-`;)
- けど、実際にやってみると意外とサックリできると思います
- 考えられる欠点
- フロントエンドの技術が Rails プロジェクト内に混ざるので babel や webpack etc ... を理解していないと使うのは難しそう
- チーム開発になるとフロントエンドとバックエンドは分けて考えた方がメリットある ... のかなぁ?
- Turbolinks と連携するとなると辛そう
他にも色々試してみたいこと
- Redux や React Router 導入
- Sorcery gem を使って jwt 認証
- React 以外も試してみたい (Angular, Vue.js)
- 本番デプロイ方法の検討 (asset:precompile との連携など)
- 今回は Rails 側に HTML の配布まで実装したが webpack-dev-server がせっかくあるのでもっと独立して フロントエンド/バックエンドで開発が分けられるか調べてみる