Hotwire を試す

Hotwire を Rails 導入を試してみました。
最終的に、動作確認してみたのが以下の通りです。

以下、詳細です。

参考

実行環境

  • Ruby 3.0.0
  • Node.js 14.10.1

(すべてDockerコンテナ内で稼働)

Hotwire導入

アプリケーション作成

hotwired/hotwire-rails-demo-chat - Gemfileを参照すると、webpackerがありません。

javascriptを除外して作成します。

1
2
3
4
5
6
7
8
9
10
bundle init

# Gemfile の gem "rails" のコメントアウトを外す

bundle install
bundle exec rails new . --skip-javascript
# <= Y

# Dockerコンテナ内で起動するので -b 0.0.0.0 を付与
bundle exec rails s -b 0.0.0.0

いつもの楽しいRailsの初期画面が出ます。

Gem追加

hotwired/hotwire-rails-demo-chat - Gemfileを参考にします。

以下を Gemfile に追記し、インストールします。

1
2
# Gemfile に追記
gem 'hotwire-rails'

hotwire-rails 設定

hotwire-railsを設定します。

1
2
3
4
5
6
7
8
9
10
11
bundle exec rails turbo: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
Turbo successfully installed ⚡️

bundle exec rails turbo:installをインストールすると、表示の通りに以下のファイルに書き換えが行われていました。

  • app/views/layouts/application.html.erb
app/views/layouts/application.html.erb
1
2
3
4
5
6
7
8
     <%= csp_meta_tag %>

<%= stylesheet_link_tag 'application', media: 'all' %>
+ <%= yield :head %>
+ <%= javascript_include_tag "turbo", type: "module" %>
</head>

<body>
  • Gemfile
Gemfile
1
2
-# gem 'redis', '~> 4.0'
+gem 'redis', '~> 4.0'
  • config/cable.yml
config/cable.yml
1
2
3
4
5
6
7
 development:
- adapter: async
+ adapter: redis
+ url: redis://host.docker.internal:6379/1

test:
adapter: test

Gemfile に書き換えがあったので、再度bundle installを実行します。

アプリケーション実装

とりあえず、何か作る時はToDoというお決まり?があるので、作っていきます。

モデル作成

マイグレーションファイルを作成します。

1
bundle exec rails g migration create_memo
db/migrate/[数字列]_create_memo.rb
1
2
3
4
5
6
7
8
9
class CreateMemo < ActiveRecord::Migration[6.1]
def change
create_table :memos do |t|
t.string :scribble

t.timestamps
end
end
end

マイグレーションを実行します。

1
bundle exec rails db:migrate

モデルを作成します。

app/models/memo.rb
1
2
class Memo < ApplicationRecord
end

コントローラー作成

コントローラーを以下の通り作成します。

app/controllers/memos_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MemosController < ApplicationController
def index
@memos = Memo.all
end

def create
memo = Memo.new(memo_params)
memo.save

redirect_to action: :index
end

private
def memo_params
params.require(:memo).permit(:scribble)
end
end

viewの作成

一旦 /memosにあたる画面のみ作成します。

app/views/memos/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
<%= form_with model: Memo, url: memos_path, method: :Post ,remote: true do |f| %>
<%= f.text_field :scribble %>
<%= f.submit "追加" %>
<% end %>

<% if @memos.present? %>
<ul>
<% @memos.each do |memo|%>
<li><%= memo.scribble %></li>
<% end %>
</ul>
<% end %>

フォームの下にリストがある。いかにもなTODOです。

ルーティング設定

config/routes.rb
1
2
3
4
Rails.application.routes.draw do
resources :memos, only: [:index, :create]
root to: "memos#index"
end

一旦確認

bundle exec rails s -b 0.0.0.0 で起動してみます。
フォームに入力して送信。下のリストが更新されるのを確認できました。

動的にページ更新してないただのPOSTではないですか。
要求にもHTML全体を返しています。
Hotwire 用に書き換えます。

Hotwire を使うように書き換える

Hotwire には、3つの技術の集合として説明されています。

  • Turbo Drive
    Turbolinks の機能の進化系。
    ページ遷移をしたときに、完全なリロードをせずに、エレメントを置換する。

  • Tarbo Frames
    [新規!]リクエストに応じた動的なエレメントの書き換え。
    リダイレクトされてきたHTMLから指定した箇所を抽出し、書き換える。

  • Tarbo Streams
    [新規!]ストリームからの配信結果に基づく動的なエレメントの書き換え。

