Rails7 - Hotwire
Hotwire (Stimulus + Turbo) を利用すると SPA的なアプリを簡単に作れるようになります。
全てのRails7アプリは Hotwireが入っているので、すぐに使えます。
Navigation: Turbo Drive
// app/javascript/application.js
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
App.jsを確認すると、デフォルトで @hotwired/turbo-rails
はインポートされています。
この設定だけで、全てのリンクナビゲーションはAJAXリクエストに変わります。
リンクタッグでログイン画面移動してみましょう:
<%= link_to "Log in", login_path %>
ログイン画面に移動して、ネットワークを確認したら、fetch
リクエストになっています。
特に何もせずにSPA的なナビゲーションはすでにできています。
どうやって?
リンク上の「クリック」イベントとフォーム上の「サブミット」イベントをTurboが代わりに受信します。
例えば、擬似コードとして以下のようになります:
// ページ上のすべてのリンクを選択
const links = document.querySelectorAll("a");
// 各リンクに「クリック」イベントリスナーを付けて、元の機能を切り替えます
links.forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault()
// ここからAJAXリクエストする
// リスポンスで <body></body> だけ切り替える
}
)});
// ページの上全てのフォームを選択
const forms = document.querySelectorAll("form");
// サブミットイベントをつけて、元の機能を切り替える。
forms.forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault()
// ここからAJAXリクエストする
// リスポンスで <body></body> だけ切り替える
}
)});
これはRails7アプリをスタートして、JSを書かずに成り立っています。
もちろん、Turbo Driveの機能は各リンクごと無効にできます。
(まだTurboDriveをサポートされてないGemはあるので)
<%= link_to "Log in", login_path, data: { turbo: false } %>
実際にDOMを見ると、リンクは data-turbo="false"
のアトリビュートが付けられている:
ほとんどの場合、Turbo Drive は HTML ページの <body>
だけを置き換え、<head>
は変更されません。
(Turbo DriveにWebページの <head>
の変更を認識させたい状況があります、例えばCSSや他の「asset」アプデートした時など)
実際に、app/views/layouts/application.html.erbを見ると:
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
新しい要求があるたびに、現在のHTMLページの<head>
および応答の<head>
にあるDOM要素をdata-request-track="request"
と比較します。違いがある場合は、Turbo Driveがページ全体をリロードします。
面白いのは、コードを書かずにTurboとRailsがやってくれるところです。
DOMとTurbo Frame
元のサーバアプリだけを使うと、DOM内に一個のノードをアップデートや追加するときに、ページ全体がリロードしたり、する必要があります。Turbo Frameを使うと、DOMを「Frame」の部分として分けられ、各「Frame」だけがアップデートできます。
どうやって?
Turbo Driveと同じ、AJAXでリクエストする時、TurboFrameの中のコンテンツしかサーバとやり取りしません。
簡単なイメージは:
Turboは頭いい子なので、FrameのIDが与えている部分だけ切り替えてくれます。
TurboFrameを使う際、いくつかのルールを覚えましょう:
ルール1: Turbo Frame内のリンクをクリックすると、Turboはターゲットページに同じIDのTurboFrameが表示されることを期待します。 次に、ソースページのTurbo FrameコンテンツをターゲットページのTurbo Frameコンテンツに置き換えます。
ルール2: Turbo Frame内のリンクをクリックすると、ターゲット ページに同じ ID のTurbo Frameがない場合、その Frameは消え、「応答に一致する 」 要素がコンソールにエラーとして記録されます。
ルール3: リンクは、data-turbo-frame data アトリビュートのおかげで、直接ネストされている Frame
以外の Frameもターゲットにできます。
各ルールをもっとわかるように、小さいアプリを作ってみましょう:
rails g scaffold Product title:string price:integer
ルール 1-2
<%# Product index.html.erb %>
<p style="color: green"><%= notice %></p>
<h1>Products</h1>
<div id="products">
<% @products.each do |product| %>
<%= render product %>
<p>
<%= link_to "Show this product", product %>
</p>
<% end %>
</div>
<%= turbo_frame_tag "first_turbo_frame" do %>
<%= link_to "New product", new_product_path %>
<% end %>
上記見ると、New Product
リンクを Turbo Frame (turbo_frame_tag
) にラップします。
ルール 1 - 2の通り、同じTurbo Frame IDを探して、なかったら「ない」というエラーが発生し、ページは「Content missing」が表示されます。
この解決のために、new.html.erb
のフォームも同じTurbo Frame ID でラップしてみましょう。
<%# Product new.html.erb %>
<h1>New product</h1>
<%# Product index.html.erb と同じ Turbo Frame tag id %>
<%= turbo_frame_tag "first_turbo_frame" do %>
<%= render "form", product: @product %>
<% end %>
<br>
<div>
<%= link_to "Back to products", products_path %>
</div>
これで実際に /products
の中リンクをクリックすると:
New product リンクがプロダクトフォームに切り替わります。URLもそのまま /products
になっています。
ルール 3: リンクは、data-turbo-frame data アトリビュートのおかげで、直接ネストされているフレーム以外のフレームもターゲットにできます。
<%# Product index.html.erb %>
<p style="color: green"><%= notice %></p>
<h1>Products</h1>
<div id="products">
<%= turbo_frame_tag "second_turbo_frame" do %>
<% @products.each do |product| %>
<%= render product %>
<p>
<%= link_to "Show this product", product %>
</p>
<% end %>
<% end %>
</div>
<%= turbo_frame_tag "first_turbo_frame" do %>
<%= link_to "New product", new_product_path, data: { turbo_frame: "second_turbo_frame" } %>
<% end %>
ちゃんと見ると、productsがループされて、各 productをpartialで表示します。そして、そのブロックをTurbo Frame Tagでラップします。
<%# Product new.html.erb %>
<h1>New product</h1>
<%= turbo_frame_tag "second_turbo_frame" do %>
<%= render "form", product: @product %>
<% end %>
<br>
<div>
<%= link_to "Back to products", products_path %>
</div>
product作成のフォームもsecond_turbo_frame
、同じIDで設定したら、プロダクト一覧がフォームに切り替えられます。
(もっとみやすくため、プロダクトを作っておきました)
ネストされたTurbo Frame Tagもターゲットできるし、同じIDがあれば、SPA的にDOMノードたちがページをリロードせずに切り替えてくれます。
TurboFrameでDOMアップデート
「Edit」する時にもページを移動せずにできたらもっと便利でしょう。なので、Productを「Edit」ところをTurbo Frameでやってみましょう。
「Create」フォームと同じく、「Edit」の方もTurbo Frame Tagでラップしないといけません。
今回、各プロダクトのIDで turbo_frame_tag
を作ります。ユニークなIDを持っているFrameを作らないといけません。
<%# Products edit.html.erb %>
<h1>Editing product</h1>
<%= turbo_frame_tag "product_#{@product.id}" do %>
<%= render "form", product: @product %>
<% end %>
<br>
<div>
<%= link_to "Show this product", @product %> |
<%= link_to "Back to products", products_path %>
</div>
「Edit」フォームと同じIDを持つ product partial
<%# Products _product.html.erb %>
<%= turbo_frame_tag "product_#{product.id}" do %>
<div id="<%= dom_id product %>">
<p>
<strong>Title:</strong>
<%= product.title %>
</p>
<p>
<strong>Price:</strong>
<%= product.price %>
</p>
</div>
<% end %>
最後に「Edit」ページに移動するリンクのデータアトリビュートも同じturbo_frame
に設定したら、終わりです。
<%# Products show.html.erb %>
<p style="color: green"><%= notice %></p>
<%= render @product %>
<div style="margin-top: 1rem;">
<%= link_to "Edit this product", edit_product_path(@product), data: { turbo_frame: "product_#{@product.id}" } %> |
<%= link_to "Back to products", products_path %>
</div>
「Edit」リンクは設定したturbo_frame_tag
と同じターゲットしているので、クリックをしたら、ページへの移動ではなく、Show
の render @product
部分だけ「Edit」フォームに 切り替わります。
JSを書かずにSPAができています。
Turbo ID について
Turbo Frame Tagはデフォルトで dom_id
のメソッドを使われるので、私たちが書いたコードを少しリファクタリングはできます:
例えば
<%# Products edit.html.erb %>
<h1>Editing product</h1>
<%= turbo_frame_tag @product do %>
<%= render "form", product: @product %>
<% end %>
<br>
<div>
<%= link_to "Show this product", @product %> |
<%= link_to "Back to products", products_path %>
</div>
そのまま、「product」のインスタンスで設定したら、turbo_frame_tag
がユニークなIDを作ってくれます。
Turbo Rails Part 2: Turbo Streams + Broadcasting
また今度!