Hotwireを理解するために選曲アプリを作ってみた

# はじめに
[Ateam Finergy Inc.× Ateam CommerceTech Inc.× Ateam Wellness Inc. Advent Calendar 2022](https://qiita.com/advent-calendar/2022/finergy-commerce-tech-wellness)の21日目の記事です。
本日はエイチームコマーステックの@hibiheionが担当します。

**Hotwire**はJavaScriptを使わずにSPA(Single Page Application)を作成することができる仕組みです。Rails 7では標準機能になっています。業務でRailsを使っている身としては無視できない技術だったので作ろうと考えていた個人開発のWebアプリに導入してみました。

# 選曲アプリの概要
カタログ情報を見ながら選曲するためのWebアプリです。ジャズをよく聴くのですが、その目線で見るとSpotify（に限らず私が知っている音楽ストリーミングサービス）が持っている情報が物足りないと感じていたことが作成理由です。本題から逸れるので折りたたんでいますが、以下がやりたかったことです。
<details><summary>やりたかったこと</summary>

- 個人名で調べたときに所属しているグループの作品も作品一覧に入るようにしたい
  - 例えば、ビル・エヴァンスというジャズピアニストの有名なアルバムの「Waltz For Debby」は「ビル・エヴァンス・トリオ」というグループ名義になっているため個人の作品一覧に出てこない
- アルバムの参加アーティストを全員知りたい
  - 例えば、ピアノトリオはピアノ、ベース、ドラムの3人で演奏しているが、アルバムのアーティストとして出るのはピアノの1人だけというケースが多い
  - また、このようになっている場合にベースとドラムの人の参加作品にはピアノトリオのアルバムが出ない
- 正しい年代を知りたい
  - ジャズのアルバムだと複数のバージョンが出ていることがあるが、Spotifyで表示される年代が最初に出たバージョンの年代ではないことがある
  - 録音年代によってアーティストの演奏スタイルの違いなどがあるので正しい年代が知りたい
- 上記の情報に基づいて曲やアーティストを辿ることができるようにしたい
</details>

ざっくりとした仕組みとしては以下のサービスのAPIを使用して情報の取得と音楽の再生を行っています。
- [Spotify](https://open.spotify.com/)
  - 音楽のストリーミングサイト
  - 音楽の再生のために使用している
- [Discogs](https://www.discogs.com/ja/)
  - 世界最大規模の音楽データベースサイト
  - ベースになる情報はここから取得している

以下の操作を行った様子を録画しました。

1. アーティスト名で検索する
1. アーティストを選択する
1. 作品一覧の追加読み込みを行う
1. アルバムを選択する
1. アルバムを再生する

![qiita動画.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206611/5cf628d1-00a9-1e9d-80ed-06dd42ef5753.gif)


URLが変わらずにページの一部だけ書き換わっています。また、ブラウザの右側のタブはSpotifyなのですが、よく見るとWebアプリ側でアルバムを再生したときにタイトルが変わっています。これはSpotifyで再生している曲が変わっているためです。

# Hotwireの使用箇所
ここからが本題です。
基本的には作成したWebアプリでHotwireで使っている部分の解説ですのでその点はご注意ください。詳細な仕様は公式ドキュメントや他の記事を参照していただければと思います。

## Turbo Drive
Turbo Driveは画面遷移時にページ全体をリロードせずにページを更新することで表示を高速化する機能です。Hotwireを有効にしたRails 7だと標準で有効になっています。今回はSPAとして作っていたので意識する機会はありませんでした。

## Turbo Frames
Turbo Framesはページ内の特定の範囲のみを書き換える機能です。HTMLにおけるiframeと似た仕組みです。今回は水色の背景のプレーヤーの部分とその他の表示部分にフレームを分割しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206611/023321a7-eba8-3f78-036a-545d73c87185.png)

以下のソースコードはTurboFramesが関連する部分の一部を抜き出したものです。Turbo Framesはビューの記述のみです。
```erb:app/views/players/index.html.erb
<%= turbo_frame_tag :player do %>
  <!-- ここにプレーヤー部分のビューを記述 -->
<% end %>
<%= turbo_frame_tag :viewer, autoscroll: true, "data-autoscroll_block": "start" do %>
  <!-- ここに表示部分のビューを記述 -->
<% end %>
```

```erb:app/views/albums/show.html.erb
<%= turbo_frame_tag :viewer, autoscroll: true, "data-autoscroll_block": "start" do %>
  <!-- 他のフレームを変更するリンク：アルバム名の左の再生ボタン -->
  <%= link_to players_select_album_path(album_uri: @spotify_album.uri), "data-turbo-frame": "player" do %>
    <span class="material-icons icon album-play-icon">play_circle</span>
  <% end %>
  <h2 class="title is-size-4 album-title"><%= @spotify_album.name %></h2>
  <!-- 自分のフレームを変更するリンク：アルバム名の下のアーティスト名 -->
  <p class="album-artist-spotify is-size-6 mb-4">
    <%== @discogs_album.artists.map { |at| link_to(at["name"], show_artist_path(id: at["id"])) }.join(", ") %>
  </p>
<% end %>
```

### 解説
- `turbo_frame_tag`で囲んだ範囲がひとつのフレームになる
- 始点になるページのビューに`turbo_frame_tag`を配置してフレームの枠を準備する
  - ここでは`players/index.html.erb`が該当する
- 遷移後のページのビューは`turbo_frame_tag`で開始する必要がある
  - そうしておかないと通常のリンクになる
  - ここでは`albums/show.html.erb`が該当する
- 変更対象のフレーム
  - 指定がなければリンクを置いたフレームが対象になる
  - リンクに`"data-turbo-frame": フレーム名`をつけると他のフレームを対象にできる
- スクロール
  - Turbo Framesで書き換えた際に何も指定していない場合はスクロール位置が変わらない
    - スクロールしている場合、中途半端なところから表示されてしまうことになる
  - 書き換えたときに表示する位置を変更したい場合は`autoscroll: true`と`"data-autoscroll-block": 移動先`というオプションをつける
    - 今回は移動先としてstartを指定しているので先頭に移動する

## Turbo Streams
Turbo StreamsはTurbo Framesと同じようにページ内の特定の部分のみを書き換える機能です。使い分けとしては以下のような場合にTurbo Streamsを使用することになります。
- Turbo Framesよりも限定した範囲を書き換える
- 複数個所を同時に書き換える
  - Turbo Framesだとひとつのフレームしか変更できない
- 操作を行ったブラウザ以外への反映（ブロードキャスト）
  - 今回は使用していない

今回はアーティスト情報ページでの参加作品一覧の追加読み込みに使っており、一覧への項目の追加と追加読み込みリンクの更新を行っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206611/6f919534-eb35-d0ab-054a-cf97c8361e1f.png)

関連するファイルは以下の通りです。ファイル数が多いので役割も書いておきます。

| ファイル | 役割 |
| ---- | ---- |
| app/controllers/artists_controller.rb | コントローラ |
| app/views/artists/show.html.erb | アーティスト情報ページのビュー |
| app/views/artists/_album.html.erb | 参加作品一覧を表示するパーシャル |
| app/views/artists/_show_more.html.erb | 追加読み込みのリンクを表示するパーシャル |
| app/views/artists/show_artist_albums.turbo_stream.erb | Turbo Streamのレスポンス |

```rb:app/controllers/artists_controller.rb
def show_artist_albums
  page = params[:page].to_i
  offset = (page - 1) * ALBUM_PER_PAGE
  @albums = @artist.albums[offset, ALBUM_PER_PAGE]
  next_page = page + 1
  is_last_page = @artist.albums.size <= ALBUM_PER_PAGE * page
  @pager = { id: @artist.id, next_page: next_page, is_last_page: is_last_page }
  # ここから上はページング処理なので気にしなくてもよい
  respond_to do |format|
    format.turbo_stream
    format.html { redirect_to show_artist_path(id: @artist.id) }
  end
end
```

```erb:app/views/artists/show.html.erb
<table class="table">
  <thead>
  <tr>
    <th>title</th>
    <th>artist</th>
    <th>year</th>
    <th>type</th>
    <th>label</th>
    <th>versions</th>
  </tr>
  </thead>
  <tbody id="albums">
    <%= render partial: "album", collection: @albums %>
  </tbody>
</table>
<%= render partial: "show_more", locals: { pager: @pager } %>
```
```erb:app/views/artists/_album.html.erb
<tr>
  <td>
    <%= link_to album[:title], show_album_path(type: album[:album_type], id: album[:album_id]) %>
  </td>
  <td>
    <%= album[:is_main] ? "★" :"" %><%= link_to album[:artist], show_artist_path(id: album[:artist_id]) %>
  </td>
  <td><%= album[:year] %></td>
  <td><%= album[:type] %></td>
  <td><%= album[:label] %></td>
  <td><%= album[:versions].present? ? "#{album[:versions]} versions" : "" %></td>
</tr>
```

```erb:app/views/artists/_show_more.html.erb
<div id="show-more">
  <% unless pager[:is_last_page] %>
    <%= link_to show_artist_albums_path(id: pager[:id], page: pager[:next_page]), "data-turbo-stream": true do %>
      <%= button_tag "show more", type: :input, class: "button is-link is-small" %>
    <% end %>
  <% end %>
</div>
```

```erb:app/views/artists/show_artist_albums.turbo_stream.erb
<%= turbo_stream.append "albums" do %>
  <%= render partial: "album", collection: @albums %>
<% end %>

<%= turbo_stream.replace "show-more" do %>
  <%= render partial: "show_more", locals: { pager: @pager } %>
<% end %>
```

### 解説
- 処理の流れは下記の通り
  1. `artists_controller.rb`の`show_artist_albums`メソッドが呼ばれる
  1. レスポンスとして`show_artist_albums.turbo_stream.erb`を返す
  1. `show.html.erb`の`<tbody id="albums">`の末尾に`_album.html.erb`の内容を追加する
  1. `/_show_more.html.erb`の`<div id="show-more">`の内容を書き換える
- コントローラで`format.turbo_stream`とするとTurbo Streamとしてのレスポンスを返すことができる
  - GETメソッド以外では呼び出し元での指定は必要ない
  - GETメソッドでは呼び出し元に`"data-turbo-stream": true`をつける必要がある
- 明示的に指定しない場合、Turbo Streamsのレスポンスは`コントローラ名.turbo_stream.erb`を返す
  - ここでは`show_artist_albums.turbo_stream.erb`を返す
  - ビューを指定しない場合に`コントローラ名.html.erb`を描画するのと同じ感覚
- Turbo Streamsのレスポンス
  - `turbo_stream.操作 対象ID`といったように操作方法を指定する
    - 操作は7種類ある
    - `append`は対象の末尾に要素を追加する
      - ここでは`<tbody id="albums">`の末尾に次のページの情報を追加している
    - `replace`は対象を置き換える
      - ここでは`<div id="show-more">`の中身を置き換えている
      - 対象IDを持つ要素も置き換える対象に含まれているため、置き換えた後に同じIDを持つ項目がないと対象から外れてしまう
  - `turbo_stream`のブロックの中の記述は通常のビューと同じ
- ここでは触れないがTurbo Streamsの書き方は複数ある
  - 例えば、コントローラ側で書き換える内容を指定することも可能

## Stimulus
StimulusはHotwireでJavaScriptを実行する仕組みです。これまでのTurbo～はサーバサイドを経由しますが、これはクライアントサイドで完結します。普通にJavaScriptを書く場合と比べると、Stimulusを使ったほうがJavaScriptとHTMLの繋がりが明確になって見通しが良くなると個人的には感じています。
今回はキーワード検索のリクエスト先を切り替えるために使用しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206611/8246b8bf-425b-136c-739b-35fcc7fcc05c.png)

