Top
beta

Commentary

Tabbed Menus

タブメニュー

タブメニューはTurbo Framesで作ることが多い #

タブメニューはTurbo Framesで作ることが多いです。Turbo Frames入門としては最適なUI要素です。

まずタブメニューの作る方に入る前に、Turbo Framesについて概略を説明します。

Turboの序列 #

私の経験では、Turbo Drive, Turbo Frames, Turbo Streamsのうち、Turbo Driveはデフォルトで全てのページに適応されるので一番使います。ついで部分置換が必要な時は、Turbo Framesを使います。Turbo Framesでは難しい時に初めてTurbo Streamsを使います。ただしmorphingが導入されてからはTurbo Driveだけでできるものが非常に増えましたので、Turbo Streamsを使う頻度はさらに下がりそうです。

つまり、少なくとも私の通常の使い方であれば、Turbo Framesを習得すればTurboのほぼ全てが習得できます。

Turboの各技術
技術用途注記使用頻度
(著者経験)
Turbo Drive全画面置換キャッシュ> 90%
Turbo Frames画面の部分的置換関連機能が豊富~ 15%
Turbo Streams画面の部分的置換複数箇所を同時置換可能< 5%
Morphing差分的更新DriveやFramesと組み合わせて
変化したところだけ更新
~ 10%
※)頻度はあくまでも著者の経験であり、条件によって変わります。モーダル等を頻繁に使う場合や、少数の画面のUXに徹底的に注力する場合は異なります。また私はコードの単純化を優先してTurbo Framesを多く使いますが、逆にTurbo Streamsによる細かい制御を好む人もにいます。

Turbo Framesは部分的置換のパッケージ #

Turbo Driveがページ遷移、つまり画面全体を置換するのに対して、Turbo Framesはサーバから送られてきたデータを使って画面の部分置換をする時に使います。

「モーダル」「ポップアップ」「ドロップダウンメニュー」「ドロワーメニュー(引き出し)」「ライブ検索」、住所を入力するときに使う「階層メニュー」などは、どれもTurbo Framesで実装できます。

インタラクティブと言われるUI要素も、実は大部分はシンプルな部分的置換で実現できるためです。あとはブラウザの中でJavaScriptを使い、動きに変化をつけていくだけです。

Turbo Framesは部分置換だけではなく、これらのUI要素を作る時の周辺機能も提供してくれます。aタグやformタグとの連携Lazy loading (遅延ロード)prefetchURL同期ローダー表示用のCSSなど、それに伴う便利機能もパッケージとして提供しています。

一方でReactなどの場合はuseStateフック条件付きレンダーなどのパターンを提供してくれますが、これを組み合わせてUI要素を作るのは開発者しだいです。その意味でReactはフルスクラッチでUI要素を作成するのに適している一方、HotwireはUIライブラリとまではいかないものの、パッケージしたものを提供していると言えます。

古典的なMPAによるタブメニューの作り方 (Turbo開発の出発点) #

MPA流のタブメニューはここでお試しいただけます。コードはGitHubでご確認いただけます。

早くTurbo Framesを使った実装を紹介したいところですが、その前にやっぱり 基本を振り返るおくことが重要だ と思います。基本とは、古典的なMPAを使った場合のタブメニューの実装方法です。

実は古典的なMPAでも、ほぼ十分なUIが実現できます。実際のデモを体験してください。

またTurbo Framesの開発の流れは、まず最初にMPAを作ることから始めることが大半です。Turboで開発する時はまずMPAから始めるべきだと覚えても90%間違いありません

タブメニュー実装のタネ明かしは、タブより上の箇所が全く同じ2つの画面( users側products側 )を用意しているだけです。タブの上の方は全く同じなので、置換されていることに気づきません。一方でタブの下の部分は異なる内容が表示されているので、ここだけが置換されたとユーザは錯覚します。

例えば食べログのサイトでも、このようなMPA流のタブメニューが今でも現役です。別の問題としてページの読み込みが非常に遅いのが気になりますが、UX的には十分に優れたものになります。

食べログのサイトのタブメニュー
tabelog tabs
ファイル・フォルダ構成は下記のようになります
Tabs No JS

