この記事は、Ruby Advent Calendar 2023 シリーズ1 の20日目です
昨日は、 @HAZI さんで 「現在開発中の『SpeedLimiter』Gemの紹介」 でした
piacere です、ご覧いただいてありがとございます
普段は、Elixir/Phoenix/LiveViewメインで開発してますが、Ruby/Railsも年2本くらい、既存システムのバージョンアップ案件やリプレイス案件をこなしている(実はRails 1系からの付き合いで結構古い)ので、何かと触ってはいるものの、そこまで新しいフィーチャは使う機会が無いです
そこで、前々から何となくは知ってたものの、ちゃんと触ったことが無かった「HotWire」について調べてみたいと思います
Elixirにてサーバサイド主体のリアルタイムWeb/SPAを叶える「LiveView」と似た性質を持っていると言われるHotWireなので、とても楽しみです
なお、LiveViewについては以前コラム書いていますので、良かったらご覧ください
【202312/22追記】
Python/DjangoのHotWireについてもコラム化しました
【202312/23追記】
PHP/Laravelの同種であるLiveWireについてもコラム化しました
あと、このコラムが、面白かったり、役に立ったら、 をお願いします
事前準備:Rails 7以降が入っていれば特に無し
HotWireは、Rails 7以降だと標準で入っているので、特に何もせずに使えるようです(本コラムはRuby 3.0.6+Rails 7.1.2での実施結果が書かれています)
下記チュートリアルを見ながら進めます
Learn Hotwire and Turbo with a free Rails 7 tutorial
Chapter 0「Turbo Rails tutorial introduction」
Rails PJを作成し、起動します
rails new quote-editor --css=sass --javascript=esbuild --database=postgresql
cd quote-editor
bin/setup
bin/rails server
ブラウザで http://localhost:3000
にアクセスすると、親の顔より良く見たRailsデフォルトページが表示されます
Chapter 1「A simple CRUD controller with Rails」
シンプルなCRUDを作っていきます
Creating our Quote model
モデルをScaffoldします(なお、この手前のテストはスッ飛ばしています)
bin/rails generate model Quote name:string
bin/rails db:migrate
Adding our routes and controller actions
コントローラをScaffoldします
bin/rails generate controller Quotes
routerに追加します
Rails.application.routes.draw do
…
# Defines the root path route ("/")
# root "posts#index"
+ Rails.application.routes.draw do
+ resources :quotes
+ end
end
Controllerの中身を実装します
class QuotesController < ApplicationController
+ before_action :set_quote, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @quotes = Quote.all
+ end
+
+ def show
+ end
+
+ def new
+ @quote = Quote.new
+ end
+
+ def create
+ @quote = Quote.new(quote_params)
+
+ if @quote.save
+ redirect_to quotes_path, notice: "Quote was successfully created."
+ else
+ render :new
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @quote.update(quote_params)
+ redirect_to quotes_path, notice: "Quote was successfully updated."
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ @quote.destroy
+ redirect_to quotes_path, notice: "Quote was successfully destroyed."
+ end
+
+ private
+
+ def set_quote
+ @quote = Quote.find(params[:id])
+ end
+
+ def quote_params
+ params.require(:quote).permit(:name)
+ end
end
Adding our quote views
View群を追加します
<main class="container">
<div class="header">
<h1>Quotes</h1>
<%= link_to "New quote",
new_quote_path,
class: "btn btn--primary" %>
</div>
<%= render @quotes %>
</main>
<div class="quote">
<%= link_to quote.name, quote_path(quote) %>
<div class="quote__actions">
<%= button_to "Delete",
quote_path(quote),
method: :delete,
class: "btn btn--light" %>
<%= link_to "Edit",
edit_quote_path(quote),
class: "btn btn--light" %>
</div>
</div>
<main class="container">
<%= link_to sanitize("← Back to quotes"), quotes_path %>
<div class="header">
<h1>New quote</h1>
</div>
<%= render "form", quote: @quote %>
</main>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit quote</h1>
</div>
<%= render "form", quote: @quote %>
</main>
<%= simple_form_for quote, html: { class: "quote form" } do |f| %>
<% if quote.errors.any? %>
<div class="error-message">
<%= quote.errors.full_messages.to_sentence.capitalize %>
</div>
<% end %>
<%= f.input :name, input_html: { autofocus: true } %>
<%= f.submit class: "btn btn--secondary" %>
<% end %>
simple_formをインストールします
source "https://rubygems.org"
…
+gem "simple_form", "~> 5.1.0"
bundle install
bin/rails generate simple_form:install
Show用Viewも追加します
<main class="container">
<%= link_to sanitize("← Back to quotes"), quotes_path %>
<div class="header">
<h1>
<%= @quote.name %>
</h1>
</div>
</main>
Railsを起動して確認してみましょう
bin/rails server
テスト用seedデータの追加は割愛するとして、ひとまず、SSR版が動くように成りました
Chapter 2「Organizing CSS files in Ruby on Rails」
ただのデザインなので、機械的に下記ファイル追加をこなしていきます
Using our CSS architecture on our quote editor
@mixin media($query) {
@if $query == tabletAndUp {
@media (min-width: 50rem) { @content; }
}
}
下記は、元コラムでやたら分割されていたので、繋げたものを下記に示します
:root {
// Simple fonts
--font-family-sans: 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
// Classical line heights
--line-height-headers: 1.1;
--line-height-body: 1.5;
// Classical and robust font sizes system
--font-size-xs: 0.75rem; // 12px
--font-size-s: 0.875rem; // 14px
--font-size-m: 1rem; // 16px
--font-size-l: 1.125rem; // 18px
--font-size-xl: 1.25rem; // 20px
--font-size-xxl: 1.5rem; // 24px
--font-size-xxxl: 2rem; // 32px
--font-size-xxxxl: 2.5rem; // 40px
// Three different text colors
--color-text-header: hsl(0, 1%, 16%);
--color-text-body: hsl(0, 5%, 25%);
--color-text-muted: hsl(0, 1%, 44%);
// Classical and robust spacing system
--space-xxxs: 0.25rem; // 4px
--space-xxs: 0.375rem; // 6px
--space-xs: 0.5rem; // 8px
--space-s: 0.75rem; // 12px
--space-m: 1rem; // 16px
--space-l: 1.5rem; // 24px
--space-xl: 2rem; // 32px
--space-xxl: 2.5rem; // 40px
--space-xxxl: 3rem; // 48px
--space-xxxxl: 4rem; // 64px
// Application colors
--color-primary: hsl(350, 67%, 50%);
--color-primary-rotate: hsl(10, 73%, 54%);
--color-primary-bg: hsl(0, 85%, 96%);
--color-secondary: hsl(101, 45%, 56%);
--color-secondary-rotate: hsl(120, 45%, 56%);
--color-tertiary: hsl(49, 89%, 64%);
--color-glint: hsl(210, 100%, 82%);
// Neutral colors
--color-white: hsl(0, 0%, 100%);
--color-background: hsl(30, 50%, 98%);
--color-light: hsl(0, 6%, 93%);
--color-dark: var(--color-text-header);
// Border radius
--border-radius: 0.375rem;
// Border
--border: solid 2px var(--color-light);
// Shadows
--shadow-large: 2px 4px 10px hsl(0 0% 0% / 0.1);
--shadow-small: 1px 3px 6px hsl(0 0% 0% / 0.1);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
}
html {
overflow-y: scroll;
height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100%;
background-color: var(--color-background);
color: var(--color-text-body);
line-height: var(--line-height-body);
font-family: var(--font-family-sans);
}
img,
picture,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--color-text-header);
line-height: var(--line-height-headers);
}
h1 {
font-size: var(--font-size-xxxl);
}
h2 {
font-size: var(--font-size-xxl);
}
h3 {
font-size: var(--font-size-xl);
}
h4 {
font-size: var(--font-size-l);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: color 200ms;
&:hover,
&:focus,
&:active {
color: var(--color-primary-rotate);
}
}
下記も、繋げたものを下記に示します
.btn {
display: inline-block;
padding: var(--space-xxs) var(--space-m);
border-radius: var(--border-radius);
background-origin: border-box; // Invisible borders with linear gradients
background-color: transparent;
border: solid 2px transparent;
font-weight: bold;
text-decoration: none;
cursor: pointer;
outline: none;
transition: filter 400ms, color 200ms;
&:hover,
&:focus,
&:focus-within,
&:active {
transition: filter 250ms, color 200ms;
}
// Modifiers will go there
&--primary {
color: var(--color-white);
background-image: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));
&:hover,
&:focus,
&:focus-within,
&:active {
color: var(--color-white);
filter: saturate(1.4) brightness(115%);
}
}
&--secondary {
color: var(--color-white);
background-image: linear-gradient(to right, var(--color-secondary), var(--color-secondary-rotate));
&:hover,
&:focus,
&:focus-within,
&:active {
color: var(--color-white);
filter: saturate(1.2) brightness(110%);
}
}
&--light {
color: var(--color-dark);
background-color: var(--color-light);
&:hover,
&:focus,
&:focus-within,
&:active {
color: var(--color-dark);
filter: brightness(92%);
}
}
&--dark {
color: var(--color-white);
border-color: var(--color-dark);
background-color: var(--color-dark);
&:hover,
&:focus,
&:focus-within,
&:active {
color: var(--color-white);
}
}
}
.quote {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-s);
background-color: var(--color-white);
border-radius: var(--border-radius);
box-shadow: var(--shadow-small);
margin-bottom: var(--space-m);
padding: var(--space-xs);
@include media(tabletAndUp) {
padding: var(--space-xs) var(--space-m);
}
&__actions {
display: flex;
flex: 0 0 auto;
align-self: flex-start;
gap: var(--space-xs);
}
}
.form {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
&__group {
flex: 1;
}
&__input {
display: block;
width: 100%;
max-width: 100%;
padding: var(--space-xxs) var(--space-xs);
border: var(--border);
border-radius: var(--border-radius);
outline: none;
transition: box-shadow 250ms;
&:focus {
box-shadow: 0 0 0 2px var(--color-glint);
}
&--invalid {
border-color: var(--color-primary);
}
}
}
// Shamelessly stolen from Bootstrap
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.error-message {
width: 100%;
color: var(--color-primary);
background-color: var(--color-primary-bg);
padding: var(--space-xs);
border-radius: var(--border-radius);
}
.container {
width: 100%;
padding-right: var(--space-xs);
padding-left: var(--space-xs);
margin-left: auto;
margin-right: auto;
@include media(tabletAndUp) {
padding-right: var(--space-m);
padding-left: var(--space-m);
max-width: 60rem;
}
}
.header {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
justify-content: space-between;
margin-top: var(--space-m);
margin-bottom: var(--space-l);
@include media(tabletAndUp) {
margin-bottom: var(--space-xl);
}
}
// Mixins
@import "mixins/media";
// Configuration
@import "config/variables";
@import "config/reset";
// Components
@import "components/btn";
@import "components/error_message";
@import "components/form";
@import "components/visually_hidden";
@import "components/quote";
// Layouts
@import "layouts/container";
@import "layouts/header";
Chapter 3「Turbo Drive」
さて、やっと本題です
全てのリンククリックとフォーム送信をAJAXリクエストに変換(≒リクエストを傍受して、上書きする)する「Turbo Drive」により、<body>
タグ内の一部変更のみとなり、<head>
およびそこでロードされているCSS/JSのロードは最初にサイトアクセスしたときだけとなり、従来のSSRよりも高速化されるという仕組みです
その後、コラムでは、Turbo Driveを無効化する解説あります … ん?…ということは、Rails標準の状態で、Turbo Driveは有効化されているということ!?
コラムに無効化の方法もあるので、試しに無効化して確かめてみましょう
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
+
+import { Turbo } from "@hotwired/turbo-rails"
+Turbo.session.drive = false
それでは、無効化を解除して、同じことを試します
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
-
-import { Turbo } from "@hotwired/turbo-rails"
-Turbo.session.drive = false
ページ移動のたびにリロードが走らず、AJAXによる部分ロードが行われていることが分かります(ちなみにURLは変わっています)
つまり、Rails 7デフォルトにした時点で、Turbo Driveによる部分ロードが有効になっていたということですね
次に、CSSをエラー状態にすると、どうなるでしょう?
// Mixins
@import "mixins/media";
// Configuration
@import "config/variables";
@import "config/reset";
// Components
@import "components/btn";
+@import "components/btn";
@import "components/error_message";
@import "components/form";
@import "components/visually_hidden";
@import "components/quote";
// Layouts
@import "layouts/container";
@import "layouts/header";
ページ移動時にAJAXでは無く、リロードになりました … 良く出来ています(確認したら、CSSは元に戻してください)
さて、このようにAJAXで上書きされた影響で、ブラウザのロード状態でレスポンス待ちが分かりにくくなるというデメリットがあります
コラムの順と異なりますが、まずそのことを確認するため、3秒のウェイトを全コントローラに入れます
class ApplicationController < ActionController::Base
+ before_action -> { sleep 3 }
end
まぁ、一応、プログレスバーのスタイル設定をしてみます
.turbo-progress-bar {
background: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));
}
…
@import "components/turbo_progress_bar";
もしかしたら、ココはブラウザ依存なのかも知れません(私はBraveで確認しました)
Chapter 4「Turbo Frames and Turbo Stream templates」
テストは飛ばし、「Turbo Frames」についてやっていきます
What are Turbo Frames?
turbo_frame_tag
ブロックでTurbo Framesが作成できます
<main class="container">
+ <%= turbo_frame_tag "first_turbo_frame" do %>
<div class="header">
<h1>Quotes</h1>
<%= link_to "New quote",
new_quote_path,
class: "btn btn--primary" %>
</div>
+ <% end %>
<%= render @quotes %>
</main>
ブラウザでソースを見ると、<turbo-frame id="first_turbo_frame">
という感じで、専用タグで出力されています
Turbo Frames cheat sheet
ここで、Newの方にも同じ名前の turbo_frame_tag
ブロックを置いてみます
<main class="container">
<%= link_to sanitize("← Back to quotes"), quotes_path %>
<div class="header">
<h1>New quote</h1>
</div>
+ <%= turbo_frame_tag "first_turbo_frame" do %>
<%= render "form", quote: @quote %>
+ <% end %>
</main>
すると、同じ名前の turbo_frame_tag
ブロックのところだけがAJAXで差し替わります
もう少し見た目を意識すると、h1
のところは、フレームから外してみましょう
<main class="container">
- <%= turbo_frame_tag "first_turbo_frame" do %>
<div class="header">
<h1>Quotes</h1>
+ <%= turbo_frame_tag "first_turbo_frame" do %>
<%= link_to "New quote",
new_quote_path,
class: "btn btn--primary" %>
+ <% end %>
</div>
- <% end %>
<%= render @quotes %>
</main>
これで、タイトルとリストは維持したまま、Newの箇所だけが差し替わる感じにできました
マッチしない名前に変えるとどうなるでしょう?
<main class="container">
<%= link_to sanitize("← Back to quotes"), quotes_path %>
<div class="header">
<h1>New quote</h1>
</div>
- <%= turbo_frame_tag "first_turbo_frame" do %>
+ <%= turbo_frame_tag "not_matching" do %>
<%= render "form", quote: @quote %>
+ <% end %>
</main>
今度は、Indexのリスト部のフレームをNewのフレームと同一にしてみましょう
<main class="container">
<div class="header">
<h1>Quotes</h1>
<%= turbo_frame_tag "first_turbo_frame" do %>
<%= link_to "New quote",
new_quote_path,
class: "btn btn--primary" %>
<% end %>
</div>
+ <%= turbo_frame_tag "second_frame" do %>
<%= render @quotes %>
+ <% end %>
</main>
<main class="container">
<%= link_to sanitize("← Back to quotes"), quotes_path %>
<div class="header">
<h1>New quote</h1>
</div>
- <%= turbo_frame_tag "not_matching" do %>
+ <%= turbo_frame_tag "second_frame" do %>
<%= render "form", quote: @quote %>
<% end %>
</main>
そして登録すると、新規入力部分が、リストになり、新規も追加されていることが確認できるようになります
ページ全体を置き換え対象にもできます
<main class="container">
+ <%= turbo_frame_tag "first_turbo_frame" do %>
<div class="header">
<h1>Quotes</h1>
<%= link_to "New quote",
new_quote_path,
+ data: { turbo_frame: "_top" },
class: "btn btn--primary" %>
</div>
+ <% end %>
- <%= turbo_frame_tag "second_frame" do %>
<%= render @quotes %>
- <% end %>
</main>
この場合、フレーム化は最初のページだけで充分です
<main class="container">
<%= link_to sanitize("← Back to quotes"), quotes_path %>
<div class="header">
<h1>New quote</h1>
</div>
- <%= turbo_frame_tag "second_frame" do %>
<%= render "form", quote: @quote %>
- <% end %>
</main>
Editing quotes with Turbo Frames
編集のような特定IDのフレーム化は、各行の中身である_quote全体を quote.id
でナンバリングされた名前のフレームで囲みます
+<%= turbo_frame_tag "quote_#{quote.id}" do %>
<div class="quote">
<%= link_to quote.name, quote_path(quote) %>
<div class="quote__actions">
<%= button_to "Delete",
quote_path(quote),
method: :delete,
class: "btn btn--light" %>
<%= link_to "Edit",
edit_quote_path(quote),
class: "btn btn--light" %>
</div>
</div>
+<% end %>
編集フォームも @quote.id
でナンバリングされた名前のフレームで囲みます
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit quote</h1>
</div>
+ <%= turbo_frame_tag "quote_#{@quote.id}" do %>
<%= render "form", quote: @quote %>
+ <% end %>
</main>
Turbo Frames and the dom_id helper
dom_id
ヘルパーの恩恵で、ID指定では無く、データそのものを指定したフレームでも処理できます
-<%= turbo_frame_tag "quote_#{quote.id}" do %>
+<%= turbo_frame_tag quote do %>
<div class="quote">
<%= link_to quote.name, quote_path(quote) %>
<div class="quote__actions">
<%= button_to "Delete",
quote_path(quote),
method: :delete,
class: "btn btn--light" %>
<%= link_to "Edit",
edit_quote_path(quote),
class: "btn btn--light" %>
</div>
</div>
<% end %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit quote</h1>
</div>
- <%= turbo_frame_tag "quote_#{@quote.id}" do %>
+ <%= turbo_frame_tag @quote do %>
<%= render "form", quote: @quote %>
<% end %>
</main>
動作は上記と同様です
Showing and deleting quotes
ただし、現在の構造には問題があって、リンクを押す、もしくは削除を行うと、どちらも下図のエラーになります
これを解決するには、削除ボタンに data
を指定し、削除時も全体を置き換えるようにします
<%= turbo_frame_tag quote do %>
<div class="quote">
- <%= link_to quote.name, quote_path(quote) %>
+ <%= link_to quote.name, quote_path(quote), data: { turbo_frame: "_top" } %> %>
<div class="quote__actions">
<%= button_to "Delete",
quote_path(quote),
method: :delete,
+ form: { data: { turbo_frame: "_top" } },
class: "btn btn--light" %>
<%= link_to "Edit",
edit_quote_path(quote),
class: "btn btn--light" %>
</div>
</div>
<% end %>
ただし、この対応だと、編集中の状態から別行の削除を行うと、編集状態がリセットされてしまうため、不完全な対応です
これを避けるためには、先ほど削除ボタンに対して行った改修を無かったことにします
<%= turbo_frame_tag quote do %>
<div class="quote">
<%= link_to quote.name, quote_path(quote) %>
<%= link_to quote.name, quote_path(quote), data: { turbo_frame: "_top" } %> %>
<div class="quote__actions">
<%= button_to "Delete",
quote_path(quote),
method: :delete,
- form: { data: { turbo_frame: "_top" } },
class: "btn btn--light" %>
<%= link_to "Edit",
edit_quote_path(quote),
class: "btn btn--light" %>
</div>
</div>
<% end %>
The Turbo Stream format
削除時のログを見ると、QuotesController#destroy
がTurbo Streamとして実行されているようです
Turbo Streamに対応した削除処理にしてみましょう
class QuotesController < ApplicationController
…
def destroy
@quote.destroy
- redirect_to quotes_path, notice: "Quote was successfully destroyed."
+ respond_to do |format|
+ format.html { redirect_to quotes_path, notice: "Quote was successfully destroyed." }
+ format.turbo_stream
+ end
end
…
削除実施用のViewも定義します
<%= turbo_stream.remove "quote_#{@quote.id}" %>
Turbo Streamは、remove以外にも、下記に対応しているようです
# Remove a Turbo Frame
turbo_stream.remove
# Insert a Turbo Frame at the beginning/end of a list
turbo_stream.append
turbo_stream.prepend
# Insert a Turbo Frame before/after another Turbo Frame
turbo_stream.before
turbo_stream.after
# Replace or update the content of a Turbo Frame
turbo_stream.update
turbo_stream.replace
なお、Turbo Streamもdom_id指定が可能です
<%= turbo_stream.remove @quote %>
以降は…
HotWire/Turbo Frame/Turbo Streamの基本的な挙動は、ここまでで大体、把握できたので、応用編的な位置付けである「Creating a new quote with Turbo Frames」「Ordering our quotes」「Adding a cancel button」および Chapter 5「Real-time updates with Turbo Streams」 以降は、HotWireを実際に使うタイミングが訪れそうなときに再び検証したいと思います
WebSocket相当が無い中で、サーバサイドでのデータ変更を反映する仕組みである「Turbo Streams」をどうやって実装しているのか興味津々です
終わりに
HotWireのおおまかな仕組みは理解できました
ReactやVue.js、Svelteあたりに近いイディオムをサーバサイドとして実装しているLiveViewとは、だいぶ異なる構造ですが、元々のRailsのイディオムをあまり変えずに対応できる点は、とてもグッドな機能だと思います
今度、時間のあるときにLiveViewとの比較コラムとかも書いてみたい感じです
なお、過去/現在/未来のスキルから、あなたのBright(輝き)とRight(正しさ)を引き出すElixir製プロダクト「Bright」を先日αリリースしたのですが、次期リリース(5月頃)で「Ruby入門」を搭載予定なので、「HotWire」はスキル科目としてぜひ入れておきたいと考えています
現在、下記がアンロックされています
- Elixir入門
- Python入門
- PHP入門
- React入門
- Webアプリ開発 Elixir
- PM(プロジェクトマネージャ)
- WebアプリUIデザイナー
- オウンドメディアマーケター
来年頭に下記がアンロック予定です
- チームリーダー/PL(プロジェクトリーダー)
- PdM(プロダクトマネージャ)
- 新人AWS or AWSプラクティショナー
- 新人Google Cloud or Google Cloudファンダメンタルズ
明日は、@buty4649 さんです