HotwireはRailsを「ゼロJavaScript」でリアクティブにできるか?(翻訳)
サマリー
The HEY stack:
- Vanilla Ruby on Rails on the backend, running on edge
- Stimulus, Turbolinks, Trix + NEW MAGIC on the front end
- MySQL for DB (Vitess for sharding)
- Redis for short-lived data + caching
- ElasticSearch for indexing
- AWS/K8S— DHH (@dhh) June 24, 2020
DHHたちによる新しいマジックを投入して、長らく待ち望んでいたHotwireを5分間チュートリアルよりも高いレベルで使うときがやってまいりました。Hotwireは、JavaScriptなしでモダンなWebインターフェイスを楽に構築できるライブラリ群の総称であり、今年大々的に発表されて以来大きな話題となっています。Hotwireの「HTML over-the-wire」アプローチはRailsの世界に波紋を広げていて、私が今年のRailsConfでお話ししたことも含め、これまでブログ記事やreddit書き込みやスクリーンキャストが山ほど公開されています。本記事ではコード例やテスト戦略を交えながら、Hotwireを徹底的に解説したいと思います。私の好きな某ロックバンドのアルバムタイトル「Hardwire: to self-destruct」をもじれば、さしずめ「新しい技を学ぶためにHotwireしよう(Hotwired: to learn a new tricks.)」となるでしょう。
忙しい人へ
Rails 6でHotwireする方法を手っ取り早く知りたい方は、以下のプルリクをご自由にお調べください(お知らせ不要です)。
本記事では、上のコードを詳しく解説します。本記事は、私がRailsConf 2021でお披露目した『"Frontendless Rails frontend"』を元に加筆したものです。既にRailsConfの全登壇者のスライドと動画がネットで公開されています。チケットをお持ちでない方もご覧いただけますのでご心配なく(訳注: その後スライドと動画が公開されたので以下に貼りました)。
Hotwireならこうやれる
ここ5年間の私は、ほぼバックエンド開発のみを手掛けてきました。「REST」「GraphQL API」「WebSocket」「gRPC」「データベース」「キャッシュ」など、ブラウザ画面の向こうにあるものすべてです。
フロントエンドの進化の波がことごとくビッグウェーブのように通り過ぎていきましたが、未だにWebアプリにReactだのwebpackだのを詰め込む理由が腑に落ちていません。伝統あるHTMLファーストのRails way(つまりハイウェイ😉)こそがこれまでの私のやり方でした。かつて、アプリケーションでJavaScriptを動かすためにそれ専用のMVCなりMVVMを使わなくてもよかった時代があったことを覚えていますか?懐かしいですね。その時代が、静かに復活しつつあるのです。
今や私たちは、「HTML-over-the-wire」(ちゃんとした実際の用語です)が台頭しつつあるところを目の当たりにしています。「バックエンドでレンダリングしたテンプレートをWebSocket経由ですべてのクライアントにプッシュする」アプローチはPhoenix LiveViewから始まり、StimulusReflexにある一連のgemのおかげでRailsコミュニティで支持を広げました。そして今年初めにDHHがHotwireを世に送り出したことで、ついにDHHみずから「HTML-over-the-wire」を祝福してくれました。
果たしてWeb開発は巨大なパラダイムシフトを迎えようとしているのでしょうか?「サーバーでレンダリングしたテンプレート」というシンプルなメンタルモデルに立ち返って、今度はあらゆるリアクティブなインターフェイスをきわめて手軽に扱えるようになるのでしょうか?そう願いたいところですが、希望的観測であることも承知しています。ビッグ・テックはクライアントでレンダリングするアプリケーションにさんざん投資して引き返せなくなっています。2020年代のフロントエンド開発は、求められる資格も業界も参入条件も別物です。今さら「フルスタック」に戻ることはありえません。
しかしHOTWire(HTML-over-the-wireをこんなふうに略したBasecampはなかなかですね)は、複雑化した、いやむしろ「こじれにこじれた」今どきのブラウザ向けクライアントサイドプログラミングのロケットサイエンスに代わる手段を提供してくれるのです。
Hotwireは、Web表示を制御できないAPIのみのアプリに疲れてしまったRails開発者や、週に40時間もSQLやJSONを処理することから逃れてユーザーエクスペリエンスを作りたいと思っているRails開発者が長らく待ち望んでいた、Web開発を再び楽しくする新しい風なのです。
本記事では、Hotwireを用いて既存のRailsアプリケーションに「HTML-over-the-wire」哲学を適用する方法を紹介したいと思います。私の最近の記事と同様、自作のAnyCableデモアプリケーションを実験材料に使うことにします。
このアプリはインタラクティブかつリアクティブで、Turbolinksと若干のカスタム(Java)Scriptで駆動され、直近のシステムテストのカバレッジも十分確保できている(つまり安全にリファクタリングできる)点がデモにうってつけです。このアプリの「Hotwire化」は、以下の簡単な4つのステップを踏むだけで完了します。
- 前編: TurbolinksをTurbo Driveに置き換える
- 前編: Turbo Framesでフレーム化する
- 後編: Turbo Streamsでストリーミングする
- 後編: Turboの向こう側へ: Stimulusとカスタム要素を使う
🔗 TurbolinksをTurbo Driveに置き換える
Turbolinksは古くからRails世界で知れ渡っていて、最初のメジャーリリースは2013年でした。しかし当時のRails開発者の間では「フロントエンドがおかしくなったらとりあえずTurbolinksを切ってみる」という経験則が広まっていました。Turbolinkのフェイクナビゲーション(pushState + AJAX) とサードパーティのJSコードの互換性を保つのは並大抵ではなかったのです。
StimulusJSが登場したとき、私はついにTurbolinksをオフにするのをやめました。StimulusJSはモダンなDOMミューテーションAPIに依存したことで、「JavaScriptスプリンクル1」との接続や切断の問題を劇的に解決したのです。コード編成やDOM操作のためにStimulusをTurbolinksと組み合わせることで、ReactやAngularの開発コストの数分の一のコストで「SPA」エクスペリエンスを楽に構築できるようになりました。
その古き良きTurbolinksが「Turbo Drive」と名を改めました。Turbo Driveは文字どおりTurboを「ドライブ」するものであり、Hotwireパッケージの中核を占めています。
(私のアプリがそうだったように)Turbolinksを既にお使いであれば、名前を変更するだけで簡単にTurbo Driveへの乗り換えられます。
package.jsonファイルでturbolinks
を@hotwired/turbo-rails
に置き換え、Gemfile内のturbolinks
をturbo-rails
に置き換えるだけで、作業は完了します。
初期化コードも少し変わり、以下のように1行で書きます。
- import Turbolinks from 'turbolinks';
- Turbolinks.start();
+ import "@hotwired/turbo"
Turbo Driveは手動で起動する必要がありません(止めることもできませんが)。
HTMLのdata-
属性data-turbolinks
をすべてdata-turbo
に検索置換しておく必要もあります。
唯一、変更方法を見つけるのに時間がかかったのは「フォーム」と「リダイレクト」の扱いでした。Turbolinksのときはリモートフォーム(remote: true
)とリダイレクト用concernを用いてJavaScriptテンプレートに応答できました。Turbo Driveにはフォームを「ハイジャックする」機能が組み込まれているので、もうremote: true
は不要です。しかしリダイレクトのコード(正確にはリダイレクションステータスのコード)については更新が必要であることがわかりました。
- redirect_to workspace
+ redirect_to workspace, status: :see_other
HTTPレスポンスコードに303 See Otherを使うのが賢い選択です。こうすることでTurboがネイティブのフェッチAPIのredirect: "follow"
オプションに依存できるようになるので、フォーム送信後に新しいコンテンツをフェッチするためのリクエストを別途開始する必要がなくなります。HTTP-redirect fetchの仕様によると、「ステータスが303で、かつリクエストメソッドがGET
でもHEAD
でもない場合」は、自動でGET
リクエストが実行されなければなりません。「ステータスが301または302で、かつリクエストメソッドがPOST
の場合」との違いがおわかりでしょうか?
その他の3xxステータスはPOST
リクエストのみに対応していますが、Railsで普段使われるのはPOST
、PATCH
、PUT
、DELETE
です。
🔗 Turbo Framesでフレーム化する
次は本当の意味で新しそうな機能、Turbo Framesです。
Turbo Framesによって、ページの一部分をシームレスに更新できるようになります(Turbo Driveのようにページ全体を更新するのではありません)。この振る舞いは<iframe>
と非常に似ていますが、別ウィンドウを開いたりDOMツリーを作成することもなく、それらに伴うセキュリティ上の悪夢もありません。
それでは利用例を見てみることにしましょう。
AnyCableのデモアプリケーション(名前はAnyWork)は、複数のToDoリストとチャットを備えたダッシュボードを作成できます。ユーザーは、異なるToDoリスト上にある項目に対して追加や削除を行うことも、完了マークを付けることもできます。
Turbo Framesの利用例: 各項目が独自のフレーム内に置かれている
項目の完了と削除は、元々AJAXリクエストとカスタムStimulusコントローラで作り込んでいましたが、この機能をTurbo Framesで書き換えてHTMLオールインワンにすることを決意しました。
ToDoリストを分解して、項目ごとの更新を扱えるようにするにはどうすればよいでしょうか。以下のように個別の項目をフレームに変えてみましょう。
<!-- _item.html.rb -->
<%= turbo_frame_tag dom_id(item) do %>
<div class="any-list--item<%= item.completed? ? " checked" : ""%>">
<%= form_for item do |f| %>
<!-- ... -->
<% end %>
<%= button_to item_path(item), method: :delete %>
<!-- ... -->
<% end %>
</div>
<% end %>
重要な点は以下の3つです。
- ヘルパーを用いて項目のコンテナを
<turbo-frame>
タグでラップし、一意のidを渡したこと(Action Viewの便利なdom_id
メソッドをチェックしてください) - フォーム送信とフレーム内容の更新をTurboがインターセプトするためにHTMLフォームを追加したこと
method: :delete
オプション付きのbutton_to
ヘルパーを追加したこと(HTMLフォームも背後で作成されます)。
これで、フレーム内でフォームが送信されるとTurboが送信にインターセプトしてAJAXリクエストを代行し、同じidを持つフレームをそのレスポンスHTMLから抽出してフレームの内容を置き換えるようになります。
ここまでJavaScriptを一行たりとも手書きせずに動きます。
次は更新されたコントローラのコードを見てみましょう。
class ItemsController < ApplicationController
def update
item.update!(item_params)
render partial: "item", locals: { item }
end
def destroy
item.destroy!
render partial: "item", locals: { item }
end
end
項目を削除するときは、同じパーシャルからレスポンスを返す点にご注意ください。しかし削除の場合は、項目のHTMLノードを更新するのではなく削除する必要があります。どうすればよいでしょうか。空のフレームを用いてレスポンスを返せばよいのです。以下のようにパーシャルを更新してみましょう。
<!-- _item.html.rb -->
<%= turbo_frame_tag dom_id(item) do %>
<% unless item.destroyed? %>
<div class="any-list--item<%= item.completed? ? " checked" : ""%>">
<!-- ... -->
</div>
<% end %>
<% end %>
ここで「項目に完了マークが付けられたらどうやってフォームを送信すればよいのか?」という疑問が持ち上がるかもしれません。言い換えれば、チェックボックスの状態が変わったときにフォーム送信をトリガーする方法があるかということです。これは以下のように「インライン」イベントリスナーを定義することで行なえます。
<%= f.check_box :completed, onchange: "this.form.requestSubmit();" %>
原注
submit()
ではなくrequestSubmit()
を使うことが重要です。requestSubmit()
がトリガーする送信イベントはTurboからインターセプトできますが、submit()
はそうではありません。
ただし、requestSubmit()
はまだモダンなブラウザの一部でサポートされていません(caniuse.com)。polyfillの利用をご検討ください。
要するに、HTMLテンプレートをちょっぴり変更して、コントローラをシンプルなコードに書き換えるという手間をかければ、従来の機能はそのままにカスタムJavaScriptを完全に消し去れるということです。ワクワクしてきますね。
さらに一歩進めて、ToDoリストもフレームに変換できます。こうすることで、項目を追加したときにTurbo Driveページの更新から特定のノード更新に切り替わるようになります。ぜひご自宅でお試しください。
また、項目が完了または削除されたときに、ユーザーにフラッシュで「正常に削除されました」のような通知を表示したいとします。これをTurbo Frameで実現できるでしょうか。フレームでフラッシュメッセージのコンテナをラップして、更新されたHTMLを項目のマークアップと一緒にプッシュする必要がありそうです。これは当初の思いつきでしたが、うまくいきませんでした。フレーム更新のスコープはイニシエータフレームに限定されるので、フレーム外にあるものを更新できません。
あるフレームの中からページ全体の更新や別のフレームの更新をトリガーすることも一応可能ですが(Hotwireドキュメントを参照)、独立した2つのフレームを更新する方法は今のところありません。
少し調べてみると、実はTurbo Streamsを使えばできることがわかりました。
(後編に続く)
概要
原著者の許諾を得て翻訳・公開いたします。
長文につき前編と後編に分割しました。