関連するファイルは以下の通りです。

```js:app/javascript/controllers/player_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "type", "artistsSearchPath", "albumsSearchPath" ]

  selectType() {
    this.typeTargets.forEach((el, i) => {
      if (event.target == el) {
        if (el.value == "artist") {
          this.element.action = this.artistsSearchPathTarget.value;
        } else {
          this.element.action = this.albumsSearchPathTarget.value;
        }
      }
    })
  }
}
```

```js:app/javascript/controllers/index.js
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName

import { application } from "./application"

import PlayerController from "./player_controller"
application.register("player", PlayerController)
```

```erb:app/views/players/index.html.erb
<%= form_tag(artists_search_path, "data-turbo-frame": "search-result", "data-controller": "player", autoscroll: true, "data-autoscroll-block": :start) do %>
  <div class="control">
    <label class="radio search-type-label">
      <%= radio_button_tag :type, "artist", true, class: "search-type-radio", "data-player-target": "type", "data-action": "click->player#selectType" %>
      artist
    </label>
    <label class="radio search-type-label">
      <%= radio_button_tag :type, "album", false, class: "search-type-radio", "data-player-target": "type", "data-action": "click->player#selectType"  %>
      album
    </label>
  </div>
  <div class="controll">
    <%= text_field_tag :name, "", placeholder: "artist name or album title", class: "input search-text" %>
    <%= submit_tag "search", class: "button is-link" %>
  </div>
  <%= hidden_field_tag :artists_search_path, artists_search_path, "data-player-target": "artistsSearchPath" %>
  <%= hidden_field_tag :albums_search_path, albums_search_path, "data-player-target": "albumsSearchPath" %>
<% end %>
```

