Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: カスタムTurbo Streamでブラウザタブのカウンタをリアルタイム更新する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: カスタムTurbo Streamでブラウザタブのカウンタをリアルタイム更新する(翻訳)

数週間前に、あるお客様が初めてSaaSを立ち上げるお手伝いをいたしました。ここで解決しようとしている小さな問題は特定のニッチな分野を対象としていますが、スモールビジネスとして有望で、早くも数名の顧客を獲得してサービスを提供しているとのことです...おっと話が逸れました。
そのアプリのメイン画面はレコードのリストを表示します。このアプリはブラウザのタブで固定しておくのに最適なので、新規レコードの件数をカウンタをタブにリアルタイム表示するのはうまいアイデアです。

本記事では、Railsで(カスタムの)Turbo Streamを使って、このリアルタイムカウンタ機能をいかに手軽に実現できるかについて解説します。カスタムのTurbo Streamについては過去記事でも取り上げましたが、こんなふうに自分できれいに書く方法までは解説していませんでした。

ブラウザタブのリアルタイムカウンタは以下のような感じで動作します。メッセージ数に応じてタブのタイトルが更新されていることにご注目ください。

コードはいつものように以下のリポジトリで参照いただけます。

rails-designer-repos/turbo-title - GitHub

🔗 Turbo Streamのカスタムアクションを作成する

タイトルカウンタのデモを行うために、シンプルなメッセージシステムをリポジトリに作成しました。カウンタが動く様子を見せるためにRailsのscaffoldでさっと作っただけなので、詳しい説明は不要です。

目標は、メッセージが作成・破棄されるたびにタブのページタイトルでカウンタが更新されるようにすることです。
Railsでは、appendprependreplaceremoveなどのさまざまなアクションがTurbo Streamに同梱されていますが、タブのページタイトルを更新するにはカスタムアクションが必要です。

まずは外側から作りましょう。indexビューに初期タイトルとメッセージ数を表示します。

-<% content_for :title, "Messages" %>
+<% content_for :title, @count.positive? ? "#{@count} - Messages" : "Messages" %>

この@countはコントローラから提供します。

 def index
   @messages = Message.all.reverse
+  @count = @messages.count
 end

Turbo Streamのレスポンスでは、通常のアクションに加えてカスタムアクションも呼び出します。

<%# app/views/messages/create.turbo_stream.erb %>
<%= turbo_stream.prepend "messages", partial: @message %>
<%= turbo_stream.set_title_counter @count %>
<%# app/views/messages/destroy.turbo_stream.erb %>
<%= turbo_stream.remove @message %>
<%= turbo_stream.set_title_counter @count %>

このset_title_counterが、通常のTurbo Streamアクションと同じように呼び出されていることにご注目ください。こういうクリーンなAPIが欲しかったのです。

次に、カスタムTurbo Streamにタグ付けするヘルパーメソッドを作成します。

# app/helpers/turbo_stream_actions_helper.rb
module TurboStreamActionsHelper
  def set_title_counter(count, divider: nil)
    turbo_stream_action_tag :set_title_counter, count: count, divider: divider
  end
end

Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)

このturbo_stream_action_tagメソッドは、Turbo Streamのカスタムアクションを「Rails way」で作成します。このメソッドが生成するHTMLタグには、アクション名と、メソッドに渡した任意の属性が含まれるようになります。
その下のprepend行は、ビューのturbo_streamオブジェクトでこのヘルパーを利用可能にするためのものです。

🔗 Turbo Streamのタグ

Turbo StreamのカスタムアクションをJavaScriptで作る前に、このヘルパーがどんなHTMLを生成するのかを理解しておくと役に立ちます。
turbo_stream.set_title_counter @countを呼び出すと、以下のようなタグが生成されます。

<turbo-stream action="set_title_counter" count="5">
  <template></template>
</turbo-stream>

この構造が、Turbo Stream組み込みのアクションと同じ構造になっているのがポイントです。
action属性は、どのJavaScript関数を呼び出すべきかをTurboに知らせます(この関数はこの後で作ります)。
count属性は、JavaScriptアクションから読み出すカスタムデータです。
<template></template>という空のタグは、ページにHTMLを何も挿入しませんが、Turbo Streamのフォーマット上必要です。

