🦔

Turbo Stream とは何なのか

2022/02/23に公開

はじめに

Rails 7.0 で rails new すると、turbo-rails gemがインストールされ、app/javascript/application.jsimport "@hotwired/turbo-rails" と記載されています。turbo-railsとは何なのか検索してみると、どうやら Hotwire について知る必要があったので、調べてみたところ、さらに Hotwire も複数の要素から構成されていることがわかりました。

この記事では Hotwire を構成する要素の一つ Turbo Streams について試してみたことをまとめました。

https://zenn.dev/takeyuweb/articles/8ebe80bf442dc2

https://zenn.dev/takeyuweb/articles/4bbb3df6ef6344

Hotwire

Hotwireは、JavaScriptを極力書かずにモダンなアプリケーションを作成するためのフレームワークです。開発者の好みのサーバー側プログラミング言語を使ってリッチなアプリケーションを作れるという触れ込みです。

Hotwire自体はライブラリではなく、実態は複数のライブラリを統合したものです。

詳しくは解説してくださっている記事がありますので、そちらをお読みください。
https://techracho.bpsinc.jp/hachi8833/2021_06_09/108495
https://logmi.jp/tech/articles/324219
https://zenn.dev/en30/articles/2e8e0c55c128e0

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操作を発生する場合を考えてみます。

  1. ユーザーがFormをsubmit
  2. Turbo が横取りして、XHRでPOST
    • このとき Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml ヘッダーを送信する
  3. サーバーは text/vnd.turbo-stream.html に対応する応答を返す
    • Content-Type: text/vnd.turbo-stream.html; charset=utf-8 ヘッダーを付与
    • turbo-stream カスタム要素を含むHTMLフラグメント
  4. Turbo は受け取った応答が text/vnd.turbo-stream.htm だったので、ページの差し替え(Turbo Drive)ではなく、Turbo Streams として turbo-stream カスタム要素を解析、DOM更新

また、 Turbo Streams はフォーム送信以外にも様々なソースをトリガーに発火できます。 たとえばAction Cable (WebSockets) であれば次のようになります。

  1. ブラウザが Turbo Streams 用のチャンネルを購読
  2. サーバが任意のタイミングで Turbo Streams 用のチャンネルに対してメッセージを配信
  3. ブラウザで Turbo が受け取ったメッセージに含まれるHTMLフラグメント中の turbo-stream カスタム要素を解析、DOM更新

HTMLフラグメントの配信方法に依らず、同じようにDOM更新を行え、またJavaScriptの記述は不要です。

ポイント

  • DOM更新についての指示をHTMLフラグメントで記述してサーバから送出すると、Turboが解析して実行してくれる
  • DOM更新の具体的な内容は、turbo-streamカスタム要素で記述する
  • WebSocketなどのプッシュ技術と組み合わせたページ更新も可能

Turbo Streams App の作り方

段階的に Turbo Streams を導入し、Turbo Streams で実現できる、次の二つのことを試してみます。

  1. フォーム送信をトリガーにした 2 箇所以上の更新。Turbo Frames では 1 箇所しか更新できませんでした。
  2. ActionCable を利用した WebSocket によるブロードキャストによる、リアルタイム更新。Turbo Frames ではリンクかフォーム送信でしか更新できませんでした。

サンプルコード

https://github.com/takeyuweb/rails-turbo-streams-demo

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>

https://github.com/takeyuweb/rails-turbo-streams-demo/commit/c222a21cfbb8acaa7e35501dc7341cb171003570

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に書けますね。

https://github.com/hotwired/turbo-rails/blob/v1.0.1/app/models/turbo/streams/tag_builder.rb

<!-- 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 %>

https://github.com/takeyuweb/rails-turbo-streams-demo/commit/f73c72a715d2db0af4a433953a569cc0f1fee268

完成

以上で、画面遷移無し、JavaScriptなしにフォーム送信後のリセット&動的な要素の追加を実現できました。

なお、JavaScript が無効化されている場合、通常のフォーム送信として正しく機能します。(Controller の format.html による)。Turbo面白いですね。

Step 3. ブロードキャスト更新を実現

次は、送信したメッセージを現在表示中のすべてのブラウザのメッセージ一覧にリアルタイムで追加します。

仕組み

ブラウザで表示すると、TurboはActionCableチャンネルの購読を開始します。

サーバはメッセージが作成したときに、購読されているチャンネルに対して turbo-stream カスタム要素を含むHTMLフラグメントをブロードキャストします。

Turboは購読中のチャンネルを通じて受け取った turbo-stream カスタム要素に従って表示中のページを更新します。

https://github.com/takeyuweb/rails-turbo-streams-demo/commit/21c869543415c337f315853f9b55725e9465aeeb

Controller

メッセージ作成後、購読されているチャンネルに対して、メッセージ一覧への追加をブロードキャストします。

turbo-rails gemによってモデルに追加されている broadcast_prepend_to または broadcast_prepend_later_toを使います。later が付いている方はActiveJobによる非同期処理でブロードキャストします。

今回は子要素の先頭への挿入なので prepend ですが、その他のアクションを行いたい場合は便宜対応するヘルパーを使います。

各ヘルパーについてはコードを確認するのが早いです。

https://github.com/hotwired/turbo-rails/blob/a289693be2577c090a27e370a62e7e2e9b9b618c/app/models/concerns/turbo/broadcastable.rb

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 gemturbo_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"  %>

https://github.com/hotwired/turbo-rails/blob/v1.0.1/app/helpers/turbo/streams_helper.rb#L47-L52

完成

以上で、最小限のコードでリアルタイムWebアプリを構築することができました。

rails-turbo-streams-demo

まとめ

Turbo Streams を使用することで、面倒だったリアルタイム更新や、動的な画面更新をご少なく、かつわかりやすいコードで書くことができました。

またこれまでJavaScriptで行っていたようなDOM更新をサーバーサイドの言語と作法で書くことができるのは、体験として良かったと思います。

個人や極小さなRailsチームで、かつUIや仕様をTurboの規約に寄せることができる案件であれば、かなり面白い技術ではないかと個人的には感じています。

弊社では使えるものを小さく素早く提供する受託開発が主ですので、今後使っていきたいと思います。

タケユー・ウェブ株式会社

Discussion