### 解説
- Stimulusで使用するJavaScriptは`app/javascript/controllers/`以下に配置する
  - ファイル名は「_controller.js」で終える必要がある
  - 作成したファイルは`app/javascript/controllers/index.js`にインポートする必要がある
  - `rails generate stimulus`コマンドでコントローラの作成とindex.jsへのインポートができる
- erbで`data-controller`属性を持った要素で囲んだ範囲が対象になる
  - `data-controller`の値には関連付けるJavaScriptの名称を入れる
- `"data-action": "click->player#selectType"`で要素のクリック時にplayer_contoller.jsのselectTypeメソッドを呼んでいる
  - `click->`がトリガーとなる操作でそれ以降は呼び出すメソッド名
  - 要素によって標準のトリガーが決まっていて標準のトリガーであれば省略できる
- JavaScriptとHTMLとの関連付け
  - JavaScriptで`static targets`に宣言した項目を通じて要素にアクセスできる
    - Rubyにおける`attr_accessor`のようなイメージ
  - erbでは`data-コントローラ名-target: 変数名`という属性を持たせる
  - JavaScriptからは`変数名Target`というような指定の仕方でアクセスできる
  - 複数の要素に同じ変数名を関連づけることができる
    - その場合は`変数名Targets`とするとすべての要素にアクセスできる
    - ラジオボタンで選択している項目はとれなかったのでループして選択した要素を特定している
- 通常のJavaScriptの機能は持っているので`document.getElementById`などを使って範囲外の要素を触ることもできる
  - ただ、それをやってしまうとStimulusを使う意味がなくなるのでやらないほうが良さそう

# まとめ
HotwireによってJavaScriptを使わずにSPAのような画面遷移を実現できました（JavaScriptを使っている部分はSPAとは関係のない部分です）。基本的な機能はここで取り上げたものだけなので学習コストはそれほど高くありません。少なくともJavaScriptでSPAを構築できるようになるために必要な学習コストよりは低いと思います。
一方で気になった点もあり、JavaScriptを使ったSPAと比べると見た目の細かい変更などは制御しにくいと感じました。そのため、変化の多いSPAには向いていなさそうです。
上で書いたようにHotwireがマッチしないケースもありそうですが、JavaScriptよりもRailsのほうが得意なエンジニアとしてはRailsに近い書き方でSPAのような使用感を提供できることには魅力を感じました。