Tarbo Frames を試す

Tarbo Framesは、で包まれたエレメントの、動的書き換えです。

app/views/memos/index.html.erbを以下の通り書き換えました。

app/views/memos/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--turbo-frame で包まれていない 動的に書き換わらない-->
<%= Time.now %>

<turbo-frame id="memos">
<!--turbo-frame で包まれている 動的に書き換わる-->
<%= form_with model: Memo, url: memos_path, method: :Post do |f| %>
<%= f.text_field :scribble %>
<%= f.submit "追加" %>
<% end %>

<% if @memos.present? %>
<ul>
<% @memos.each do |memo|%>
<li><%= memo.scribble %></li>
<% end %>
</ul>
<% end %>
</turbo-frame>

こちらを実行した時の表示が次のようになります。

ややわかりにくいですが、フォームでの入力時は上に表示された時刻が書き換わらず、リロードすると書き換わります。

入力(リンクを踏むでもいいとドキュメントではされています。)に対応した動的書き換えが実現できました。

Tarbo Streams を試す

続けて Tarbo Streams を試していきます。
Tarbo Frames を混ぜると、ややこしくなるので Tarbo Streams のみを使う形に書き換えます。

コントローラーを書き換え

app/controllers/memos_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MemosController < ApplicationController
def index
@memos = Memo.all
end

def create
@memo = Memo.new(memo_params)
@memo.save

respond_to do |format|
# format.turbo_stream を 使用して
format.turbo_stream do
render turbo_stream: turbo_stream.prepend("memo-list-el", partial: "memos/scribble", locals: { memo: @memo })
end
# format.html { redirect_to action: :index }
end
end

private
def memo_params
params.require(:memo).permit(:scribble)
end
end

各引数の構成は以下のようになっています。

1
2
3
format.turbo_stream do
render turbo_stream: turbo_stream.prepend("[変更するHTMLエレメント指定]", partial: "[使用するパーシャルの指定]", locals: { "[パーシャルに渡す変数]" })
end

今回は、書き変え先のHTMLエレメントの先頭に書き足すためturbo_stream.prependを使用しています。(turbo_stream.appendであれば対象箇所の末尾に追記する。)

ビューの書き換え

件数がゼロの状態でも書き換えが発生するので、if @memos.present?の外側に、<ul id="memo-list-el">を出しました。
こうしないと書き換え先のIDを指定したエレメントがなくなってしまうからです。

app/views/memos/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<%= Time.now %>

<%= form_with model: Memo, url: memos_path, method: :Post do |f| %>
<%= f.text_field :scribble %>
<%= f.submit "追加" %>
<% end %>

<ul id="memo-list-el">
<% if @memos.present? %>
<% @memos.each do |memo|%>
<%= render partial: "scribble", :locals => { memo: memo } %>
<% end %>
<% end %>
</ul>

また、今回は@memos.eachの中のレンダリングは、パーシャルに切り出しました。

app/views/memos/_scribble.html.erb
1
<li id="scribble_<%= memo.id %>" ><%= memo.scribble %></li>

turbo_stream.prependに、渡したパーシャルに再利用してHTMLのフラグメントのレンダリングに使用するからです。

ここまでで、書き込んだタブではHTMLの書き換えが発生します。
同じページを開いている他のタブにWebSocketでの配信をするに以下のように、書き換えます。

app/views/memos/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<%= Time.now %>

<%= turbo_stream_from "memo-list" %>

<%= form_with model: Memo, url: memos_path, method: :Post do |f| %>
<%= f.text_field :scribble %>
<%= f.submit "追加" %>
<% end %>

<ul id="memo-list-el">
<% if @memos.present? %>
<% @memos.each do |memo|%>
<%= render partial: "scribble", :locals => { memo: memo } %>
<% end %>
<% end %>
</ul>

<%= turbo_stream_from "memo-list" %>を記述することで、memo-listというチャンネルに対してのWebsocketのリッスンが設定されているようです。

加えて、モデルを編集します。

app/models/memo.rb
1
2
3
4
5
class Memo < ApplicationRecord
after_create_commit do
broadcast_prepend_to "memo-list", target: "memo-list-el", partial: "memos/scribble"
end
end

Memoが作成されると、broadcast_prepend_to "memo-list", target: "memo-list-el", ~~~~~が発火します。
broadcast_prepend_to を実行したことで、memo-list チャンネル?をリッスンしている各タブに配信されます。

動作させると次のようになります。

