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

HotwireはRailsを「ゼロJavaScript」でリアクティブにできるか?後編(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

長文につき前編と後編に分割しました。今回は後編です。

HotwireはRailsを「ゼロJavaScript」でリアクティブにできるか?前編(翻訳)

🔗 Turbo Streamsでストリーミングする

Turbo DriveやTurbo Framesと比べると、Turbo Streamsはまったく新しい技術です。Turbo DriveやTurbo Framesと異なり、Turbo Stremsは「明示的」です。つまり、Turbo Stremsがあっても何かが「自動的に」動くことはなく、ページ上で何をいつ更新するかは開発者が決めます。Turbo Streamsを使うには、<turbo-stream>要素が必要です。

<turbo-stream>要素の例を見てみましょう。

<turbo-stream action="replace" target="flash-alerts">
  <template>
    <div class="flash-alerts--container" id="flash-alerts">
      <!--  -->
    </div>
  </template>
</turbo-stream>

<turbo-stream>要素は、DOM IDがflash-alertsのノードを、<template>タグ内に渡された新しいHTMLコンテンツに置き換える(action="replace")操作を担当します。<turbo-stream>要素をページの好きな場所に置くと、ただちに指定のアクションを実行し、自分自身を削除します。背後ではHTMLの「Custom Elements API」が用いられています。これも、開発者の幸せのため(要するにJavaScriptを減らすため🙂)に最新のWeb APIを用いた例のひとつです。

<turbo-stream>要素について詳しく知りたい方は、stream_element.tsのソースコードをご覧ください。

Turbo Streamsは、JavaScriptテンプレートでやっていたことを宣言的に記述できるものと言えるでしょう。

// destroy.js.erb
$("#<%= dom_id(item) %>").remove();

2010年代には上のように書いていたものを、以下のように書けるのです。

<!--  destroy.html.erb -->
<%= turbo_stream.remove dom_id(item) %>

現時点でサポートされているアクションはappendprependreplaceremoveupdate(ノードのテキストコンテンツのみを置き換える)の5つのみです。こうした制約やそれを乗り越える方法については後述します。

それでは最初の問題に立ち返るとしましょう。ToDoリストの項目を完了または削除したときにフラッシュで通知を表示する方法です。

ここでやりたいことは、<turbo-frame><turbo-stream>の両方について更新を通知することです。どうすればできるでしょうか。試しに、それ用のパーシャルテンプレートを追加してみましょう。

<!-- _item_update.html.erb -->
<%= render item %>

<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

ItemsControllerを以下のように少し変更します。

+    flash.now[:notice] = "Item has been updated"

-    render partial: "item", locals: { item }
+    render partial: "item_update", locals: { item }

しかし残念ながら、上の方法では期待どおりに動きません(フラッシュメッセージがまったく表示されません)。HotwireのTurboのドキュメントを詳しく読んでみると、ストリーム要素を有効にするにはHTTPレスポンスのContent-Typeヘッダーをtext/vnd.turbo-stream.htmlに設定する必要があることがわかりました。なるほど、早速やってみましょう。

-    render partial: "item_update", locals: { item }
+    render partial: "item_update", locals: { item }, content_type: "text/vnd.turbo-stream.html"

これでフラッシュメッセージが表示されるようになりました。しかし今度は項目のコンテンツが更新されません😞。Hotwireに多くを期待しすぎたのでしょうか?Turboのソースコードをみっちり読んでみると、ここでやろうとしたようなストリームとフレームのミックスはできないことがわかりました。

そして、この機能は以下の2とおりの方法で実装できることがわかりました。

  • すべてをストリームにする
  • <turbo-stream><turbo-frame>の内側に置く

2番目の方法は、HTMLパーシャルを通常のページ読み込みやTurboの更新で再利用するというアイデアに反していると個人的に思えたので、1番目の方法でやることにしました。

<!-- _item_update.html.erb -->
<%= turbo_stream.replace dom_id(item) do %>
  <%= render item %>
<% end %>

<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

任務完了です!しかしこのユースケースでは、これと引き換えに新しいテンプレートを追加しなければなりませんでした。現実には、アプリケーションが育つにつれて、この手の「アドホックな」パーシャルが増えてしまいそうです。

原注: 2021-04-13の更新情報

Alex Takitaniが上のツイートでもっとエレガントな方法を教えてくれました↓。フラッシュコンテンツを更新するレイアウトを使うというものです。

この場合、Turbo Streamsのレスポンスに使う以下のアプリケーションレイアウトを定義できます。

<!-- layouts/application.turbo_stream.erb -->
<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

<%= yield %>

続いて、コントローラから明示的なrenderを削除する必要があります(さもないとレイアウトが使われません: #25)。

   def update
     item.update!(item_params)

     flash.now[:notice] = "Item has been updated"
-    render partial: "item_update", locals: { item }, content_type: "text/vnd.turbo-stream.html"
   end

注意: テストコードで暗黙のレンダリングが行われるようにするために、上に対応するコントローラspecやリクエストspecにformat: :turbo_streamを必ず追加しておくこと。

続いて、_item_updateパーシャルを書き換えて Turbo Streamのupdateテンプレートにします。

<!-- update.turbo_stream.erb -->
<%= turbo_stream.replace dom_id(item) do %>
  <%= render item %>
<% end %>

クールですね。これこそRails wayです!

それでは現実の(リアルタイム)ストリーミングについてのお話に移りましょう。

Turbo Streamsは、いわゆる「リアルタイム更新」の文脈でよく引き合いに出されます(StimulusReflexともよく比較されます)。

それでは、リストの同期機能をTurbo Streams上に構築する方法を見ていくことにしましょう。


Turbo Streamsが、接続されている全クライアントでページ更新を同期する様子

Turbo登場以前は、ブロードキャストを扱うためにAction CableのカスタムチャネルとStiumulusコントローラを追加しなければならず、項目の削除と完了を取り違えないようメッセージのフォーマットにも気を配る必要がありました。つまり、メンテナンスするコードが山ほどあったのです。

Turbo Streamsなら、それらのほとんどについて面倒を見てくれます。turbo-rails gemに含まれる一般的なTurbo::StreamsChannelクラスと#turbo_stream_fromヘルパーを使うと、以下のHTMLでサブスクリプションを作成できます。

<!-- worspaces/show.html.erb -->
<div>
  <%= turbo_stream_from workspace %>
  <!-- ... -->
</div>

ここではAction Cableを用いていますが、Turbo Streamsはトランスポート方法に依存しません。他のなんちゃらケーブルを使いたければ、それ用のアダプタを書くだけで更新をクライアントにプッシュできるようになります。

既にコントローラでは更新のブロードキャストを担当する#broadcast_new_item#broadcast_changesという「afterアクション」を使っていたので、必要なのはそれらをTurbo::StreamsChannelに切り替えることだけです。

 def broadcast_changes
   return if item.errors.any?
   if item.destroyed?
-    ListChannel.broadcast_to list, type: "deleted", id: item.id
+    Turbo::StreamsChannel.broadcast_remove_to workspace, target: item
   else
-    ListChannel.broadcast_to list, type: "updated", id: item.id, desc: item.desc, completed: item.completed
+    Turbo::StreamsChannel.broadcast_replace_to workspace, target: item, partial: "items/item", locals: { item }
   end
 end

Turbo::StreamsChannelへの乗り換えはスムーズにやれましたが、惜しくもブロードキャストを検証するコントローラ用の単体テストがすべて失敗してしまいました。

残念ながら現時点のTurbo Railsはテストツールを提供していないので、よくある話ですがテストツールを自分で書かなければなりませんでした(#33659)。

module Turbo::HaveBroadcastedToTurboMatcher
  include Turbo::Streams::StreamName

  def have_broadcasted_turbo_stream_to(*streamables, action:, target:) # rubocop:disable Naming/PredicateName
    target = target.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(target) : target
    have_broadcasted_to(stream_name_from(streamables))
      .with(a_string_matching(%(turbo-stream action="#{action}" target="#{target}")))
  end
end

RSpec.configure do |config|
  config.include Turbo::HaveBroadcastedToTurboMatcher
end

上の新しいマッチャーを用いてテストを以下のように書き換えました。

 it "broadcasts a deleted message" do
-  expect { subject }.to have_broadcasted_to(ListChannel.broadcasting_for(list))
-    .with(type: "deleted", id: item.id)
+  expect { subject }.to have_broadcasted_turbo_stream_to(
+    workspace, action: :remove, target: item
+  )
 end

Turboによるリアルタイム更新はここまでうまく動き、多くのコードが不要になりました。


ここまで一行たりともJavaScriptコードを書いていません。これは果たして現実なのでしょうか?


それともただの幻でしょうか?この夢はいつ覚めるのでしょうか?さっそく現実に戻ることにしましょう。

🔗 Turboの向こう側へ: Stimulusとカスタム要素を使う

Turboへの乗り換え中に、既存のAPIだけでは機能が足りなくなるユースケースがいくつも立ちはだかったので、とうとうJavaScriptを書かなければならなくなるときがやって来ました。

  • ユースケース1: ダッシュボードで新しいリストをリアルタイムに追加する

上の章でやったような項目リストの例と違う部分は、マークアップです。以下のダッシュボードレイアウトを見てみましょう。

<div id="workspace_1">
  <div id="list_1">...</div>
  <div id="list_2">...</div>
  <div id="new_list">
    <form>...</form>
  </div>
</div>

末尾の要素は常に新しいリストフォームのコンテナとし、リストを追加すると#new_listノードの直前に挿入されるようにしたいと思います。先ほど、Turbo Streamsがサポートするアクションがたった5つと申し上げたのを思い出してください。以下は移行前のコードですが、どこが問題かおわかりですか?

この部分については、StimulusReflexファミリーのCableReadyが圧勝しています。CableReadyはinsert_adjacent_htmlを含む30個以上のアクションをサポートしているのです。

handleUpdate(data) {
  this.formTarget.insertAdjacentHTML("beforebegin", data.html);
}

上と同じような振る舞いをTurbo Streamsで実装するには、リストをストリームで追加した直後にリストを適切な位置に移動するハックを追加しなければなりません。それでは独自の「JavaScriptスプリンクル」を追加することにしましょう。

最初に、このタスクに形式的な定義を与えます。「ワークスペースのコンテナに新しいリスト項目が追加されるときは、新しいフォーム要素の直前に置かれるべきである」。この定義の「ワークスペースのコンテナに新しいリスト項目が追加される」タイミングで、DOMを観測(observe)して変更(change)にリアクティブに対応する必要が生じます。どこかで見覚えがありませんか?そう、前編でStimulusの話をしたときに言及したMutationObserver APIです。これを使うことにしましょう。

運のよいことに、この機能を使うために高度なJavaScriptを書く必要はありません。"使う"のはStimulus Useというライブラリだけです(トートロジーですみません)。Stimulus Useは、Stimulusコントローラで使える便利な「振る舞い」のコレクションであり、複雑な問題を解決できるシンプルなスニペット群です。ここで必要なのはuseMutationという振る舞いです。

コントローラで用いるコードはかなり簡潔で、内容も一目瞭然です。

import { Controller } from "stimulus";
import { useMutation } from "stimulus-use";

export default class extends Controller {
  static targets = ["lists", "newForm"];

  connect() {
    [this.observeLists, this.unobserveLists] = useMutation(this, {
      element: this.listsTarget,
      childList: true,
    });
  }

  mutate(entries) {
    // ストリーム経由で新しいリストを1個追加するときはエントリを1個だけにすべき
    const entry = entries[0];

    if (!entry.addedNodes.length) return;

    // childListの変更中はobserverを無効にする
    this.unobserveLists();
    // newFormをchildListの末尾に移動する
    this.listsTarget.append(this.newFormTarget);
    this.observeLists();
  }
}

ユースケース1の問題は解決しました。次に進みましょう。

  • ユースケース2: チャット機能の実装

私たちのアプリには、ダッシュボードごとにごくシンプルなチャット機能が付いていました。ユーザーが送信するメッセージはどこにも保存されない短命なかたちになっていて、リアルタイムで受信できます。

メッセージの外観はコンテキストに応じて変化させます。ユーザー自身が送信したメッセージは緑の枠で囲まれて左側に表示され、他のユーザーのメッセージは灰色の枠で囲まれて右側に表示されます。しかし、チャットに接続しているユーザー全員にブロードキャストされるHTMLはまったく同じです。

ユーザーに合わせてチャットの表示を変えるにはどうすればよいでしょうか?これはチャット的なアプリにありがちな問題で、一般には「ユーザーごとに異なるHTMLを送信する」か「受信したHTMLをクライアント側で加工する」かのどちらかで解決します。私の好みは後者なので、その方法で実装してみましょう。

カレントユーザーの情報をJavaScriptにわたすために、以下のmetaタグを使います。

<!-- layouts/application.html.erb -->
<head>
  <% if logged_in? %>
    <meta name="current-user-name" content="<%= current_user.name %>" data-turbo-track="reload">
    <meta name="current-user-id" content="<%= current_user.id %>" data-turbo-track="reload">
  <% end %>
  <!-- ... -->
</head>

次に、これらの値にアクセスする小さなJSヘルパーを書きます。

let user;

export const currentUser = () => {
  if (user) return user;

  const id = getMeta("id");
  const name = getMeta("name");

  user = { id, name };
  return user;
};

function getMeta(name) {
  const element = document.head.querySelector(
    `meta[name='current-user-${name}']`
  );
  if (element) {
    return element.getAttribute("content");
  }
}

チャットメッセージのブロードキャストにはTurbo::StreamChannelを使います。

def create
  Turbo::StreamsChannel.broadcast_append_to(
    workspace,
    target: ActionView::RecordIdentifier.dom_id(workspace, :chat_messages),
    partial: "chats/message",
    locals: { message: params[:message], name: current_user.name, user_id: current_user.id }
  )
  # ...
end

なお、以下はオリジナルのchat/messageテンプレートです。

<div class="chat--msg">
  <%= message %>
  <span data-role="author" class="chat--msg--author"><%= name %></span>
</div>

以下はカレントユーザーに応じてスタイルを変更する既存のJSコードですが、近日中に削除する予定です。

// これは使わないこと
appendMessage(html, mine) {
  this.messagesTarget.insertAdjacentHTML("beforeend", html);
  const el = this.messagesTarget.lastElementChild;
  el.classList.add(mine ? "mine" : "theirs");

  if (mine) {
    const authorElement = el.querySelector('[data-role="author"]');
    if (authorElement) authorElement.innerText = "You";
  }
}

ところで、HTMLをTurboで更新する場合はこれを別の方法で行う必要があります。もちろんuseMutationをここで使う手もあります。「現実の」プロジェクトではおそらくそうするでしょう。しかし本記事の目的は、問題を解決するさまざまな方法を紹介することです。

本セクションのタイトルに「カスタム要素」という言葉があったのを思い出してください(ここまでの話がすっかり長くなってしまい恐縮です)。カスタム要素はTurboをパワーアップするWeb APIです。これを使ってみてはどうでしょう?

手始めに、HTMLを以下のように更新します。

<any-chat-message class="chat--msg" data-author-id="<%= user_id %>>
  <%= message %>
  <span data-role="author" class="chat--msg--author"><%= name %></span>
</any-chat-message>

変更点は、data-author-id属性を追加したことと、<div>タグを<any-chat-message>というカスタムタグに置き換えたことだけです。

では、このカスタム要素を登録しましょう。

import { currentUser } from "../utils/current_user";

// これが、モダンなAPIでカスタムHTML要素を作成する方法です
export class ChatMessageElement extends HTMLElement {
  connectedCallback() {
    const mine = currentUser().id == this.dataset.authorId;

    this.classList.add(mine ? "mine" : "theirs");

    const authorElement = this.querySelector('[data-role="author"]');

    if (authorElement && mine) authorElement.innerText = "You";
  }
}

customElements.define("any-chat-message", ChatMessageElement);

完成です!これで、ページに<any-chat-message>要素を追加しておけば、カレントユーザーからのメッセージが自動的に更新されるようになりました。このコードではStimulusすら不要です。

本記事で用いた完全なソースコードについては以下のプルリクをどうぞ。

さて、本記事のトピックである「ゼロJavaScriptのリアクティブなRails」は果たして存在するかについてですが、実際にはそうではありませんでした。JavaScriptコードを大幅に削減できたものの、最終的には新しいJavaScriptコードに置き換える必要があります。しかし新しいJavaScriptコードは、従来よりも実用性が高いと言えるでしょう。機能がより高度な分、JavaScriptと最新のブラウザAPIに関する十分な知識も求められます。ここがトレードオフであることは間違いありません。

追伸: CableReadyとStimulusReflexについても同様のプルリク↓を投げていますので、よろしければHotwireと比較してお気づきになった点をEvil MartiansのTwitterまでお知らせいただければ幸いです。


火星からやってきたバックエンド(フロントエンド)スペシャリストたちの力で御社のデジタルプロダクトを強化したいとお考えのお方は、ぜひEvil Martiansのフォームまでご相談をお寄せください。

本記事の翻訳や転載についてのご相談は、まずメールにてお願いします。

関連記事

速報: Basecampがリリースした「Hotwire」の概要


CONTACT

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