はじめに
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
