38
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Rails 7のHotwireがどんなことをしてくれるものかを知りたいです

Last updated at Posted at 2022-01-12

はじめに

先月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件登録して、再び一覧画面に戻ります。

20220104_Hotwire_09.png

ここで、Webブラウザの開発ツール、「ネットワーク」で確認すると、新規作成画面以降のアクセスが XHR になっています。なんと、POST メソッドのアクセスも XHR です。

20220104_Hotwire_08.png

従来通りにプロジェクトを作成しただけなのに、シングルページアプリケーションができあがりました。

不思議ですね。

Turbo Frame

シングルページアプリケーション(SPA)といえば、例えば一覧画面で、画面の表示直後はスピナー(ローディングインジケータ)がくるくる回って、データの取得が終わったら結果が表示される、というような動作になっていたりします。

20220104_Hotwire_10.png

これはつまり、フロントエンドで以下の処理を行っている、ということになります。

  1. ローディングインジケータを表示する
  2. データ取得APIに対してリクエストを送信する
  3. 取得した JSON 文字列を分解し、テンプレートに流し込む
  4. テンプレートから生成された HTML を DOM ツリーに差し込む(画面に表示する)
  5. ローディングインジケータを非表示にする

使用するフロントエンドのフレームワークによって書き方はさまざまかと思いますが、必要な処理は同じです。

Rails 7.0 でやってみます。

app/views/bus_schedules/_schedule_table.html.erb
<%= 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 %>
app/views/bus_schedules/index.html.erb
<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 %>
app/views/list_bus_schedules/index.html.erb
<%= render partial: 'bus_schedules/schedule_table', locals: { bus_schedules: @bus_schedules } %>
app/controllers/list_bus_schedules_controller.rb
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 を用いる場合、フロントエンド側では以下の処理を行う必要があります。

  1. コネクションを確立する。
  2. サーバ側からのイベントを受け取る。
  3. イベントからデータを取り出し、テンプレートに流し込む
  4. テンプレートから生成された HTML を DOM ツリーに差し込む(画面に表示する)

チャットではありませんが、Webブラウザのタブを2つ開き、片方は一覧画面、もう片方は新規作成画面として、新規作成を行うと自動的に一覧画面に反映される、ということをやってみます。

20220104_Hotwire_13.png

app/models/bus_line.rb
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
app/views/bus_lines/_list_item.html.erb
<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>
app/views/bus_lines/index.html.erb
<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

別にフロントエンドを毛嫌いしているわけでもなく、フロントエンドなしで何でもできるわけでもありません。

例えば、特に意味はありませんが、一覧画面において、いずれかの行がクリックされたら、その行の背景色が変わるようにしてみます。

20220104_Hotwire_14.png

必要な処理は以下のようになります。

  1. 行がクリックされたことを検知できるようにする
  2. 他の行の選択状態を解除する (選択状態であることを示すクラスを取り除く)
  3. クリックされた行を選択状態にする (選択状態であることを示すクラスをセットする)

Rails 7.0 で書いてみます。

前述、Turbo Frame の部分で作成した _schedule_table.html.erb を編集し、かつ、JavaScript のファイルを追加します。

apps/views/bus_schedules/_schedule_table.html.erb
<%= 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 %>
app/javascript/controllers/schedule_controller.js
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 が使えたりするので、よいかもしれません。

参考

38
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?