Turbo Driveによるタブメニューの作り方 #

Turbo Driveによるタブメニューはここでお試しいただけます。コードはGitHubでご確認いただけます。

Turbo Driveを使う場合は、MPAのサイトにTurboのJavaScriptファイルをダウンロードするして、<head>の中で読み込むだけです。 この場合はTurbo Driveによってヌルサクになった分だけ、タブの切り替えが自然に見えてきます。しかし実際にやっているのはMPAの場合と同様、 画面全体の置換です

一見するとタブだけが差し代わっているようには見えますが、下記の点を細かくみると、実際には画面全体の置換だとバレてしまいます。

  • 少し下にスクロールした後にタブをクリックすると、タブの中身が置換されるだけではなく、トップにスクロールしてしまうことがわかります。なおこの動きは画面全体を置換するから起こるのではなく、Turbo DriveがMPAのページ遷移の動きを真似るためにわざとやっているものです。
  • Search のテキスト入力フィールドに文字を入力し、その後にタブを切り替えると、テキスト入力フィールドの文字は消えてしまいます。これは画面全体を置換する時にこのフィールドも丸ごと置換されるためです。
  • なお、今回はTurbo Driveで画面全体が置換されると説明していますが、実はMorphingを使うと、置換するのではなく差分だけを更新することも可能です。Morphingについては後ほどまとめて紹介したいと思いますが、Reactに近い感じの更新を可能にするもので、かなり強力なものです。
Turbo Driveによるタブメニュー
turbodrive image

Turbo Framesによるタブメニューの作り方 #

Turbo Framesによるタブメニューはここでお試しいただけます。コードはGitHubでご確認いただけます。

まず大切なことは、Turbo Driveを使った場合と比較したUIの違いです。一見するとTurbo Driveの場合とあまり差がありませんが、以下の点が異なります。

  • 少し下にスクロールした後にタブをクリックしても、トップにスクロールしません。デフォルトではスクロール位置が維持されます。より細かく制御したい場合は、autoscroll属性で調整できます
  • Searchのテキスト入力フィールドに文字を入力し、その後にタブを切り替えても、テキスト入力フィールドの文字はそのまま維持されます。フォーカルも維持されます。今回設定したTurbo Framesでは、Searchのテキスト入力フィールドはTurbo Framesの外にあります(下図)。タブが切り替わっても、SearchのDOM要素はそのままなのです。だから文字およびフォーカスが維持されています。

このようにTurbo Framesの特徴は画面を枠で分割し、枠内を置換しつつ、枠外をそのままに維持するところです。

Turbo Frames実装方法 #

Turbo Framesによるタブメニューの作り方はごく簡単です。まずはTurbo Driveのバージョンから出発します。そして、どこをTurbo Framesで囲むかを決めます。今回はSearchのテキスト入力フィールドの下のところからテーブルの最後までを囲むことにします。

Turbo Framesによるタブメニュー
turbo frames image

次にエディタで該当するEJSファイルの内容を確認し、囲みたいところを <turbo-frame id="[適当な名前]"></turbo-frame>のタグで囲みます。今回は2つのページ (UsersProducts)がありますので、双方のEJSファイルで同じ処理をします。結果はtemplates/tabbed_segments_turboframes/index.ejsおよびtemplates/tabbed_segments_turboframes/products.ejsにあります。

Turbo Framesを実現しているのは下記の部分です。

tabbed_segments_turboframes/index.ejs

<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
  ...
</turbo-frame>

tabbed_segments_turboframes/products.ejs

<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
  ...
</turbo-frame>

はい、以上でおしまいです!

  1. templates/tabbed_segments_turboframes/index.ejs<turbo-frame>タグを加える
  2. templates/tabbed_segments_turboframes/products.ejsに同じIDの``タグを加える
  3. 必要に応じてdata-turbo-actionでURLと連動させたり、ロード時に自動的に追加されるbusy属性を利用してCSSでローダーを表示したり、autoscroll属性でスクロールの動作を変えるなど、機能を追加します。

