はじめに
先月12月15日に Ruby on Rails 7.0 がリリースされたということで、自分でもその機能を試してみます。
リリースを伝えるブログ記事の日本語訳はこちらを読みました。
ここでは Hotwire について試してみます。
Hotwireは、こちらの記事 では「It's a complete alternative to heavy JavaScript client-side apps that speak JSON to a backend.」と説明されています。早速使ってみましょう。
デモアプリケーション
こちら にソースコードを置きました。
プロジェクトの新規作成
プロジェクトは rails new
コマンドで作成しますが、これを実行するためにはあらかじめ以下の gem がインストールされている必要があります。
bundle init
echo "gem 'rails', '~>7.0.1'" >> Gemfile
echo "gem 'bootsnap'" >> Gemfile
echo "gem 'importmap-rails'" >> Gemfile
echo "gem 'turbo-rails'" >> Gemfile
echo "gem 'stimulus-rails'" >> Gemfile
echo "gem 'sprockets-rails'" >> Gemfile
bundle config set --local path vendor/bundle
bundle install
bundle exec rails new ../bustime -d postgresql --skip-jbuilder
※Rails 7.0.1 で、Ruby 3.1 対応になりました。
Turbo
Turbo Drive
モデルとコントローラを作成してみます。
bundle exec rails generate scaffold BusLine name:text
bundle exec rails generate scaffold BusSchedule departure_hour:bigint departure_minute:bigint bus_line_id:bigint
bundle exec rails db:migrate
bundle exec rails server -b 0.0.0.0
Webブラウザでアクセスします。
http://localhost:3000/bus_lines
一覧画面が表示されました。続けて新規作成画面を表示し、1件登録して、再び一覧画面に戻ります。
ここで、Webブラウザの開発ツール、「ネットワーク」で確認すると、新規作成画面以降のアクセスが XHR になっています。なんと、POST メソッドのアクセスも XHR です。
従来通りにプロジェクトを作成しただけなのに、シングルページアプリケーションができあがりました。
不思議ですね。
Turbo Frame
シングルページアプリケーション(SPA)といえば、例えば一覧画面で、画面の表示直後はスピナー(ローディングインジケータ)がくるくる回って、データの取得が終わったら結果が表示される、というような動作になっていたりします。
これはつまり、フロントエンドで以下の処理を行っている、ということになります。
- ローディングインジケータを表示する
- データ取得APIに対してリクエストを送信する
- 取得した JSON 文字列を分解し、テンプレートに流し込む
- テンプレートから生成された HTML を DOM ツリーに差し込む(画面に表示する)
- ローディングインジケータを非表示にする
使用するフロントエンドのフレームワークによって書き方はさまざまかと思いますが、必要な処理は同じです。
Rails 7.0 でやってみます。
<%= turbo_frame_tag 'bus_schedules', src: bus_schedules.nil? ? list_bus_schedules_path : nil do %>
<table class="bus-schedule-table">
<thead>
<tr>
<th class="departure-hour">時</td>
<th class="departure-minute">分</td>
<th class="line">路線</td>
<th class="operation">操作</td>
</tr>
</thead>
<tbody>
<% if bus_schedules.nil? %>
<tr>
<td colspan="4" class="loading"><%= image_tag 'loading.gif' %></td>
</tr>
<% else %>
<% bus_schedules.each do |bus_schedule| %>
<tr>
<td class="departure-hour"><%= bus_schedule.departure_hour %></td>
<td class="departure-minute"><%= bus_schedule.departure_minute %></td>
<td class="line"><%= bus_schedule.bus_line.name %></td>
<td class="operation"><%= link_to "Show this bus schedule", bus_schedule, data: { turbo_frame: '_top' } %></td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<% end %>
<p style="color: green"><%= notice %></p>
<h1>Bus schedules</h1>
<%= render partial: 'schedule_table', locals: { bus_schedules: nil } %>
<%= link_to "New bus schedule", new_bus_schedule_path %>
<%= render partial: 'bus_schedules/schedule_table', locals: { bus_schedules: @bus_schedules } %>
class ListBusSchedulesController < ApplicationController
def index
sleep 2
@bus_schedules = BusSchedule.all
end
end
エラー処理などが一切ありませんが、期待通りの動作ができました。
あれ、一覧のデータ取得はどこで行っているのでしょうか?ローディングインジケータはどうやって切り替えたのでしょうか?むむ。
データ取得は、turbo_frame_tag
において、src
属性にパスを指定することで行っています。src
が指定されていると、ページの読み込み完了時に、自動的にそのパスにリクエストが送信されます。
turbo_frame_tag
は、<turbo-frame>
というタグを生成します。リクエストから応答が返却されると、その内容で、<turbo-frame>
の中身が置き換わります。これにより、最初に表示していたローディングインジケータが消え、一覧に切り替わる、という動作になります。
ところで、ここに至るまで、このプロジェクトでは、まだ1行も JavaScript を書いていません。
不思議ですね。
Turbo Stream
フロントエンドといえば、サーバプッシュの仕組みもよく利用されています。たとえばチャットの画面で、誰かが送信したメッセージが自動的に自分の Web ブラウザの画面に反映される、というような状況です。
WebSocket を用いる場合、フロントエンド側では以下の処理を行う必要があります。
- コネクションを確立する。
- サーバ側からのイベントを受け取る。
- イベントからデータを取り出し、テンプレートに流し込む
- テンプレートから生成された HTML を DOM ツリーに差し込む(画面に表示する)
チャットではありませんが、Webブラウザのタブを2つ開き、片方は一覧画面、もう片方は新規作成画面として、新規作成を行うと自動的に一覧画面に反映される、ということをやってみます。
class BusLine < ApplicationRecord
has_many :bus_schedules
after_create_commit -> { broadcast_append_to 'bus_lines', partial: 'bus_lines/list_item' }
after_update_commit -> { broadcast_update_to 'bus_lines', partial: 'bus_lines/list_item' }
after_destroy_commit -> { broadcast_remove_to 'bus_lines' }
end
<tr id="<%= dom_id bus_line %>">
<td class="name"><%= bus_line.name %></td>
<td class="operation"><%= link_to "Show this bus line", bus_line, data: { turbo_frame: '_top' } %></td>
</tr>
<p style="color: green"><%= notice %></p>
<h1>Bus lines</h1>
<table class="bus-line-table">
<thead>
<tr>
<th class="name">Name</th>
<th class="operation">操作</th>
</tr>
</thead>
<tbody id="bus_lines">
</tbody>
</table>
<%= link_to "New bus line", new_bus_line_path %>
<%= turbo_stream_from 'bus_lines' %>
<% @bus_lines.each do |bus_line| %>
<%= turbo_stream.append(:bus_lines, partial: "list_item", locals: { bus_line: bus_line }) %>
<% end %>
おや、どこでコネクションを確立したのでしょうか?どうやって画面上の一覧に新しい行を追加しているのでしょうか?
ERB のテンプレート内、turbo_stream_from 'bus_lines'
が、コネクションを表しています。
モデルの broadcast_append_to
が、コネクションに対してデータを追加するイベントを送信します。DBテーブルにレコードが追加された時に、この処理を実行しているので、新規作成の処理に合わせて一覧が更新される、というどうさになりました。
なんと、ここに至るまで、JavaScript を1行も書いていません。
不思議ですね。
Stimulus
別にフロントエンドを毛嫌いしているわけでもなく、フロントエンドなしで何でもできるわけでもありません。
例えば、特に意味はありませんが、一覧画面において、いずれかの行がクリックされたら、その行の背景色が変わるようにしてみます。
必要な処理は以下のようになります。
- 行がクリックされたことを検知できるようにする
- 他の行の選択状態を解除する (選択状態であることを示すクラスを取り除く)
- クリックされた行を選択状態にする (選択状態であることを示すクラスをセットする)
Rails 7.0 で書いてみます。
前述、Turbo Frame の部分で作成した _schedule_table.html.erb
を編集し、かつ、JavaScript のファイルを追加します。
<%= turbo_frame_tag 'bus_schedules', src: bus_schedules.nil? ? list_bus_schedules_path : nil do %>
<table class="bus-schedule-table" data-controller="schedule" data-schedule-selected-class="selected">
: (中略)
<% bus_schedules.each do |bus_schedule| %>
<tr data-schedule-target="row" data-action="click->schedule#selectRow">
: (中略)
</tr>
<% end %>
: (中略)
</table>
<% end %>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ 'row' ];
static classes = [ 'selected' ];
// コントローラがDOMと接続された時に実行されるハンドラー
connect() {
this.clearState();
}
// すべての行を未選択状態にする(selectedクラスを取り除く)
clearState() {
this.rowTargets.forEach((tableRow) => {
tableRow.classList.remove(this.selectedClass);
});
}
// クリックされた行を選択状態にする
selectRow(event) {
this.clearState();
const tableRow = event.target.closest('tr');
tableRow.classList.add(this.selectedClass);
}
}
JavaScript は、おおよそ普通に見えますね。すべての行の選択状態を解除して、クリックされた行を選択状態にする、ということをしているだけです。
普通に見えますが、よくわからない部分があります。どうして schedule_controller.js
で定義したクラスが、この <table>
タグに対して動作するのでしょうか。もう一つ、クリックイベントのハンドラーを設定していないように見えますが、どうやってクリックを検知しているのでしょうか。
<table>
タグに、以下の記述を追加しました。
data-controller="schedule"
これにより、schedule_controller.js
という名前のファイルが自動的に参照されるようになります。
さらに、<tr>
タグに、以下の記述を追加しました。
data-schedule-target="row" data-action="click->schedule#selectRow"
これにより(targets
の宣言も必要ですが)、tr タグを JavaScript のクラス内、this.rowTargets
で参照できるようになります。また、data-action
で、クリック時のイベントハンドラーを設定しています。
フロントエンドのフレームワークは独自にテンプレート機能を持っていますが、Rails 7.0 では、ERB がそのままフロントエンドのテンプレートになっています。
おわりに
Rails 7.0 で導入された、Hotwire と呼ばれる機能について、どんな機能なのか、どうやって使うのか、について確認しました。
既存のプロジェクトを Rails 7.0 にして何も考えずに Turbo を有効にするといろいろ動かなくなりそうですが、新しいプロジェクトであれば、手軽に WebSocket が使えたりするので、よいかもしれません。