書き込んだ側だけ、2重に配信されています。
書き込みリクエストのレスポンスと、broadcast_prepend_to での結果の配信が、重なっているためです。

Tarbo Frames と Tarbo Streams を組み合わせて対策

Tarbo Streamsを使用した結果として、2重にリストが追記されるようになってしまいました。
対策として以下を考えます。

  • 書き込みリクエストのレスポンスの反映は、Tarbo Frames を使う
  • 他のタブへの配信は Tarbo Streams を使う。

コントローラーとビューを以下の様に書き換えます。

app/controllers/memos_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MemosController < ApplicationController
def index
@memos = Memo.all.order(id: :desc)
end

def create
@memo = Memo.new(memo_params)
@memo.save

respond_to do |format|
format.html { redirect_to action: :index }
end
end

private
def memo_params
params.require(:memo).permit(:scribble)
end
end
app/views/memos/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<%= Time.now %>

<%= turbo_stream_from "memo-list" %>
<turbo-frame id="memos">
<%= form_with model: Memo, url: memos_path, method: :Post do |f| %>
<%= f.text_field :scribble %>
<%= f.submit "追加" %>
<% end %>

<ul id="memo-list-el">
<% if @memos.present? %>
<% @memos.each do |memo|%>
<%= render partial: "scribble", :locals => { memo: memo } %>
<% end %>
<% end %>
</ul>
</turbo-frame>

この修正を行った結果の動作は、次のようになります。

TODOの削除も実装

削除を通知するbroadcast_remove_toは、実は削除対象を任意のエレメントをターゲットにできません。

github - hotwired/turbo-rails - turbo-rails/app/models/concerns/turbo/broadcastable.rbを確認します。

github - hotwired/turbo-rails - turbo-rails/app/models/concerns/turbo/broadcastable.rb 抜粋
1
2
3
def broadcast_remove_to(*streamables)
Turbo::StreamsChannel.broadcast_remove_to *streamables, target: self
end

target には self を設定されています。
仕方ないので、Turbo::StreamsChannel.broadcast_remove_to を直接呼び出すようにします。

モデルを以下のように書き換えます。

app/models/memo.rb
1
2
3
4
5
6
7
8
9
class Memo < ApplicationRecord
after_create_commit do
broadcast_prepend_to "memo-list", target: "memo-list-el", partial: "memos/scribble"
end
after_destroy_commit do
# broadcast_remove_to "memo-list", target: "scribble_#{self.id}" <= できない
Turbo::StreamsChannel.broadcast_remove_to ["memo-list"], target: "scribble_#{self.id}"
end
end

コントローラーは、以下のようにdestroyに対応するメソッドを追加します。

app/controllers/memos_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MemosController < ApplicationController
def index
@memos = Memo.all.order(id: :desc)
end

def create
@memo = Memo.new(memo_params)
@memo.save

respond_to do |format|
format.html { redirect_to action: :index }
end
end

def destroy
memo = Memo.find(params[:id])
memo.destroy

respond_to do |format|
format.html { redirect_to action: :index }
end
end

private
def memo_params
params.require(:memo).permit(:scribble)
end
end

各TODOに完了ボタンをつけて削除できるようにするのでパーシャルを以下のように編集します。
(動作確認したいだけなので、レイアウトは適当です。)

app/views/memos/_scribble.html.erb
1
<li id="scribble_<%= memo.id %>" ><%= memo.scribble %><%= button_to "完了", memo_path(memo.id), method: :delete %></li>

ルーティングは、destroyを追加します。

config/routes.rb
1
2
3
4
Rails.application.routes.draw do
resources :memos, only: [:index, :create, :destroy] # <= destroy 追加
root to: "memos#index"
end

ここまで修正を行った結果の動作は、次のようになります。

TODOの追加、削除が実装できました。


今回の Hotiwire を試しましたが、JavaScriptを一切書かずに動的なコンテンツが作成できてしまいました。
既存のRailsをベースとした知識に+αでかなりのことができるようになっています。
サンプルを見ると、パラメータの設定をしないデフォルトでのパーシャルの指定をしているのですが、わかりにくさがあったのでそれぞれわざと指定しました。
結果として、細かいところの動作を触れたように感じます。

Hotwireは、Laravel向けのパッケージTurbo Laravelも公開されています。
Hotwireが、1つの思想として他言語でも盛んに行われるとサービス実装のシステム構成のトレンドが塗り替わる可能性になるやもしれません。

ではでは。