この3つのステップだけで、Turbo Frames的なタブメニューができ上がりました!

解説 #

<turbo-frame></turbo-frame>でくくることによって、Turbo Frame中に含まれるaタグやformタグは通常と違う性質を持つようになります。通常であればTurbo Driveのような 全画面 遷移をするのですが、 全画面 ではなくて、同じTurbo Frame内に限定された 部分画面 遷移をするように変化します。あたかもブラウザウィンドウの中に、もう1つ小さなブラウザウィンドウができたような感じです。

今回はTurbo Frameの中にタブがくるように配置しましたので、Users, ProductsのタブはTurbo Frame内を置換しながら切り替わっています。Turbo Frame内のみが変化するので、Searchのテキストフィールドもリセットされないわけです。

タブをクリックすると、通常のaタグと同じようにHTTPリクエストは飛びます。そしてHTMLがサーバから返ってきます。ここからがTurbo Framesの動きです。通常なら画面全体を置換するのですが、Turbo Framesの場合は新しいページの中にある<turbo-frame></turbo-frame>を探し出し、元のページの中にある<turbo-frame></turbo-frame>の中身と置換するのです。この時、id属性をみてturbo-frameのペアを認識するので、idの値を揃えておく必要があります。

今回、Turbo Framesは通常のaタグで起動しています。aタグなので、Turbo Driveの機能であるprefetchも働きます。このためprefetchによるUXの大幅向上、ヌルサクな体感も、何もしなくても勝手についてきます。

余計なHTMLを送ることは悪いことじゃない #

Turboの大きな特徴は、余計なHTMLをサーバから送ることを気にしないことです。タブを切り替えるだけならタブの中身だけをサーバから送信すれば良いのですが、今回のケースは常にページ全体を送っています。これは古典的なMPAによるタブメニューと完全に同じです。

実際問題、余計な箇所をサーバで際レンダリングしても大きな負荷にならないことがほとんどです。データベースに負荷のかかる処理は、通常はタブの中身になりますし、また余計なデータをネットワーク越しに送信しても、その負荷は一般に微々たるものです。そこの最適化をやるよりは、シンプルで行きましょうというのがTurboの考え方の一つになります。

コードの冗長さを嫌うのであれば、Rails ERB, Laravel Blade, Node EJSなどのHTMLテンプレートシステムに用意されている partial の機能などを使うことでDRYにできます。またサーバへの負担を心配するのであれば、サーバ側でキャッシュする機能などを使うこともできます。負荷があったとしてもサーバ側で十分に解決できますので、ページ全体を送ることは問題になりません。