Turboがこのタグにさしかかると、set_title_counter関数をTurbo.StreamActions内で探索して実行します。
この関数のコードは以下のとおりです。

// app/javascript/turbo_stream_actions/set_title_counter.js
export default function() {
  const count = this.getAttribute("count") || 0
  const divider = this.getAttribute("divider") || "-"

  const title = document.title
  const baseTitle = title.includes(divider) ? title.split(divider).pop().trim() : title

  document.title = count > 0 ? `${count} ${divider} ${baseTitle}` : baseTitle
}

このset_title_counter関数は、Turbo Streamのタグからcount属性とdivider属性を読み取ってから、既存のカウンタを削除してベースタイトルを抽出します。
カウンタが0より大きい場合は、カウンタの前に区切り文字-を追加します。それ以外の場合はベースタイトルのみを表示します。

この関数で重要なのは、ベースタイトルを抽出するロジックです。この関数は、カウンタが既に存在するかどうかを「区切り文字-が存在するかどうか」で判定します。区切り文字が存在する場合は、文字列を区切り線で分割して、後ろ半分(つまりベースタイトル)のみを取り出します。こうすることで、カウンタの更新を繰り返してもカウンタが重複しないようになります。

それではこの関数をTurboに登録して動かしてみましょう。

// app/javascript/turbo_stream_actions/index.js
import { Turbo } from "@hotwired/turbo-rails"
import set_title_counter from "turbo_stream_actions/set_title_counter"

Turbo.StreamActions.set_title_counter = set_title_counter

これをメインのapplication.jsファイルでインポートします。

 import "@hotwired/turbo-rails"
 import "controllers"
+import "turbo_stream_actions"

config/importmap.rbを更新して新しいディレクトリを知らせます(npmを使っている場合は不要です)。

 pin_all_from
 "app/javascript/controllers", under: "controllers"
+
+pin_all_from "app/javascript/turbo_stream_actions", under: "turbo_stream_actions"

最後に、コントローラのアクションから@count = Message.all.countでカウントを提供するようにします。
これで、冒頭のデモ動画のように新しいカスタムアクションがすべて接続され、リアルタイムカウンタが動作するようになります。やった!

Turbo Streamの新しいカスタムアクションは、ユーザーと直接やりとりするのに最適です。しかし他のユーザーによる更新やバックグラウンドジョブによる更新を使う場合はどうでしょうか?Turbo Streamのブロードキャストは、これもちゃんと同様に処理できます。

以下のMessageモデルは変更をブロードキャストして、ブラウザタブのタイトルカウンタ更新をトリガーします。

 class Message < ApplicationRecord
+  broadcasts :messages, inserts_by: :prepend
+
+  after_commit :set_title_counter
+
   def title = "Message #{id}"
+
+  private
+
+  def set_title_counter
+    broadcast_action_to :messages, action: :set_title_counter, attributes: { count: Message.count }
+  end
 end

このbroadcasts行は、標準のcreateupdate(上のリポジトリでは使っていませんが、メッセージを「既読」にするのに使えます)、そしてdestroyのブロードキャストを処理します。
after_commitコールバックによって、データベースで変更が発生したときにタイトルカウンタのカスタム更新が送信されます。

このbroadcast_action_toメソッドは、先ほど作ったヘルパーと同様に、Turbo Streamアクションをブロードキャストチャネルに送信します。attributeハッシュはタグのHTML属性となり、これがJavaScriptのアクションによって読み取られます。

最後に、ビューでブロードキャストにサブスクライブしましょう。

 <% content_for :title, @count.positive? ? "#{@count} - Messages" : "Messages" %>
+<%= turbo_stream_from :messages %>

これで、どのユーザーがメッセージを作成・削除しても、接続しているすべてのクライアントでブラウザタブのタイトルカウンタがリアルタイム更新されるようになります。


この新しいカスタムアクションは、RailsのTurbo Streamとスムーズに連携します。ユーザーに直接レスポンスを返すことも、ブロードキャストすることも可能です。カスタムアクションは単なるJavaScriptコードなので、アニメーションや通知など必要なブラウザAPIを追加して好きなように拡張できます。クールだと思いませんか?

関連記事

Rails: 最新のブラウザ機能で「JavaScriptなし」のスタイル付き確認ダイアログを構築する(翻訳)

Rails: Turbo Frameの読み込みプログレス表示をCSSだけで実現する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。