Rails: Turbo Morph Driveに見るフルスタックRailsの未来(翻訳)
はじめに
Web開発コミュニティの「フルスタック回帰」トレンドはますます勢いを増しています。フロントエンドフレームワーク界隈ではサーバーコンポーネントの採用を試みていて、新たにhtmxが注目を浴び、LiveViewはElixirアプリケーションを、LiveWireはLaravelアプリケーションを征服しつつあるところです。そしてもちろん、我らがRuby on Railsにもサーバーコンポーネントの新しい子孫であるHotwireがあります。Railsのフルスタックアプローチがどこまで通用するか、そして将来はどうなるのかを探ってみることにしましょう。
シリーズ構成
- Rails: フルスタックRailsの未来(1)Turbo Morph Drive(本記事)
- Rails: フルスタックRailsの未来(2)Turboのビュートランジション(翻訳)
フルスタック技術といえばRuby on Railsが筆頭ですね。理由ですか?生産性の高い小規模チームから常に信頼を寄せられているからです。
Railsフレームワークの作者であるDavid Heinemeier Hansson(DHH)は、ごく最近開催されたRails World conferenceで「Ruby on Railsは個人のためのフレークワークである」と述べています。ここで言う「個人」は、仕様上(技術スタックの一部だけを扱うエンジニアではなく)フルスタックエンジニアしかありえません。
DHHのキーノートスピーチはこちらでご覧いただけます。
Rails Worldというカンファレンスは、新着情報やアナウンスにおいて非常に実りあるイベントでした。中でも本記事にも関連する、とびきり注目株のトピックが、Rails 7からデフォルトのフロントエンドコンポーネントであり続けているTurboの新バージョンであるTurbo 8でした。Turbo 8は、最新のHTMLドリブンWebアプリケーションを構築するライブラリです。
Turbo 8の変更点や新機能の完全なリストはまだわかりませんが、DOMモーフィング(DOM morphing)やページトランジション(page transition)など、既に明らかになっているものもいくつかあります。どちらの機能も適切に策定されている最中であり、最終形がどんなものになるかは明らかでないものの、これらのアイデアを現在のHotwireアプリケーションに適用して探ってみてはならない、などということはありません。
2回シリーズの第1回では、上述のフロントエンド技術をいち早く調査し、現在のTurbo 7でどんなふうに活用できるかを探っていきたいと思います(TypeScriptを楽しむなら今のうち😁)1。
🔗 デモアプリケーション: Turbo Music Drive
そこで、対話型WebアプリケーションをTurboで動かす様子を紹介するデモアプリを作成しました。このアプリの名前はTurbo Music Driveで、音楽ライブラリと、基本的なブラウズ機能をいくつか備えた音楽プレーヤーを兼ねています。Turbo Music DriveはRails 7.1アプリケーションであり、データを配信するためのモデルとコントローラがいくつかあり、クライアント側にStimulusを少々まぶしています。
音楽アプリは、Webフレームワークの機能をデモンストレーションするのに使える新しいToDo MVCと言えます。たとえば、Astroで構築されたこのSpotifyクローンアプリは、Astroにおけるビュートランジション機能2のサポートをデモするためのものです。
以下のアプリを実際に動かして、動作を確認してください🎧。
最終版のデモアプリ
当初は、すべての機能を素のTurbo(v7)で構築しました。JavaScriptを1行たりとも書かずに済んだ割りには、かなりの出来栄えです。まずは基本となるアプリの振る舞いをご覧ください。
素のTurboで作ったときのデモアプリ
これならクールですよね?私もそう思います。しかしじっくり見ていくと、いくつか物足りない部分が目についてきますね。ご心配なく、これからすべてつぶしていきますので。
🔗 モーフィングを採用すべきか、すべきでないか?
モーフィングのトピックを深掘りする前に、Turboがページナビゲーションをどのように実行するかを振り返ってみましょう。
Turbo Driveは、ナビゲーションイベント(リンクのクリックやフォームの送信など)をインターセプトし、バックグラウンドでAJAXリクエストを実行してから、ページのコンテンツを新しいHTMLとブラウザ履歴の両方で更新します。ここでは、「ページのコンテンツを更新する」部分に注目します。
現在のTurbo Driveは、HTMLの<body>
要素全体を新しいHTMLで置き換える形でページのコンテンツを更新します。つまり、この概念を簡単なコードで表すと以下のようになります。
render(newHTML) {
document.body.innerHTML = newHTML;
}
この方法の問題点は、ブラウザページの「ローカルステート」が失われることです。この問題のいくつかをデモアプリで確認できます。
スクロールとCSS transitionの不具合
問題1: トラック(曲目)をフィルタで絞り込んでいる状態でページを再読み込みすると、ページのスクロール位置が常にリセットされてしまいます。
問題2: ページ間を移動すると、プレーヤーの波形アニメーションが常にリセットされてしまいます(このプレーヤーは永続的な要素ですが、そのHTMLが保持されています)。
これらは、ページのHTMLを完全に差し替えることで発生する潜在的なUX問題のほんの一例に過ぎません。他にも、入力のフォーカスや入力フィールドのステートが失われる例があります(Turboで自動保存機能を実装する場合)。ユーザー体験を完璧に仕上げたければ、こうした問題を回避するよう頑張るべきです。こうした問題は、インクリメンタルDOM更新、すなわちモーフィングに乗り換えることで回避できるようになります。
TurboとIdiomorphの出会い
モーフィングは目新しい話ではありません。主にPhoenix LiveViewやStimulusReflexなどの他のフルスタックフレームワークでは長年モーフィングが利用されており、非常に効果的な手法であることが実証されています。つまり、Turboでモーフィングを採用する計画が持ち上がっても特に驚く話ではありません。幸い、Turbo 8を待つまでもなく、Turbo 7は十分な柔軟性を備えているので、すぐにでもカスタムレンダリング戦略を実装できます。
Turboで現在進行中のプルリク#1019では、特定のTurbo Streamアクションでトリガーされるページ更新でのみモーフィングを利用します。通常のナビゲーションでは、引き続きページ全差し替えを利用しますが、これは最終リリースで変更される可能性があります。
そのために、まずはモーフィング用のライブラリを選ぶ必要があります。いくつか候補はありますが、最終的にTurbo 8への採用が決まっているという理由でIdiomorphを選びました(Idiomorphは、私が以前使っていたmorphdomなどに比べていくつかのメリットもあります)。
次に、Turboハンドブックにある以下のスニペットを使って、IdiomorphをTurbo Driveに統合します。
document.addEventListener("turbo:before-render", (event) => {
event.detail.render = async (prevEl, newEl) => {
await new Promise((resolve) => setTimeout(() => resolve(), 0));
Idiomorph.morph(prevEl, newEl);
};
});
なお、非同期モーフィングを実行するためにハックを追加する必要がありました。本記事では詳しく説明しませんが、Turboのキャッシュが正常に動作するには、このハックが必要です。詳しくはTurboの#951を参照してください。
上の数行のコードを追加するだけで、前述の問題がすべて修正されるかどうかを確認してみましょう。
単純なモーフィングで修正されたのは水平スクロールの問題だけだった
残念、すべてとはいきませんでした。水平スクロール位置は維持されるようになりましたが、垂直スクロール位置はまだリセットされています。原因はどこにあるのでしょうか?
実は、Turboはナビゲーションイベントが発生するたびにスクロール位置を復元していることがわかりました。このロジックは次のように説明できます。つまり、URLが変更されたときは新しいページを表示していることを前提としているので、振る舞いの種別を「マルチページナビゲーション」にしなければならないということです。この例ではクエリ文字列だけを更新しているので、これは更新中のページと同じであると仮定しても問題はなく、スクロールを戻す必要はありません。この問題は、コールバック関数に以下のコードを追加することで回避できます。
let prevPath = window.location.pathname;
document.addEventListener("turbo:before-render", (event) => {
Turbo.navigator.currentVisit.scrolled = prevPath === window.location.pathname;
prevPath = window.location.pathname;
event.detail.render = async (prevEl, newEl) => {
await new Promise((resolve) => setTimeout(() => resolve(), 0));
Idiomorph.morph(prevEl, newEl);
};
});
成功です!水平スクロールと垂直スクロールの問題がどちらも修正されました。しかしプレーヤーの波形アニメーションがまだ修正されていません。何らかの理由で、ナビゲーションイベントが発生するたびに波形アニメーションがリセットされています。おかしいですね。
CSSを使って、Rubyのputs
的なデバッグを追加してみましょう。DOMツリーに追加された要素をハイライト表示するデフォルトの波形アニメーションをすべてのノードに追加できます。
@keyframes highlight {
0% {
outline: 1px solid red;
}
100% {
outline: 0px solid red;
}
}
* {
animation: highlight 0.2s ease;
}
これで、ページのどの部分が再レンダリングされたか見分けがつくようになります。驚くかもしれませんが、プレーヤーはあらゆるナビゲーションイベントでハイライトされています。
CSSによるハイライトでDOMの変更をデバッグする
この調査にはしばらく時間を要しましたが、最終的に次のことがわかりました。
data-turbo-permanent
要素は、あらゆるレンダリング操作の直前で削除され、新しい要素(存在する場合)にマッチするのではなく、DOMツリーに再び追加される。
つまり、この要素は変わっていないのですが、アンマウントされてから再マウントされるので、CSSアニメーションが再起動されていたのです。
Turbo 8では、レンダリング機能がさらに別のクラスに抽象化されるので、data-turbo-permanent
は置換モードでもモーフィングモードでも同じように動作します。Turboの#1029を参照してください。
data-turbo-permanent
機能を自分たちのカスタムモーフィングベースのレンダリングに移植することで、これを修正できます。互いを認識していない異なる2つのレンダリングエンジンを使うよりも、すべてのロジックを1箇所に置く方がよいので、この方法は非常に理にかなっています。
以下のように、プレーヤーの属性をdata-morph-permanent
に変更し、Idiomorph.morph
呼び出しにbeforeNodeMorphed
コールバックを追加してみましょう。
event.detail.render = async (prevEl, newEl) => {
await new Promise((resolve) => setTimeout(() => resolve(), 0));
Idiomorph.morph(prevEl, newEl, {
callbacks: {
beforeNodeMorphed: (fromEl, toEl) => {
if (typeof fromEl !== "object" || !fromEl.hasAttribute) return true;
if (fromEl.isEqualNode(toEl)) return false;
if (
fromEl.hasAttribute("data-morph-permanent") &&
toEl.hasAttribute("data-morph-permanent")
) {
return false;
}
return true;
},
},
});
});
それでは、どこが変わったかを見てみましょう。
プレーヤーのCSSアニメーションが期待通り動くようになった
プレーヤーはナビゲーション中もDOMツリーにとどまり(つまりハイライトされない)、アニメーションも(ひいてはユーザー体験全体も)スムーズになりました。素晴らしいですね!
モーフィングをTurboの主要なレンダリング戦略に採用する作業はこれでおしまいとお思いの方、残念ながら、まだ作業は他にも残っています。
🔗 モーフィングと他のHotwireコンポーネントの兼ね合い
Turbo DriveはHotwireファミリーの一員に過ぎません。Hotwireでは、Turbo FramesとTurbo Streamsもページを更新するのです。さらに、StimulusコントローラはDOMツリーに接続されているので、HTMLの変更方法に影響されます。これらがモーフィングと衝突せずにうまく動作するのに必要なものを見てみましょう。
🔗 Turbo Framesを扱う
Turbo Framesは一種のサブページと見なせます。このサブページはページとは独立してレンダリングされるので、個別にモーフィングをセットアップしなければなりません。ありがたいことに、対応は以下のように簡単です。
document.addEventListener("turbo:before-frame-render", (event) => {
event.detail.render = (prevEl, newEl) => {
Idiomorph.morph(prevEl, newEl.children, { morphStyle: "innerHTML" });
};
});
このコードでは、before-frame-render
イベントのリスナーを定義してrender
をオーバーライドしています。この手法はbefore-render
でやったことと似ていますが、唯一の違いはデフォルトのouterHTML
ではなくmorphStyle: "innerHTML"
を使っていることです。そうする理由は、フレームが更新されたときにその子だけを更新するようにするためです。
このTurbo Music Driveアプリで使われている唯一のフレームは、artistページにリスナー統計を表示するフレームです。置換をモーフィングに切り替えるとどんな違いがあるかを見てみましょう。
リスナーのカウンタフレームが壊れている
う、リスナーのカウンタが壊れたようです。アニメーションも表示されなければ更新もされていません。何が原因でしょうか?アニメーションを動かしているのはStimulusコントローラなのですが、DOMのインクリメンタル更新が正しく扱われていないようです。この問題をさらに掘り下げてみましょう。
🔗 モーフィングを意識してStimulusコントローラを書く
StimulusコントローラはHTML要素と結びついています。コントローラのconnect()
コールバックにセットアップ用ロジックを、disconnect()
コールバックにティアダウン(終了処理)のロジックをそれぞれ定義するのが定番の手法です。これも、モーフィングに移行した後で動かなくなった理由です。コントローラがアタッチされるDOM要素は変わっておらず、data-animated-number-end-value
属性だけが変更されていました。この問題はどうすれば修正できるでしょうか?
一般に、Stimulusコントローラをモーフィングに対応するには2通りの方法があります。
- 1: モーフィング後にStimulusコントローラを再起動する(
disconnect()
とconnect()
をトリガーしてHTMLの置換をエミュレーションする)方法 - 2: 属性の更新に応答するよう調整する方法
(現時点の)Turbo 8は、1番目のオプションを採用することを決定し、モーフィング後にStimulusコントローラを再起動するコールバック関数を追加しました。もちろん私たちも同様に1番目の方法にしてもよいのですが、モーフィングのメリットがStimulusから失われることになります。Stimulusには既に属性の変更(values
)や依存要素(targets
)をトラッキングするライフサイクルコールバックがあるのですから、これを使ってみましょう!
必要なのは、Stimulusのanimated-number
コントローラを以下のように拡張して、data-animated-number-end-value
属性の変更にコールバックを追加することだけです。
import AnimatedNumber from "stimulus-animated-number";
export default class extends AnimatedNumber {
endValueChanged(_newValue, oldValue) {
this.startValue = oldValue;
this.animate();
}
}
このコードでは、startValue
に直前のendValue
を設定している点にもご注意ください。こうすることでアニメーションが改善され、再生位置が冒頭ではなく最後の再生位置に設定されます。これが、Stimulusコントローラでモーフィングのメリットを活用する方法です。では実際に動かしてみましょう。
リスナーカウンタが動くようになり、今まで以上に良くなった様子
🔗 追伸: Turbo Morph Drive vs. Turbo 8のページリフレッシュ機能
ここで告白しておかなければならないことがあります。私たちのTurbo Morph Driveは、次期Turbo 8のページリフレッシュ(Page Refresh)機能と同じではありません。Turbo 8では、あらゆるページ更新でモーフィングに切り替わるのではなく、現在のページをリフレッシュする場合にのみモーフィングに切り替わるようになる予定です。
しかし、同じアプリケーションに2つの異なる更新モードが同居すると、開発者の混乱の元になる可能性があり、(Stimulusの例でお見せしたように)モーフィングの実力発揮を制限してしまうのではないかと私は思っています。私としては全面的にモーフィングを使うことをおすすめしますが、どうするかは皆さん次第です。
Turbo Streamのrefresh
アクションはどうでしょうか?これについては、Marco Rothによるガイド記事を読めば自分で実装できます。私のバージョンは以下のとおりです。
import { StreamActions } from "@hotwired/turbo";
const sessionID = Math.random().toString(36).slice(4);
StreamActions.refresh = function () {
// カレントユーザーではページリフレッシュをトリガーしたくない。
// (カレントユーザーはこのリクエストに応じて
// 必要なHTML更新を受信することを前提としている)
//
if (this.getAttribute("session-id") !== sessionID) {
window.Turbo.cache.exemptPageFromPreview();
window.Turbo.visit(window.location.href, { action: "replace" });
}
};
document.addEventListener("turbo:before-fetch-request", (event) => {
event.detail.fetchOptions.headers["X-Turbo-Session-ID"] = sessionID;
});
Rails側では以下のように、現在のTurboセッションIDをCurrent
オブジェクトに保存して、refresh
ストリームをブロードキャストするときにこのセッションIDを利用できます。
# application_controller.rb
class ApplicationController < ActionController::Base
around_action :set_turbo_session_id
private
def set_turbo_session_id(&block)
Current.set(turbo_session_id: request.headers["X-Turbo-Session-ID"], &block)
end
end
# application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
class << self
def broadcasts_refreshes
after_commit do
Turbo::StreamsChannel.broadcast_stream_to(
self,
content: Turbo::StreamsChannel.turbo_stream_action_tag(
:refresh,
:"session-id" => Current.turbo_session_id
)
)
end
end
end
end
# 何らかのモデル
class Artist < ApplicationRecord
# ...
broadcasts_refreshes
end
完全な実装については、Turbo Music Driveアプリのコミット09a1730をご覧ください。
私たちのTurbo Music Driveアプリの実装はほぼ完成しました。Turbo Streamsの更新にモーフィングを追加してもよいのですが、親愛なる読者の皆さんへの宿題として残しておきます。
次回は、私たちのTurbo Music Driveアプリを拡張して流麗なページ遷移を追加する予定です。どうぞお楽しみに!
シリーズ記事1と2のソースコードは、以下のGitHubリポジトリでご覧いただけます。
Evil Martiansは、成長段階のスタートアップ企業をユニコーン企業に飛躍させるためにサポートいたします。開発ツールの構築やオープンソース製品の開発も行っています。ワープの準備が整ったお客様、ぜひフォームまでご相談をお寄せください!
関連記事
- 訳注: これは最近#971でTurboのコードからTypeScriptが削除されたことを指しています。 ↩
- 訳注: ビュートランジション 🚀 Astroドキュメント ↩
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。