Turbo Frameリクエストの場合は、HTTPヘッダーTurbo-Frameが送られてくるので、これを見て余計なところを送信しないという選択肢もあります(turbo-rails gemを使えば、コントローラに#turbo_frame_request?メソッドが用意されているので、これは容易にできます)。しかし上述のように、最適化の効果は微々たるもので、特に気にしないことが多いです。

ローダーの表示 #

また、タブのロードに時間がかかってしまう時のために、ローダーの表示もさせています。Turbo Framesはサーバに問い合わせするときにbusy属性がつくのを利用します。

input.css

    .turbo-with-loader[busy] .turbo-hide-on-loading {
        visibility: hidden !important;
    }

    .turbo-with-loader {
        position: relative;
    }

    .turbo-with-loader[busy] .turbo-hide-on-loading::before {
        content: '';
        visibility: visible !important;
        display: block;
        background-image: url('../../images/rocket.gif');
        width: 64px;
        height: 64px;
        margin: 48px auto;
    }

tabbed_segments_turboframes/index.ejs

<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
  ...
  <div class="my-10 px-4 sm:px-6 lg:px-8 turbo-hide-on-loading" >
    <!-- [ここがロード中に隠れ、代わりにローダーが表示される] -->
  </div>
  ...
</turbo-frame>

tabbed_segments_turboframes/products.ejs

<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
  ...
  <div class="my-10 px-4 sm:px-6 lg:px-8 turbo-hide-on-loading" >
    <!-- [ここがロード中に隠れ、代わりにローダーが表示される] -->
  </div>
  ...
</turbo-frame>
ファイル・フォルダ構成は下記のようになります
Tabs Turbo Frames

一般的なReactとの比較 #

Reactによるタブメニューはここでお試しいただけます。コードはGitHubでご確認ください。

Reactのコードの特徴は以下の通りです。一般的なReactのデータフェッチのパターンを複数回使うだけで、ロジックは追いやすいと思います。

  • 選択されたタブをステートとして持つ (useStateを使用)
  • 条件付きレンダーのパターンを使って、ステートのよってタブの中に表示するコンポーネントを切り替える(今回はここにあるUsers.tsxProducts.tsx)
  • データはUsersProductsコンポーネントの中のuseEffectの中のfetchで行う

コンポーネントの切り替えはブラウザの中だけで行うので瞬間的ですが、データフェッチに時間がかかり、その間はローディング画面を表示します。そしてコンポーネントがDOMにロードされるまでuseEffectは動きませんので、prefetchは効きません。

Next.js App Router Parallel Routesとの比較 #

Next.js Pages RouterのSSRはページ全体を遷移するのには適していますが、Turbo Framesのように部分置換をする機能がありません。これは従来のSPAと同様に、ブラウザ上でuseEffectを使ってステートを更新し、CSRで更新する必要があります。

一方でApp Routerは部分置換の仕組みが用意されています。LayoutParallel Routesがこれに相当します。特にこのページで紹介しているタブメニューについては、Parallel Routesのドキュメントにわざわざタブメニューの言及もあり、この方法が奨励されていると受け取れます。

LayoutやParallel Routesを使うと、Client Componentを使わずにServer Componentだけでタブメニューが実装できます。App RouterではなるべくServer Componentを使うことが推奨されていますし、なるべくサーバでデータフェッチをすることがベストプラクティスとされています。LayoutやParallel Routesを使うと、これが可能になるわけです。

Parellel Routesを使ったデモも用意しましたのでご確認ください。またコードはGitHubにあります。

Parallel Routesを使った場合のUI/UXの特徴は以下の通りです

  • どのタブを開いているかによってURLが変化します。つまり特定のタブを開いた状態をブックマークできる利点があります。これはTurbo Driveを使った場合、あるいはTurbo Framesでdata-turbo-actionを設定した時と同じ効果です。
  • 小さくて副次的な内容のタブの場合は、URLを変化させるのは不自然です。それは画面のメインではないためです。しかしServer ComponentsではURLを変化させずにタブを切り替える方法がないようです。一方でTurbo Framesの場合はdata-turbo-actionを省略するだけで、URLを固定してタブを切り替えることが可能です。
  • loading.jsを配置することでローディングアニメーションが出せます。配置しなければ、表示されません。
  • データフェッチはサーバ側で行いますが、動的なルート(Dynamic Rendering)の場合は最初のloading.jsファイルまでしかprefetchをしてくれません。そのため、コンテンツが表示されるまでにかかる時間は短くなりません。ReactでuseEffectを使った場合とほぼ同じUXになります。

またParallel Routesを使った場合のコードの特徴は以下の通りです。

個人的には、随分と大袈裟なことをするなぁと感じます。タブは大小のものがありますし、厳密にURLと結びつく必要がありません。ルーティングと強く結びつくServer Componentとの相性の悪さを感じます。Client ComponentでuseStateuseEffectを使った場合と比較して、UX上のメリットがありませんので、わざわざParallel Routesを使う動機は弱いと感じています。

ファイル・フォルダ構成は下記のようになります
Tabs Parallel Routes

タブメニューのまとめ #

今回はTurbo Framesによるタブメニューを実装しました。簡単なものだったため、UX的にMPAと大きな差はありませんでした。しかしTurbo Framesを使うと、の外のステートが維持できていることが確認できました。スクロール位置やフォーム要素のステート維持が必要な場合は、Turbo Framesを使う必要があります。

React、Next.jsと比較すると、Turbo Framesによるタブメニューは実装が簡単である一方で、機能的にも遜色がないことが確認できました。Turbo Framesの方がむしろ高速で多機能というケースが多いのもわかります。