Hotwire のデモアプリを実装してみた

11 min read読了の目安(約10400字

はじめに

昨年末 12/23 に Basecamp から Hotwire がリリースされました。

WebSocket 通信や Stimulus に興味があったので、Hotwire のデモアプリを動画を見ながら実装してみました。

cobachie/hotwired-demo

前提

$ 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-railsredis gemをGemfileに追加

WebSocket 通信で Redis を使うので、redis gem もあわせて追加しておきます。

hotwired/hotwire-rails

Gemfile
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 が読み込まるようになりました。

app/views/layouts/application.html.erb
+    <%= 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
config/routes.rb
- resources :rooms
+ resources :rooms do
+   resources :messages
+ end
$ touch app/controllers/messages_controller.rb
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
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
app/views/messages/_message.html.erb
<p id="<%= dom_id message %>">
  <%= message.created_at.to_s(:short) %>: <%= message.content %>
</p>

チャットルームの show ページにメッセージリストを表示するようにしたら、基本のチャットアプリの完成です。

app/views/rooms/show.html.erb
+ <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 を適用した部分がわかりやすいように、青いボーダーで囲むようにしておきます。

app/assets/stylesheets/application.css
turbo-frame {
    display: block;
    border: 1px solid blue;
}

チャットルームの show ページ上で画面遷移せずにルーム情報を変更できるようにするために、以下のように turbo_frame_tag を追加します。

app/views/rooms/show.html.erb
+ <%= 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 %>
app/views/rooms/edit.html.erb
+ <%= 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 を導入します。

app/views/rooms/show.html.erb
- <%= link_to 'New message', new_room_message_path(@room) %>
+ <%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
app/views/messages/new.html.erb
+ <%= 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" オプションを追加して、画面遷移できるように修正します。

app/views/rooms/show.html.erb
- <%= link_to 'Back', rooms_path %>
+ <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>

メッセージのリアルタイム更新

次に、メッセージを投稿したときに画面リロードを行わずにメッセージリストに追加するようにします。

投稿したメッセージをメッセージリストに追加する

app/controllers/messages_controller.rb
  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
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
app/assets/javascripts/controllers/reset_form_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  reset() {
    this.element.reset()
  }
}
app/views/messages/new.html.erb
<%= 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 のストリームが用意されます。

app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

+ <%= turbo_stream_from @room %>

<%= turbo_frame_tag "room" do %>
...

投稿したメッセージをブロードキャストする

メッセージを新規登録したら room のストリームに配信するコールバックを Message モデルに追加します。

app/models/message.rb
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 の処理は不要なので削除します。

app/views/messages/create.turbo_stream.erb
- <%= turbo_stream.append "messages", @message %>
+ <% # Return handled by cable %>

メッセージの更新や削除もリアルタイムで反映する

メッセージの削除や更新のときにブロードキャストするためのコールバックもあります。

app/models/message.rb
  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行で定義することもできます。

app/models/message.rb
+  broadcasts_to :room
-  after_create_commit -> { broadcast_append_to room }
-  after_destroy_commit -> { broadcast_remove_to room }
-  after_update_commit -> { broadcast_replace_to room }

チャットルームのリアルタイム更新

最後にチャットルーム情報もリアルタイム更新できるようにしておきます。

app/models/room.rb
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
app/views/rooms/_room.html.erb
<p id="<%= dom_id room %>">
  <strong>Name:</strong>
  <%= room.name %>
</p>
app/views/rooms/show.html.erb
<%= turbo_frame_tag "room" do %>
-  <p>
-    <strong>Name:</strong>	
-    <%= @room.name %>	
-  </p>
+  <%= render @room %>

  (省略)
<% end %>

所感

Hotwire は、JavaScript をほとんど書かずにページの部分更新やリアルタイム更新が実装できるとても便利な機能でした。

WebSocket などの仕組みを理解していなくても実装できてしまうので、自分で基本的な仕組みくらいは把握した上で使いたいなあと思いました。