RailsのTurbo Framesで遅延読み込み|重い部分だけ後から出して体感速度を上げる

Turbo Framesの遅延読み込みの流れ。ページ本体は即表示しスケルトンを出す、重い処理を別アクションに切り出す、同じIDのフレームに中身を差し替える、の3ステップ図
  • このエントリーをはてなブックマークに追加

スポンサーリンク

「ページの表示が重い。でも遅いのは一部の集計やフィードだけで、そこだけ後から出せれば十分なのに」——そんなふうに感じたことはありませんか?

ダッシュボードのアクティビティ一覧、外部APIを叩く箇所、重い集計。こうした「一部だけ遅い」ページを速く見せるのに、JavaScriptをガリガリ書く必要はありません。

RailsにはHotwire標準のTurbo Framesがあり、loading: :lazy を付けるだけで「重い部分だけを別リクエストで後から読み込む」設計が実現できます。TurboはRails 7以降は標準添付(turbo-rails)なので、Rails 7でも8でも追加のgemなしで使えます。この記事では、最小構成のコードとつまずきポイントまで、まとめて解説します。

この記事で作るもの(完成イメージ)

今回作るのは、次のような動きのダッシュボードです。

  • ページ本体(見出しやナビ)はすぐに表示される
  • 重いアクティビティ一覧の場所には、最初はスケルトン(灰色のプレースホルダ)が出る
  • その部分だけ別のエンドポイントから後追いで読み込まれ、中身が差し込まれる
Turbo Framesの遅延読み込みの流れ。ページ本体は即表示しスケルトンを出す、重い処理を別アクションに切り出す、同じIDのフレームに中身を差し替える、の3ステップ図
Turbo Frames の遅延読み込み:本体は即表示し、重い部分だけを別リクエストで後追い表示する

ポイントは、重い部分を「別のアクション」に切り出しておくことです。メインの表示をその重さで止めないので、体感速度が大きく変わります。しかも書くのはほぼERBだけ。順番に見ていきましょう。

遅延読み込みの基本形

まず、メインのビュー側です。重い部分を turbo_frame_tag で囲み、src(読み込み先のパス)と loading: :lazy を指定します。

<%# app/views/dashboards/show.html.erb %>
<h1>ダッシュボード</h1>

<%= turbo_frame_tag "activity_feed", src: activity_feed_path, loading: :lazy do %>
  <p>読み込み中...</p>
<% end %>

これだけで、ページを開くと <h1> はすぐ表示され、activity_feed フレームは activity_feed_path を裏で取りにいきます。do ... end の中身(ここでは「読み込み中…」)は、読み込みが終わるまでのあいだ表示されるプレースホルダです。

ここで大事なのは、loading: :lazy にすると読み込みがフレームが画面に見えたタイミングまで遅延される点です。ファーストビューの外にあるフレームは、スクロールして見える位置に来るまでリクエストが飛びません。ページ下部の重い一覧に付けると、初回表示のコストをさらに減らせます。

ルーティングとコントローラを用意する

次に、src の飛び先を用意します。ここが「重い処理を切り出す」本体です。まずはルーティング。

# config/routes.rb
resource :dashboard, only: :show
resource :activity_feed, only: :show

続いてコントローラです。重い集計やクエリは、このアクションの中だけで完結させます。メインのダッシュボード側からはこの処理を切り離せるので、本体の表示はこの重さに引きずられません。

# app/controllers/activity_feeds_controller.rb
class ActivityFeedsController < ApplicationController
  def show
    # 重い集計・関連の読み込みはここに閉じ込める
    @activities = Activity.recent.includes(:user).limit(20)
  end
end

そして読み込み先のビュー。ここが最大のポイントで、返すHTMLの中にメイン側と同じIDの turbo_frame_tag(= <turbo-frame>)を必ず含めます。Turboはこの同じIDのフレームを探し出し、その中身だけを差し替えます。

<%# app/views/activity_feeds/show.html.erb %>
<%= turbo_frame_tag "activity_feed" do %>
  <ul>
    <% @activities.each do |activity| %>
      <li><%= activity.summary %></li>
    <% end %>
  </ul>
<% end %>

これで動きます。メインの activity_feed フレームが activity_feed_path を取得し、返ってきたHTMLの中の同IDフレームの中身(<ul>...</ul>)で置き換わる、という流れです。

スケルトンで「空白の一瞬」を消す

「読み込み中…」のテキストのままでも動きますが、実務ではスケルトン(骨組みだけのプレースホルダ)を置くと、待ち時間の印象がぐっと良くなります。読み込みが終わると、この骨組みが実データに置き換わります。

