Turbo Stream とは何なのか
はじめに
Rails 7.0 で rails new
すると、turbo-rails gemがインストールされ、app/javascript/application.js
に import "@hotwired/turbo-rails"
と記載されています。turbo-railsとは何なのか検索してみると、どうやら Hotwire について知る必要があったので、調べてみたところ、さらに Hotwire も複数の要素から構成されていることがわかりました。
この記事では Hotwire を構成する要素の一つ Turbo Streams について試してみたことをまとめました。
Hotwire
Hotwireは、JavaScriptを極力書かずにモダンなアプリケーションを作成するためのフレームワークです。開発者の好みのサーバー側プログラミング言語を使ってリッチなアプリケーションを作れるという触れ込みです。
Hotwire自体はライブラリではなく、実態は複数のライブラリを統合したものです。
詳しくは解説してくださっている記事がありますので、そちらをお読みください。
turbo-rails
TurboはHotwireの中心となる要素です。
TurboはTypeScriptで書かれたサーバー側言語に依存しないフレームワークですが、サーバー側言語との接続にはアダプタを書く必要があります。 Railsで使う場合はすでに用意されていて、便利なヘルパーを使うことができます。このヘルパーを実装するのが turbo-rails gem です。
Turbo Streams
Turbo Frames は、webページ中の特定の範囲を簡単に書き換えることができました。しかしながら、Turbo Frames では次のことができません。
- リンククリックやフォーム送信以外をトリガーにした書き換え
- 2箇所以上の書き換え
このような場合は、Turbo Streamsを使います。
仕組み
基本的な仕組みは次の通りです。
- Turbo が Turbo Streams 用のメッセージを受け取ったら、DOM更新
- DOM更新内容は、
turbo-stream
カスタム要素を含むHTMLフラグメントで記述する
具体例として、フォーム送信をトリガーに Turbo Streams によるDOM操作を発生する場合を考えてみます。
- ユーザーがFormをsubmit
- Turbo が横取りして、XHRでPOST
- このとき
Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml
ヘッダーを送信する
- このとき
- サーバーは
text/vnd.turbo-stream.html
に対応する応答を返す-
Content-Type: text/vnd.turbo-stream.html; charset=utf-8
ヘッダーを付与 -
turbo-stream
カスタム要素を含むHTMLフラグメント
-
- Turbo は受け取った応答が
text/vnd.turbo-stream.htm
だったので、ページの差し替え(Turbo Drive)ではなく、Turbo Streams としてturbo-stream
カスタム要素を解析、DOM更新
また、 Turbo Streams はフォーム送信以外にも様々なソースをトリガーに発火できます。 たとえばAction Cable (WebSockets) であれば次のようになります。
- ブラウザが Turbo Streams 用のチャンネルを購読
- サーバが任意のタイミングで Turbo Streams 用のチャンネルに対してメッセージを配信
- ブラウザで Turbo が受け取ったメッセージに含まれるHTMLフラグメント中の
turbo-stream
カスタム要素を解析、DOM更新
HTMLフラグメントの配信方法に依らず、同じようにDOM更新を行え、またJavaScriptの記述は不要です。
ポイント
- DOM更新についての指示をHTMLフラグメントで記述してサーバから送出すると、Turboが解析して実行してくれる
- DOM更新の具体的な内容は、
turbo-stream
カスタム要素で記述する - WebSocketなどのプッシュ技術と組み合わせたページ更新も可能
Turbo Streams App の作り方
段階的に Turbo Streams を導入し、Turbo Streams で実現できる、次の二つのことを試してみます。
- フォーム送信をトリガーにした 2 箇所以上の更新。Turbo Frames では 1 箇所しか更新できませんでした。
- ActionCable を利用した WebSocket によるブロードキャストによる、リアルタイム更新。Turbo Frames ではリンクかフォーム送信でしか更新できませんでした。
サンプルコード
Step 1. Turbo Streams なしに構築
一般的なページ遷移を伴う Rails アプリケーション(メッセージボード)を構築します。
class MessagesController < ApplicationController
def index
@message = Message.new
@messages = Message.order(created_at: :desc)
end
def create
@message = Message.new(params.require(:message).permit(:body))
if @message.save
redirect_to messages_url
else
@messages = Message.order(created_at: :desc)
render action: :index, status: :unprocessable_entity
end
end
end
<!-- index.html.erb -->
<div id="new_message" class="mb-5">
<%= form_with model: @message do |form| %>
<%= render partial: "form", locals: { form: form } %>
<% end %>
</div>
<div id="messages">
<%= render @messages %>
</div>
Step 2. フォーム送信をトリガーにした 2 箇所以上の更新を実現
フォームを送信するとフォームの内容をリセットして、送信したメッセージをメッセージ一覧に追加するようにします。
Turbo Streams を使うと、JavaScriptなしにサーバー実装だけで書けます。
Controller
respond_to を使って Turbo Streams のリクエストに対する応答を追加します。
この応答は format.turbo_stream
を使って書くことができます。(turbo-rails gem で追加されています)
class MessagesController < ApplicationController
def index
@message = Message.new
@messages = Message.order(created_at: :desc)
end
def create
@message = Message.new(params.require(:message).permit(:body))
respond_to do |format| # 追加
if @message.save
format.html { redirect_to messages_url }
# 追加。text/vnd.turbo-stream.html なら create.turbo_stream.erb を処理して返す
format.turbo_stream
else
@messages = Message.order(created_at: :desc)
# エラー画面は text/html で返す
# Turbo は Turbo Drive として処理しページを差し替えるので、 html だけでよい
format.html { render action: :index, status: :unprocessable_entity }
end
end
end
View
create アクションの format.turbo_stream
で処理される create.turbo_stream.erb
を書きます。
このテンプレートでは turbo-rails gem
によって追加される turbo_stream
タグビルダーを使用できます。
今回は
-
id="new_message"
の要素の差し替えをしたいのでturbo_stream.replace
-
id="messages"
の子要素先頭への挿入をしたいのでturbo_stream.prepend
を使います。
通常のRailsのビューテンプレートのため部分テンプレートももちろん使えてDRYに書けますね。
<!-- create.turbo_stream.erb -->
<!-- 「id="new_message" の要素をブロックの内容で書き換える」 -->
<%= turbo_stream.replace "new_message" do %>
<%= form_with model: Message.new do |form| %>
<%= render partial: "form", locals: { form: form } %>
<% end %>
<% end %>
<!-- 「id="messages" の要素の子要素先頭にブロックの内容を差し込む」 -->
<%= turbo_stream.prepend "messages" do %>
<%= render @message %>
<% end %>
完成
以上で、画面遷移無し、JavaScriptなしにフォーム送信後のリセット&動的な要素の追加を実現できました。
なお、JavaScript が無効化されている場合、通常のフォーム送信として正しく機能します。(Controller の format.html
による)。Turbo面白いですね。
Step 3. ブロードキャスト更新を実現
次は、送信したメッセージを現在表示中のすべてのブラウザのメッセージ一覧にリアルタイムで追加します。
仕組み
ブラウザで表示すると、TurboはActionCableチャンネルの購読を開始します。
サーバはメッセージが作成したときに、購読されているチャンネルに対して turbo-stream
カスタム要素を含むHTMLフラグメントをブロードキャストします。
Turboは購読中のチャンネルを通じて受け取った turbo-stream
カスタム要素に従って表示中のページを更新します。
Controller
メッセージ作成後、購読されているチャンネルに対して、メッセージ一覧への追加をブロードキャストします。
turbo-rails gem
によってモデルに追加されている broadcast_prepend_to または broadcast_prepend_later_toを使います。later
が付いている方はActiveJobによる非同期処理でブロードキャストします。
今回は子要素の先頭への挿入なので prepend
ですが、その他のアクションを行いたい場合は便宜対応するヘルパーを使います。
各ヘルパーについてはコードを確認するのが早いです。
class MessagesController < ApplicationController
def index
@message = Message.new
@messages = Message.order(created_at: :desc)
end
def create
@message = Message.new(params.require(:message).permit(:body))
respond_to do |format| # 追加
if @message.save
# "messages_channel" チャンネルに対して非同期でブロードキャスト
# 追加するHTMLを構築するために使用するテンプレートは
# 暗黙に @message.to_partial_path で決定されるが、
# 明示したい場合は partial: "messages/message" のようにオプションで指定する
@message.broadcast_prepend_later_to("messages_channel") # 追加
format.html { redirect_to messages_url }
format.turbo_stream
else
@messages = Message.order(created_at: :desc)
format.html { render action: :index, status: :unprocessable_entity }
end
end
end
Model
Controller でなく、モデルのコールバックでブロードキャストすることもできます。
class Message < ApplicationRecord
validates :body, presence: true
# To broadcast in a model callback, write the following
# after_create_commit -> { broadcast_prepend_to("messages_channel", partial: "messages/message") } # Specify a partial template
# after_create_commit -> { broadcast_prepend_later_to("messages_channel") } # Broadcast asynchronously using ActiveJob.
# after_create_commit -> { broadcast_prepend_to("messages_channel") }
end
View
メッセージ一覧への追加はブロードキャストを使うことにしたので、フォーム応答としてはフォームのリセットだけするようにします。
<!-- create.turbo_stream.erb -->
<!-- 「id="new_message" の要素をブロックの内容で書き換える」 -->
<%= turbo_stream.replace "new_message" do %>
<%= form_with model: Message.new do |form| %>
<%= render partial: "form", locals: { form: form } %>
<% end %>
<% end %>
TurboでActionCableのチャンネル購読するために turbo-rails gem
の turbo_stream_from
ヘルパーを使用します。
<!-- index.html.erb -->
<div id="new_message" class="mb-5">
<%= form_with model: @message do |form| %>
<%= render partial: "form", locals: { form: form } %>
<% end %>
</div>
<div id="messages">
<%= render @messages %>
</div>
<!-- "messages_channel" チャンネルを Turbo Stream に接続する -->
<%= turbo_stream_from "messages_channel" %>
完成
以上で、最小限のコードでリアルタイムWebアプリを構築することができました。
まとめ
Turbo Streams を使用することで、面倒だったリアルタイム更新や、動的な画面更新をご少なく、かつわかりやすいコードで書くことができました。
またこれまでJavaScriptで行っていたようなDOM更新をサーバーサイドの言語と作法で書くことができるのは、体験として良かったと思います。
個人や極小さなRailsチームで、かつUIや仕様をTurboの規約に寄せることができる案件であれば、かなり面白い技術ではないかと個人的には感じています。
弊社では使えるものを小さく素早く提供する受託開発が主ですので、今後使っていきたいと思います。
Discussion