ちょっと複雑なサイドバーをHotwireで簡単に作りたい

こんにちは、レシピ事業部プロダクト開発グループの渡邉(@taso0096)です。 クックパッドは最近、One Experienceというプロジェクトによって日本版とグローバル版のシステムが統合されました。 どちらのシステムもRailsで実装されているという点は同じですが、統合先となったグローバル版ではHotwireが使われていました*1。そのため、One Experience関連の開発ではHotwireが積極的に活用されています。本記事ではそんなHotwireの多くの機能が使われたデスクトップ版のサイドバーについてご紹介します。

デスクトップ版で表示されるサイドバー

ちょっと複雑なサイドバー

One Experienceに伴い、グローバル版にもともと存在したUIのまま移行するのではなく、いくつか画面構成の変更を入れる事になりました。特にデスクトップ版においては、自分のコンテンツにより素早くアクセスできるようにするためにサイドバーの導入が決まりました。

このサイドバーでは、一般的なナビゲーションメニューのほかに「きろく」と呼ばれるコンテンツを表示しています。「きろく」とはユーザの保存レシピや投稿レシピなどを整理するための機能です。また、「きろく」に保存したレシピはユーザーさんの手で、フォルダを作成して分類できます。このとき、レシピやフォルダの数が多いユーザーさんでも充分素早く表示させたいです。更に、サイドバーの外でレシピが保存されたとき、リロードせずともサイドバーの中の表示も追従する必要があります。以上を整理すると、作りたいものは以下のようなものということになります。

  • コンテンツが多く読み込みに時間がかかる場合を考慮して非同期で読み込む
  • フォルダが多い場合でも全てのフォルダを読み込める
  • レシピの保存やフォルダの作成などの操作時に表示を同期する

これらの要件はHotwireを使えば簡単に実装することができます。ここではHotwireの各機能を軽く説明しつつ、それぞれどういった実装をしたのかご紹介します。

Turbo Frames

Turbo Framesとはページ全体をリロードせずに部分的な更新を可能にするための機能です。部分更新したい箇所をTurbo Framesのタグで囲うことで、そのタグ内の部分更新が行われます。また、iframeのようにURLを指定することで全く別のページを埋め込むことも可能です。この場合はタグの中身が最初にレンダリングされ、その後に非同期で別のページが読み込まれます。

今回実装したサイドバーでは、非同期でコンテンツを読み込むためにTurbo Framesを利用しました。そのためにTurbo Framesで表示される専用ページを新規作成し、図の赤枠内で読み込むようにしました。この赤枠の部分ではローディングが最初に表示され、ページの読み込み後に非同期でコンテンツが読み込まれます。

Turbo Framesの範囲

多くの場合、Rails側で実装してある既存のページのロジックなどをそのまま流用して簡単に埋め込めるというのがTurbo Framesのメリットかと思います。一方、この例では新規ページをわざわざ作成しました。これはフォルダのページネーション実装をシンプルにするためで、Stimulus で実装しています。詳細は後ほど解説します。

なお、Turbo Framesはlazy loadingも可能であり、今回も設定しています。サイドバーはデスクトップ版ではスクロール位置などに関係なく常に表示されるものですが、スマホ版だとそうではないためです。

ページ遷移に伴うリセット

Turbo Framesによって非同期でのコンテンツの取得が実現できました。しかし、このままではページ遷移するたびにコンテンツの再取得が行われてしまいます。場合によってはそれでも問題ないですが、今回はページネーションによって読み込まれたフォルダの情報がリセットされることを避けたいと考えました。そうでなくともページ遷移する度にサイドバーがローディングによって一瞬使えなくなることはかなり不便だと思います。

そこでdata-turbo-permanent属性というものを使用しました*2。以下のようにこの属性が付与されたDOMはページ遷移時にもDOMが維持されます。

<div data-turbo-permanent>sidebar</div>

これにより一度読み込んだTurbo Framesのコンテンツは通常の画面遷移ではリセットされなくなります。これを再度読み込むにはブラウザのリロードやJavaScriptによる再読み込みの処理が必要になります。

レスポンス

Turbo Framesが取得するHTMLはページに埋め込まれる部分だけではありません。layoutテンプレートこそ使用されませんがActionViewでレンダリングされたページ全体がレスポンスとして返されます。そのため、ActionViewの中の一要素のみを埋め込みたいといった場合はそれ以外のレスポンスは破棄されてしまいます。おおよそのレスポンスは以下のようになっており、ブラウザ側のHotwireランタイムで解釈されて画面に埋め込まれます。

<html>
  <head></head>
  <body>
    <div>破棄される要素</div>
    <turbo-frame id="dom_id">埋め込みたい要素</turbo-frame>
  </body>
</html>

多少の無駄は生じてしまいますが、上でも書いたように既存のページをほぼそのまま流用可能であるというメリットの方が大きいと考えます。なお、今回は新規ページを作成しActionView全体が埋め込まれる形にしたため、そもそも破棄される要素は存在しません。

Turbo Streams

Turbo Streamsとはリアルタイムでのデータ更新を簡単にするための機能です。ページに対してDOMの追加・変更・削除などが可能であり、複数箇所を同時に更新することもできます。今回はレシピを新規で保存した際のレシピ数の更新や、フォルダ自体の編集を反映するために使用しました。