プレースホルダは turbo_frame_tag の do ... end に書くだけです。

<%= turbo_frame_tag "activity_feed", src: activity_feed_path, loading: :lazy do %>
  <div class="skeleton">
    <div class="skeleton-line"></div>
    <div class="skeleton-line"></div>
    <div class="skeleton-line"></div>
  </div>
<% end %>

スケルトンの見た目はCSSだけで作れます。JavaScriptは不要です。

.skeleton-line {
  height: 1rem;
  margin-bottom: 0.75rem;
  border-radius: 4px;
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: skeleton 1.2s ease-in-out infinite;
}

@keyframes skeleton {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

「空白 → いきなり表示」よりも「骨組み → 実データ」のほうが、体感の待ち時間は短く感じられます。画像を多く含む一覧(たとえばActiveStorageのアバター機能を並べる画面)でも、この置き換えは有効です。

eager と lazy の違い

loading には eager(デフォルト)と lazy の2つがあります。どちらも「別リクエストで読み込む」点は同じですが、いつ読み込むかが違います。

  • eager(既定): フレームがDOMに現れた瞬間、すぐに src を読みにいく。ファーストビュー内の重要な部分向き
  • lazy: フレームが画面に見える位置に入るまで読み込みを遅らせる。ページ下部やタブの裏など、すぐ見えない部分向き

使い分けの目安はシンプルです。すぐ見える重要な塊は eager、スクロールしないと見えない塊は lazy。lazy にすると、ユーザーがそこまで見なければリクエスト自体が発生しないので、サーバー負荷も下げられます。

よくあるつまずきと対処

① 中身が差し込まれない(IDが一致していない)

いちばん多いのがこれです。読み込み先のビューに、メインと同じIDの turbo_frame_tag が無いと、Turboは差し替える先を見つけられず、中身が入りません。

src 側のフレームIDと、返すHTMLのフレームIDが完全に一致しているかを最初に確認してください。dom_id(@post) のように動的なIDを使う場合は、両側で同じ式になっているかに注意します。

② ルーティング・アクション・部分テンプレートの用意漏れ

src に指定したパスは、実在するルーティングとアクションを指している必要があります。activity_feed_path を書いたのに routes.rb に定義が無い、といったケアレスミスが起きがちです。bin/rails routes でパスヘルパーの有無を確認しましょう。

③ レイアウトを二重に描画してしまう

フレーム読み込みのレスポンスに、ヘッダーやフッターまで含んだフルのレイアウトが乗るのは無駄です。Turboは同IDのフレーム部分しか使わないので、レイアウトを外しておくと転送量を減らせます。

turbo_frame_request? でフレームからのリクエストかどうかを判定し、その場合だけレイアウトを外すのが定番です。

def show
  @activities = Activity.recent.includes(:user).limit(20)
  # フレームからの読み込みならレイアウトを外して軽くする
  render layout: false if turbo_frame_request?
end

「遅い原因を潰す」→「残りは遅延で逃がす」の合わせ技

遅延読み込みは強力ですが、遅いものを遅いまま後回しにするだけでは、フレームの中身がなかなか出てこず、結局ユーザーを待たせます。順番としては、まず遅い原因そのものを潰すのが先です。

典型的なのがN+1クエリです。上のコントローラで includes(:user) を付けているのは、一覧表示でユーザーを1件ずつ引かないための対策です。クエリを最適化して速くできる部分は速くし、それでも重い外部API・重い集計だけを遅延で逃がす——この順番が体感速度を最大化します。

フィードや一覧の作り方そのものは、Railsブログ機能の実装方法7選でも触れています。あわせて読むと、どこを別フレームに切り出すかのイメージが掴みやすいはずです。

まとめ

Turbo Framesの遅延読み込みは、JavaScriptをほとんど書かずに「一部だけ遅いページ」を速く見せる、Rails標準の武器です。ポイントを振り返ります。

  • 重い部分を turbo_frame_tag ..., src:, loading: :lazy で囲み、別アクションに切り出す
  • 読み込み先のビューには同じIDのフレームを必ず置く(差し込まれない最大の原因)
  • do ... end にスケルトンを入れて、待ち時間の印象を良くする
  • すぐ見える塊は eager、スクロール先の塊は lazy
  • まずN+1などの遅い原因を潰し、それでも重い部分だけを遅延で逃がす

JavaScriptを増やさずにUXを上げたい、という方針はimportmapでJSライブラリを使う方法とも相性が良いです。まずは一番重い一箇所を loading: :lazy に置き換えるところから、試してみてください。

スポンサーリンク

  • このエントリーをはてなブックマークに追加