はじめに
12/23にRuby on Railsの生みの親:DHH氏がHotwire
という新しいgem
について言及され、それが話題になっているようだったので試してみた記事です。
Also cross-posted the screencast from the Hotwire to YT. That fucking thing nearly killed me. Hours and hours of work to get this just like I wanted it. But it's a clean 10 minutes on the main tech, then another 3 on showing it in real life. ACTION! 😄 https://t.co/0MWpEPnSzx
— DHH (@dhh) December 22, 2020
本記事ではチュートリアルとして進めつつHotwire
のデモと同じアプリを作っていきます。
また、本記事ではHotwire
を試してみて気づいたことなども紹介しつつ、Hotwire
の魅力を伝えられればと思います。
完成品
環境
以下は、僕が実際に作ったときの環境です
OS: Windows10(WSL2 + Ubuntu18.04)
Ruby: ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
Rails: Rails 6.1.0
Bundler: Bundler version 2.1.4
またHotwire
はWebsocket
を使っているのでRedis
も使用しています。
チュートリアル
rails new
何はともあれ、まずはrails new
からはじめましょう。
rails new hotwire-sample --skip-webpack-install
--skip-webpack-install
を追加しているのは、Hotwire
でWebpacker
を使わないからですね。
それと、Webpacker
がインストールされているとturbo_frame_tag
周りの挙動がおかしくなるので、それを回避するためでもあります(原因とかは調べてますが、まだわからない……)
ちなみに、僕は動画の通り進めていたつもりがturbo_frame_tag
周りの挙動でかなり頭を悩ますことになりました……。
rails new
が終了後、cd hotwire-sample
を実行してディレクトリを移動します。
cd hotwire-sample
Webpacker関連のファイルの削除
--skip-webpack-install
をつけてrails new
したんですが、Webpacker
周りのファイル(Gemfile
の記述とか)が残っているのでそれを削除していきます。
まずはapp/javascripts
ディレクトリを削除します。削除方法はrm
コマンドでも、マウスで削除でも構いません。
次に、Gemfile
に残っている以下のWebpacker
の部分を削除します。
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem 'webpacker', '~> 5.0'
削除後、bundle install
を実行するのを忘れないように気をつけてください。
bundle install
最後に、Webpacker
でビルドしたJavaScriptを読み込んでいるタグを削除します。
場所はapp/views/layouts/application.html.erb
内の<head>
タグ内にありますね。
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
ここまでの段階でWebpacker
周りの設定はきれいに削除できました!
Hotwireのインストール
ようやくHotwire
のインストールです。
Gemfile
に以下のコードを追加します。
gem 'hotwire-rails'
次に、bundle install
を実行します。
bundle install
あとは、Hotwire
をインストールしてくれるhotwire:install
を実行すればOKです!
rails hotwire:install
これでHotwire
のインストールは完了です!
roomとmessageを作成
デモではroom
とmessage
を使ってチャットルームを作っていましたので、同じようにそれぞれ作っていきます。
rails g scaffold room name:string
rails g model message room:references content:text
rails g scaffold room name:string
でroom
を作成し、rails g model message room:references content:text
でmessage
を作成してます。
次にrails db:migrate
でマイグレーションを実行します。
rails db:migrate
マイグレーション後、ルーティングを修正します。
Rails.application.routes.draw do
- resources :rooms
+ resources :rooms
+ resources :messages
+ end
# For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
次に、app/models/room.rb
にmessage
モデルへのリレーションを追加します。
class Room < ApplicationRecord
+ has_many :messages
end
まだmessage
用のコントローラーを作っていないので、それも作ります。
app/controllers/messages_controller.rb
を以下のように作成します。
class MessagesController < ApplicationController
before_action :set_room, only: %i[ new create ]
def new
@message = @room.messages.new
end
def create
@message = @room.messages.create!(message_params)
redirect_to @room
end
private
def set_room
@room = Room.find(params[:room_id])
end
def message_params
params.require(:message).permit(:content)
end
end
コントローラーはこれで出来たので、次はmessage
用のパーシャルとビューを作成します!
app/views/messages/new.html.erb
とapp/views/messages/_message.html.erb
をそれぞれ以下のように作ります!
<h1>New Message</h1>
<%= form_with(model: [ @message.room, @message ]) do |form| %>
<div class="field">
<%= form.text_field :content %>
<%= form.submit "Send" %>
</div>
<% end %>
<%= link_to 'Back', @message.room %>
<p id="<%= dom_id message %>">
<%= message.created_at.to_s(:short) %>: <%= message.content %>
</p>
最後に、app/views/rooms/show.html.erb
に以下のようにmessage
を表示する箇所とmessage
を新しく作る画面へのリンクを追加します。
<p id="notice"><%= notice %></p>
<p>
<strong>Name:</strong>
<%= @room.name %>
</p>
<%= link_to 'Edit', edit_room_path(@room) %> |
<%= link_to 'Back', rooms_path %>
+ <div id="messages">
+ <%= render @room.messages %>
+ </div>
+
+ <%= link_to "New Message", new_room_message_path(@room) %>
とりあえずここまででチャットができるようになりました!
TurboでSPA化
ここからはHotwire
を導入したことで使えるようになるTurbo
を使っていきます!
ですが、その前にどの部分でTurbo
が使われているか分かりやすくなるようにCSSを少しいじります。
app/assets/stylesheets/application.css
に以下のようにCSSを追加します。
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
* vendor/assets/stylesheets directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
*= require_tree .
*= require_self
*/
+ turbo-frame {
+ display: block;
+ border: 1px solid blue;
+ }
以下の部分を追加していますね。
turbo-frame {
display: block;
border: 1px solid blue;
}
これでTurbo
を使っている箇所は青枠で表示されるので分かりやすくなります!
それではTurbo
を使っていきます!
まずはapp/views/rooms/show.html.erb
を以下のように変更します。
<p id="notice"><%= notice %></p>
- <p>
- <strong>Name:</strong>
- <%= @room.name %>
- </p>
-
- <%= link_to 'Edit', edit_room_path(@room) %> |
- <%= link_to 'Back', rooms_path %>
+ <%= turbo_frame_tag "room" do %>
+ <p>
+ <strong>Name:</strong>
+ <%= @room.name %>
+ </p>
+
+ <%= link_to 'Edit', edit_room_path(@room) %> |
+ <%= link_to 'Back', rooms_path %>
+ <% end %>
<div id="messages">
<%= render @room.messages %>
</div>
<%= link_to "New Message", new_room_message_path(@room) %>
<%= turbo_frame_tag "room" do %>
から<% end %>
までがTurbo
が適用される範囲です!
間違ってこの範囲にmessage
関連の表示とかを含めると意図しない挙動になるので注意しましょう。
同じようにapp/views/rooms/edit.html.erb
にもturbo_frame_tag
を追加します。
<h1>Editing Room</h1>
+ <%= turbo_frame_tag "room" do %>
+ <%= render 'form', room: @room %>
+ <% end %>
<%= link_to 'Show', @room %> |
<%= link_to 'Back', rooms_path %>
ここまでの段階で、app/views/rooms/show.html.erb
でEdit
をクリックするとその部分だけが編集用のフォームに切り替わります。
このようにturbo_frame_tag
で括っている範囲内の表示をリンクを経由して差し込むことができるようです。
また引数に渡している文字列はそのturbo-frame
のid
などになっているようです。
ただし、一点注意が必要で、app/views/rooms/show.html.erb
でBack
をクリックすると一覧へともどることが出来ません。
これはturbo_frame_tag
で括られている箇所がapp/views/rooms/index.html.erb
内にないためだと思われます。
そこで、"data-turbo-frame": "_top"
をlink_to
に追加します。これはTurbo
を使った画面遷移を超えてリンク先に移動できるようにするためのようです。
<p id="notice"><%= notice %></p>
<%= turbo_frame_tag "room" do %>
<p>
<strong>Name:</strong>
<%= @room.name %>
</p>
<%= link_to 'Edit', edit_room_path(@room) %> |
- <%= link_to 'Back', rooms_path %>
+ <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
<% end %>
<div id="messages">
<%= render @room.messages %>
</div>
<%= link_to "New Message", new_room_message_path(@room) %>
これで一覧へと戻ることができるようになりました!
最後に、Turbo
を使ってmessage
を作成するフォームをapp/views/rooms/show.html.erb
に埋め込んでみます。
まずはapp/views/messages/new.html.erb
で埋め込みたいフォームの部分をturbo_frame_tag
で括ります。
先ほどまでと違うのは, target: "_top"
を追加しています。
<h1>New Message</h1>
- <%= form_with(model: [ @message.room, @message ]) do |form| %>
- <div class="field">
- <%= form.text_field :content %>
- <%= form.submit "Send" %>
- </div>
- <% end %>
+ <%= turbo_frame_tag "new_message", target: "_top" do %>
+ <%= form_with(model: [ @message.room, @message ]) do |form| %>
+ <div class="field">
+ <%= form.text_field :content %>
+ <%= form.submit "Send" %>
+ </div>
+ <% end %>
+ <% end %>
<%= link_to 'Back', @message.room %>
なぜ, target: "_top"
を追加しているのかはよくわかっていないんですが、先ほどのリンクの件のようにスコープの外からでも呼び出せる的なことなんじゃないかと……。
あとはapp/views/rooms/show.html.erb
のリンク部分を置き換えます。
<p id="notice"><%= notice %></p>
<%= turbo_frame_tag "room" do %>
<p>
<strong>Name:</strong>
<%= @room.name %>
</p>
<%= link_to 'Edit', edit_room_path(@room) %> |
<%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
<div id="messages">
<%= render @room.messages %>
</div>
<% end %>
- <%= link_to "New Message", new_room_message_path(@room) %>
+ <%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
これで、app/views/rooms/show.html.erb
を表示するとmessage
のフォームが埋め込まれています!
リアルタイム更新用の準備
今のままだとTurbo
経由でビューのHTMLがすべて受け取ったりしてmessage
を追加することになり面倒です。
そこで、追加されたmessage
の部分だけを差分で受け取ることができるように修正していきます。
また追加でその他の修正箇所も直していきます。
まずはapp/controllers/messages_controller.rb
のcreate
メソッドを以下のように修正します。
def create
@message = @room.messages.create!(message_params)
- redirect_to @room
+ respond_to do |format|
+ format.turbo_stream
+ format.html { redirect_to @room }
+ end
end
新しくformat.turbo_stream
を使えるようにしています。
次にapp/views/messages/create.turbo_stream.erb
を以下のように作成します。
<%= turbo_stream.append "messages", @message %>
これは対になるerb
ファイルを経由してid="messages"
のところに差分のHTMLだけを差し込むための修正です。
新しくmessage
が作成されるとformat.turbo_stream
を経由してapp/views/messages/create.turbo_stream.erb
にデータが渡され、最後にmessage
を表示しているところに差分が差し込まれるという流れです。
次に、新しくmessage
を作成するときに入力した文字などがリセットされていない問題を解消します。
その問題を解決するためにデモではStimulus
というJavaScriptのフレームワークを使っています。
まずapp/assets/javascripts/controllers/reset_form_controller.js
を以下のように作成します。
import { Controller } from "stimulus"
export default class extends Controller {
reset() {
this.element.reset()
}
}
これはrest()
という入力された値をリセットするメソッドを作っています。
次に、作成したreset()
をフォームで呼び出せるようにします。
app/views/messages/new.html.erb
を以下のように修正します。
<h1>New Message</h1>
<%= turbo_frame_tag "new_message", target: "_top" do %>
- <%= form_with(model: [ @message.room, @message ]) do |form| %>
+ <%= form_with(model: [ @message.room, @message ], data: { controller: "reset_form", action: "turbo:submit-end->reset_form#reset" }) do |form| %>
<div class="field">
<%= form.text_field :content %>
<%= form.submit "Send" %>
</div>
<% end %>
<% end %>
<%= link_to 'Back', @message.room %>
Stimulus
ではdata
に{ controller: "reset_form", action: "turbo:submit-end->reset_form#reset" }
のように作成したファイル名と呼び出す際のイベントと呼び出されるメソッドを指定することができます。
これでSend
をクリックすると入力内容がリセットされます!
ActionCable(websocket)でリアルタイム更新
最後にActionCable(websocket)
でリアルタイム更新ができるように修正していきます!
まずは、room
毎にActionCable(websocket)
への接続を追加します。
app/views/rooms/show.html.erb
を以下のように修正していきます。
<p id="notice"><%= notice %></p>
+ <%= turbo_stream_from @room %>
<%= turbo_frame_tag "room" do %>
<p>
<strong>Name:</strong>
<%= @room.name %>
</p>
<%= link_to 'Edit', edit_room_path(@room) %> |
<%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
<div id="messages">
<%= render @room.messages %>
</div>
<% end %>
<%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
<%= turbo_stream_from @room %>
を追加しているだけで、簡単ですね。
次に、message
が作成されたときにActionCable(Websocket)
へと配送する処理を追加します。
これも非常に簡単でapp/models/message.rb
を以下のように修正するだけです。
class Message < ApplicationRecord
belongs_to :room
+ after_create_commit -> { broadcast_append_to room }
end
これでActionCable(Websocket)
経由で新しいmessage
が配送されます。
余談ですがafter_create_commit
は新しくmessage
が作成されたイベントをフックしています。このほかにもafter_destroy_commit
やafter_update_commit
のように削除されたり、更新されたときのイベントをフックすることもできます。
なので、削除などもリアルタイムに処理することができます。
ここまでの修正でapp/views/messages/create.turbo_stream.erb
での処理は不要となっているので以下のようにコメントだけに修正します。
- <%= turbo_stream.append "messages", @message %>
+ <% # Return handled by cable %>
現状では作成されたmessage
の配送はしていますが、削除などは対応していません(after_destroy_commit
を追加していないので)
そこでそれらすべてをまとめて追加できるbroadcasts_to
をapp/models/message.rb
に追加します。
class Message < ApplicationRecord
belongs_to :room
- after_create_commit -> { broadcast_append_to room }
+ broadcasts_to :room
end
基本的に個別のイベントをフックしたいとかでもない限りはbroadcasts_to
を使うとよさそうです。
ここまでの段階でリアルタイムにmessage
をやり取りすることができます!(ただし、Redis
は必要!)
最後に、room
の情報もリアルタイムに共有できるようにします!
まずはapp/models/room.rb
に配送周りの処理を追加します。
class Room < ApplicationRecord
has_many :messages
+ broadcasts
end
追加するのはbroadcasts
だけです。
あとはapp/views/rooms/_room.html.erb
を以下のように作成し、それをapp/views/rooms/show.html.erb
でパーシャルとして呼び出すだけです。
<p id="<%= dom_id room %>">
<strong>Name:</strong>
<%= room.name %>
</p>
<p id="notice"><%= notice %></p>
<%= turbo_stream_from @room %>
<%= turbo_frame_tag "room" do %>
- <p>
- <strong>Name:</strong>
- <%= @room.name %>
- </p>
+ <%= render @room %>
<%= link_to 'Edit', edit_room_path(@room) %> |
<%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
<div id="messages">
<%= render @room.messages %>
</div>
<% end %>
<%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
これでroom
のname
を編集した内容がリアルタイムで共有されます!
所感
かなり簡単かつ手早くSPA+Websocketを使ったアプリが作れるのでこれは非常に便利!
少人数のチームでサクッと作るのにかなり向いてそうですね。
あとは、JavaScriptに強い人がいないチームとかでは重宝されそうですね!
参考
ref: 速報: Basecampがリリースした「Hotwire」の概要
ref: hotwired/hotwire-rails-demo-chat
ref: hotwired/stimulus-rails
ref: hotwired/turbo-rails
ref: hotwired/hotwire-rails
ref: Hotwire
ref: Hotwire デモ
ref: Rails環境でJS , CSSをwebpackで完全に管理する
ref: rails new with --skip-webpack-install still includes the webpacker gem. #35484
ref: How to use Hotwire in Rails