レスポンス

Turbo Framesと違い、Turbo Streamsではページ全体ではなく、差分のみをサーバーからレスポンスします。DOMをどのように扱うかについてはTurbo Streamsのアクションによって指示されます。例えばユーザが新規でレシピをフォルダに追加した場合を考えます。この場合は画面の赤枠部分が全て更新されます。

Turbo Streamsによって更新される要素

この時に返されるレスポンスはおおよそ以下のようになります。

<turbo-stream action="replace" target="dom_id">保存ボタンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">ドロップダウンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「すべて」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「保存済み」のHTML</turbo-stream>
<turbo-stream action="prepend" target="dom_id">「きろく」の「新規フォルダ」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">通知のHTML</turbo-stream>

このレスポンスをHotwireランタイムが解釈して画面の更新が行われます。

リダイレクト

サイドバーの同期を実装するにあたって、これまでは画面の更新が不要だったいくつかの既存のリクエストのformatをHTMLからTurbo Streamsに置き換える必要がありました。基本的に問題なく置き換えることが可能でしたが、リダイレクトが必要な場合は少し工夫する必要がありました。

例えばユーザがフォルダのページからそのフォルダを削除した場合を考えます。この場合はサイドバーの「きろく」から対象のフォルダのDOMを削除した上で「きろく」のトップにリダイレクトするという仕様になっています。これはデフォルトのアクションでは対応できないため、以下のようなリダイレクトのためのカスタムアクションを追加することで対応しました。

Turbo.StreamActions.redirect = function () {
  Turbo.visit(this.target)
}

View側では通常のアクションとほとんど同じように呼び出すことが可能です。

<%= turbo_stream.action :redirect, path %>

実際のレスポンスは以下のようになり、Hotwireランタイムで解釈されてリダイレクトが実行されます。

<turbo-stream action="redirect" target="path"></turbo-stream>

カスタムアクションは任意のJavaScriptを簡単に実行できるためかなり便利な機能です。しかし、だからと言ってデフォルトアクションで対処可能な機能に対してカスタムアクションを作成してしまうとコードの一貫性が失われてしまうため注意が必要です。

Stimulus

StimulusとはHTMLとJavaScriptを適切に切り離して書くための枠組みです。これによってHTMLだけを見た時にどんな挙動をするのかわかりやすくしたり、コード自体の再利用性を高めるメリットがあります。CSSがHTMLのclass属性を介して紐づいているように、StimulusではHTMLのデータ属性を介して任意のJavaScriptによる操作を可能にします。このJavaScriptはcontrollerという単位で分けられており、controllerのメソッドをDOMのライフサイクルやイベントをフックに実行するというのが主な機能です。これによって再利用しやすいJavaScriptになるような仕組みになっています。今回はページネーションのための無限スクロールとアクティブ状態の更新のために使われました。

無限スクロール

Turbo Framesのセクションでフォルダのページネーションについて解説しましたが、より使いやすくするために無限スクロールに対応します。グローバル版には元々無限スクロール用のcontrollerが実装されていたためこれをそのまま使用しました。実装としてはシンプルで、スクロールイベントを監視し閾値を超えたら次のページを非同期通信で読み込むというものです。Stimulusのtargetsとvaluesの機能を使ってリストの中身はどのDOMなのか、次のページのURLは何なのかといった情報を管理しています。

なお、Hotwireにおける無限スクロールの実装としてTurbo Framesの遅延読み込みを活用したものもあります。この場合は自分ではJavaScriptを一切書かずに無限スクロールの実装が可能です。ただし、単純なページネーションにcontrollerを登録だけすれば済むStimulusと比較すると、Viewに少し手を入れる必要がある点には注意が必要です。今回は便利に使える既存実装があったのでTurbo Framesによる無限スクロールをあえて採用するようなことはしませんでした。

アクティブ状態の更新

Turbo Framesのセクションで説明したように、サイドバーは画面遷移によるリセットを回避するためにdata-turbo-permanent属性が指定されています。しかし、これによって現在のページに応じて「きろく」のリンクのアクティブ状態を更新する機能が壊れてしまいました。これに対処するにはJavaScriptによってページ遷移を検出してアクティブ状態を更新する必要があります。作成したcontrollerの中身自体は単純なものですが、メソッドを呼び出すためのイベントの指定だけ少し特殊になっています。Stimulusではdata-action属性を使ってどのイベントにフックしてメソッドを実行するか指定することができます。このとき、基本的には属性が指定されたDOMに対するイベントを参照しますが、ページ遷移のようなグローバルのイベントフックしたい場合は@documentのようなsuffixを指定することで対応できます*3

<div data-action="turbo:visit@document->controller#method"></div>

まとめ

Hotwireを活用したちょっと複雑なサイドバーの実装についてご紹介しました。Hotwireの仕組みを利用することでインタラクティブなUIのためのJavaScriptをほとんど書かずに主要な機能の実装ができたかと思います。個人的には元々はNext.jsを書いていたこともありJavaScriptは好きですが、Railsを書く上でHotwireはかなり良くできた仕組みだと感じています。Hotwireはまだ使い始めたばかりの技術ですので、新しい知見が溜まったらまた共有したいと思います。