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 bundle install bundle exec rails new . --skip-javascript 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 1 2 -# gem 'redis', '~> 4.0' +gem 'redis', '~> 4.0'
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 <%= Time.now %> <turbo-frame id ="memos" > <%= 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 do render turbo_stream: turbo_stream.prepend ("memo-list-el" , partial: "memos/scribble" , locals: { memo: @memo }) end 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 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 ] root to: "memos#index" end
ここまで修正を行った結果の動作は、次のようになります。
TODOの追加、削除が実装できました。
今回の Hotiwire を試しましたが、JavaScriptを一切書かずに 動的なコンテンツが作成できてしまいました。 既存のRailsをベースとした知識に+αでかなりのことができるようになっています。 サンプルを見ると、パラメータの設定をしないデフォルトでのパーシャルの指定をしているのですが、わかりにくさがあったのでそれぞれわざと指定しました。 結果として、細かいところの動作を触れたように感じます。
Hotwireは、Laravel向けのパッケージTurbo Laravel も公開されています。 Hotwireが、1つの思想として他言語でも盛んに行われるとサービス実装のシステム構成のトレンドが塗り替わる可能性になるやもしれません。
ではでは。