Hotwire のデモアプリを実装してみた
はじめに
昨年末 12/23 に Basecamp から Hotwire がリリースされました。
WebSocket 通信や Stimulus に興味があったので、Hotwire のデモアプリを動画を見ながら実装してみました。
前提
$ ruby -v
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin19]
$ rails -v
Rails 6.1.0
デモアプリ チュートリアル
チャットルームにメッセージを投稿するチャットアプリをつくっていきます。
rails new
$ mkdir hotwired-demo
$ cd hotwired-demo
$ gem install rails --no-document
$ rails new . --skip-javascript
--skip-javascript
を指定することで、Rails 6 から標準インストールされるようになった Webpacker 関連のファイルが作成されません。Webpacker は使わずに、従来のアセットパイプライン(Sprockets)の仕組みを使って実装していきます。
Hotwire をインストールする
hotwire-rails
と redis
gemをGemfileに追加
WebSocket 通信で Redis を使うので、redis gem もあわせて追加しておきます。
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '3.0.0'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 6.1.0'
(省略)
+ gem 'hotwire-rails'
+ gem 'redis'
(省略)
Hotwire をインストール
rails hotwire:install
コマンドでインストールします。
$ bundle install
$ bin/rails hotwire:install
Yield head in application layout for cache helper
insert app/views/layouts/application.html.erb
Add Turbo include tags in application layout
insert app/views/layouts/application.html.erb
Enable redis in bundle
gsub Gemfile
Switch development cable to use redis
gsub config/cable.yml
Copying Stimulus JavaScript
create app/assets/javascripts
create app/assets/javascripts/controllers/hello_controller.js
create app/assets/javascripts/importmap.json.erb
create app/assets/javascripts/libraries
Add app/javascripts to asset pipeline manifest
append app/assets/config/manifest.js
Add Stimulus include tags in application layout
insert app/views/layouts/application.html.erb
Turn off development debug mode
gsub config/environments/development.rb
Turn off rack-mini-profiler
gsub Gemfile
run bin/bundle from "."
app/views/layouts/application.html.erb を見ると以下のタグが追加され、Turbo と Stimulus が読み込まるようになりました。
+ <%= yield :head %>
+ <%= turbo_include_tags %>
+ <%= stimulus_include_tags %>
JavaScript 関連のファイルは app/assets/javascripts/
に生成されています。
チャットルームを実装する
まずメッセージを投稿する場所となるチャットルームを scaffold で作成します。
$ bin/rails g scaffold room name:string
$ bin/rails db:migrate
次にチャットルームに投稿するメッセージを実装していきます。
$ bin/rails g model message room:references content:text
$ bin/rails db:migrate
- resources :rooms
+ resources :rooms do
+ resources :messages
+ end
$ touch 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)
respond_to do |format|
format.html { redirect_to @room }
end
end
private
def set_room
@room = Room.find(params[:room_id])
end
def message_params
params.require(:message).permit(:content)
end
end
$ mkdir app/views/messages
$ touch app/views/messages/new.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%>
$ touch app/views/messages/_message.html.erb
<p id="<%= dom_id message %>">
<%= message.created_at.to_s(:short) %>: <%= message.content %>
</p>
チャットルームの show ページにメッセージリストを表示するようにしたら、基本のチャットアプリの完成です。
+ <div id="messages">
+ <%= render @room.messages %>
+ </div>
+ <%= link_to 'New message', new_room_message_path(@room) %>
それでは、Hotwire を使ってページの部分更新やリアルタイム更新を実装していきます。
ページの部分的な更新を行えるようにする
Hotwire に含まれる Turbo を使って、ページの部分更新を簡単に実装することができます。
Turbo とは
DHH のツイート にあるように、
- フレームワーク
- ページ変更やフォーム送信を高速化したり、複雑なページをコンポーネントに分割したり、WebSocketを介して部分的なページ更新をストリームしたりするための補完的なテクニックのセット
- すべて JavaScript を一切書かずに実現している
ということなので、SPA(Single Page Application)などを実装するときに便利そうです。
turbo_frame_tag
を追加する
部分更新する箇所に Turbo を適用した部分がわかりやすいように、青いボーダーで囲むようにしておきます。
turbo-frame {
display: block;
border: 1px solid blue;
}
チャットルームの show ページ上で画面遷移せずにルーム情報を変更できるようにするために、以下のように turbo_frame_tag
を追加します。
+ <%= turbo_frame_tag "room" do %>
<p>
<strong>Name:</strong>
<%= @room.name %>
</p>
<p>
<%= link_to 'Edit', edit_room_path(@room) %> |
<%= link_to 'Back', rooms_path %>
</p>
+ <% end %>
+ <%= turbo_frame_tag "room" do %>
<%= render 'form', room: @room %>
+ <% end %>
<%= turbo_frame_tag %>
からは <turbo-frame>
タグを生成します。例えば、上記 show ページのルーム情報は以下のような HTML で構成されます。
<turbo-frame id="room">
<p id="room_1">
<strong>Name:</strong>
Room1
</p>
<p>
<a href="/rooms/1/edit">Edit</a> |
<a data-turbo-frame="_top" href="/rooms">Back</a>
</p>
</turbo-frame>
そして、同じページ内でメッセージの追加もできるようにメッセージの部分にも Turbo を導入します。
- <%= link_to 'New message', new_room_message_path(@room) %>
+ <%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
+ <%= 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 %>
戻るリンクの修正
show ページの一覧画面へ戻るリンクが turbo_frame_tag
のブロックに入ったことで画面遷移ができなくなってしまいました。リンクを生成している link_to
に "data-turbo-frame": "_top"
オプションを追加して、画面遷移できるように修正します。
- <%= link_to 'Back', rooms_path %>
+ <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
メッセージのリアルタイム更新
次に、メッセージを投稿したときに画面リロードを行わずにメッセージリストに追加するようにします。
投稿したメッセージをメッセージリストに追加する
def create
@message = @room.messages.create!(message_params)
respond_to do |format|
+ format.turbo_stream
format.html { redirect_to @room }
end
end
$ touch app/views/messages/create.turbo_stream.erb
<%= turbo_stream.append "messages", @message %>
<div id="messages">
に差分のメッセージを追加するようにします。
メッセージを投稿した後に入力フォームをリセットする
メッセージ投稿した後に、入力フィールドに値が残ったままになってしまっている問題を解決します。
Stimulus を使って、メッセージ投稿の Submit の後にフォームをリセットする処理を実装します。
$ touch app/assets/javascripts/controllers/reset_form_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
reset() {
this.element.reset()
}
}
<%= 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 %>
WebSocket のコネクションを追加する
投稿したメッセージを、チャットルームにアクセスしている複数のクライアントにリアルタイムで配信できるようにします。
turbo_stream_from
をビューに追加することで WebSocket のストリームが用意されます。
<p id="notice"><%= notice %></p>
+ <%= turbo_stream_from @room %>
<%= turbo_frame_tag "room" do %>
...
投稿したメッセージをブロードキャストする
メッセージを新規登録したら room
のストリームに配信するコールバックを Message モデルに追加します。
class Message < ApplicationRecord
belongs_to :room
after_create_commit -> { broadcast_append_to room }
end
Message モデルのコールバックですので、例えば rails console
上からメッセージを登録した場合も各クライアントに配信されます。
> Room.first.messages.create(content: "Hello!")
不要になった turbo_stream.append
を削除する
すでに app/views/messages/create.turbo_stream.erb で投稿したメッセージをチャットルームのメッセージリストに追加する処理を入れていました。
そのため Message モデルのコールバックでメッセージが配信されると、投稿した画面上では新しいメッセージが 2 重に表示されてしまいます。app/views/messages/create.turbo_stream.erb の turbo_stream.append
の処理は不要なので削除します。
- <%= turbo_stream.append "messages", @message %>
+ <% # Return handled by cable %>
メッセージの更新や削除もリアルタイムで反映する
メッセージの削除や更新のときにブロードキャストするためのコールバックもあります。
after_create_commit -> { broadcast_append_to room }
+ after_destroy_commit -> { broadcast_remove_to room }
+ after_update_commit -> { broadcast_replace_to room }
after_create_commit
after_update_commit
after_destroy_commit
をまとめて1行で定義することもできます。
+ broadcasts_to :room
- after_create_commit -> { broadcast_append_to room }
- after_destroy_commit -> { broadcast_remove_to room }
- after_update_commit -> { broadcast_replace_to room }
チャットルームのリアルタイム更新
最後にチャットルーム情報もリアルタイム更新できるようにしておきます。
class Room < ApplicationRecord
has_many :messages
+ broadcasts
end end
broadcasts
は Message モデルで登場した broadcasts_to
と同様に、after_create_commit
after_update_commit
after_destroy_commit
の処理を提供してくれます。
そしてルーム情報を部分テンプレートにします。
$ touch app/views/rooms/_room.html.erb
<p id="<%= dom_id room %>">
<strong>Name:</strong>
<%= room.name %>
</p>
<%= turbo_frame_tag "room" do %>
- <p>
- <strong>Name:</strong>
- <%= @room.name %>
- </p>
+ <%= render @room %>
(省略)
<% end %>
所感
Hotwire は、JavaScript をほとんど書かずにページの部分更新やリアルタイム更新が実装できるとても便利な機能でした。
WebSocket などの仕組みを理解していなくても実装できてしまうので、自分で基本的な仕組みくらいは把握した上で使いたいなあと思いました。
Discussion