Chapter 06

Turbo Drive

shita
shita
2022.04.15に更新

🐱 ここからはHotwireを構成する技術を個別に深堀りしていくよ。まずはTurbo Driveから!

Turbo Driveとは?


通常の画面遷移


Turbo Driveによる画面遷移

🐱 Turbo Driveは画面遷移を高速にしてくれる機能だよ。

🐱 Turbo DriveはTurbolinksの名前を変えたもので、基本的な機能はTurbolinksと同じだよ。リンク、フォームのリクエストをTurbo Driveがインターセプトして、fetchによる非同期リクエストに差し替える。そしてレスポンスされたHTMLの<body>要素だけを抜き出して、現在のページの<body>要素を置換してくれるよ。

🐱 通常の画面遷移がHTMLを丸ごと変えるに対して、Turbo Driveでの画面遷移は<body>だけを置換する感じだよ(正確には<body>の置換に加えて、<head>の一部がマージされる)。

🐱 これの何が嬉しいかと言うと、画面遷移しても今のページのCSS・JavaScriptをそのまま利用できるため、CSS・JavaScriptを初期化してページに適用する処理をスキップできるんだ。これによって画面遷移が高速になるよ。

🐱 Turbo Driveはコードを何もいじる必要がなくて、導入するだけで無料で高速化できちゃうっていうのが特徴だよ。

Turbo Driveの処理の流れ

🐱 Turbo Driveの処理の流れは以下の通りだよ。

  1. リンクをクリックした時、あるいはフォームをサブミットした時に、Turbo Driveがデフォルトのリクエスト処理をインターセプトしてfetchに差し替える
  2. fetchしたHTMLの<head>タグのassetsを確認して、現在のページと同じものなら処理を継続する。異なっているならlocation.hrefを書き換えリダイレクトする
  3. fetchしたHTMLの<body>の部分だけを、現在のページの<body>要素と差し替える
  4. fetchしたHTMLの<head>の一部(title, CSRFトークン)を、現在のページの要素と差し替える
  5. history.pushState()を使いURLを更新する

🐱 fetch時にHistory APIを使いURLを更新するので、ブラウザの画面遷移ではないんだけれどもブラウザの履歴(戻るボタンや進むボタンなど)を使えるよ。

Turbo Driveで高速になる理由

🐱 Turbo Driveで画面遷移する場合には、今のページのCSS・JavaScriptをそのまま利用できる。そのためCSS・JavaScriptを初期化してページに適用する処理をスキップすることができて、画面遷移を速くできるよ。この「CSS・JavaScritを初期化してページに適用する」という処理が画面表示時間の大きな部分を占めているので、ここをスキップしてしまおうという発想だよ。

🐱 Rails作者のDHHが開発しているBasecampというRailsアプリケーションでは、これで画面遷移の速度が最大3倍になったらしいよ。

🐱 これとは別にCSSとJavaScripの再ダウンロードをスキップできるという恩恵もあるけれども、こちらは基本的にはキャッシュが効くため、あんまり速度には影響しないみたいだよ。

ページキャッシュ

🐱 Turboはページをキャッシュする機能を備えていて、2つの用途で利用するよ。

🐱 1つ目はブラウザの戻る・進む機能で利用する。Turbo Driveは新しいページを開く度にページをキャッシュしてくれて、戻る・進む時にはそのキャッシュを表示してくる。これで戻る・進む時に毎回リクエストする必要がなくなり、高速化できる。


通常の戻る


Turbo Driveによる戻る

🐱 2つ目はプレビューと呼ばれる機能だよ。既にキャッシュされているページを再び訪れた際に、ページの読み込みが完了するまでの間、キャッシュを表示するよ。そしてページの読み込みが完了したらそのページを表示する。ユーザーからするとページの読み込みが完了する前からそのページ(のキャッシュ)が表示されるので、体感速度の向上につながるよ。


通常の画面遷移


Turbo Driveによる画面遷移(プレビューを利用)

🐱 プレビュー機能はキャッシュされた状態とページを読み込んだ状態が異なる場合、画面を開いた後に表示内容が書き換わるので、鬱陶しく感じることもある。そんな場合にはプレビュー・キャッシュを無効にすることも可能だよ。

<head>
  ...

  <%# プレビューだけ無効にする %>
  <meta name="turbo-cache-control" content="no-preview">
</head>
<head>
  ...

  <%# キャッシュを丸ごと無効にする %>
  <meta name="turbo-cache-control" content="no-cache">
</head>

🐱 プレビュー時にはdocumentがdata-turbo-previewというデータ属性を持つよ。これを利用してプレビュー中の処理をJavaScriptで書くことができるよ。

if (document.documentElement.hasAttribute("data-turbo-preview")) {
  // プレビュー表示中の処理
}

🐱 その他いくつか注意点があるので列挙しておくね。

  • 最大直近10件をキャッシュする
  • キャッシュ利用時はturbolinks:loadを発火する
  • キャッシュ利用時はcloneNode(true)を使ってDOMを再構築するため、イベントはコピーされず、毎回新しいDOMになる

プログレスバー

🐱 Turbo Driveを使うとブラウザの画面遷移を利用しなくなるので、ブラウザのタブのスピナー(くるくる)をフィードバックとして使えなくなるよ。なのでページ読込中を示す視覚的なフィードバックとしてTurbo Driveが提供するプログレスバーを代わりに使うことになるよ。


通常の画面遷移(タブのスピナー)


Turbo Driveの画面遷移(プログレスバー)

🐱 このプログレスバーはデフォルトで有効で、500ms以上かかるページで自動的に表示されるよ。

🐱 プログレスバーは.turbo-progress-barというクラス属性を持っているので、CSSで見た目をカスタマイズすることが可能だよ。

.turbo-progress-bar {
  height: 5px;
  background-color: green;
}

スクロール保持

🐱 Turbo Driveは画面遷移時に自動で画面トップまでスクロールしてくれるよ。

🐱 スクロール位置を覚えておいてくれるので、戻るボタンで画面遷移をした際にはその位置までスクロールしてくれるよ。

アセット更新時の強制リロード

🐱 Turbo Driveを使っていると<head>内のアセット(JavaScript・CSS)は初回しか読み込まれないよ。

🐱 デプロイをしてアセットを更新した際には、最新のアセットを読み込むためにページをリロードしてほしい。そんな場合には<link><script>data-turbo-track属性を指定するで強制的にリロードさせることができるよ。デプロイ時の$ rails assets:precompileでpathが更新されるので、そのpathの変更を検知してリロードしてくれるよ。

🐱 Rails7ではデフォルトでセットアップされているので変更する必要はないよ(↓はjsbundling-railsを利用している場合のコード)。

<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>

🐱 HTMLだとこんな感じ。

<link rel="stylesheet" href="/application-d4008ddeb261f22a9f53f3dddd43752869320b84c550fd43799xxxx.css" data-turbo-track="reload">
<script src="/assets/application-d4008ddeb261f22a9f53f3dddd43752869320b84c550fd43799xxxx.js" data-turbo-track="reload" defer="defer"></script>

指定ページの強制リロード

🐱 Turboと相性が悪いJavaScriptのライブラリを使う際に、指定のページだけフルリロードさせたい場合があるよ。そんな時は以下のような<meta>要素を追加すると、ページを開いた際にwindow.location.reload()を実行させて強制的にフルリロードさせることができるよ。

<head>
  ...
  <meta name="turbo-visit-control" content="reload">
</head>

画面遷移時にHTML要素を保持する

🐱 data-turbo-permanent属性を使うと、画面遷移時にHTML要素を更新せずに保持できるよ。

🐱 上のGIFではサイドバーに<audio>要素を置いているよ。通常は画面遷移するたびに<audio>の再生状態はリセットされるけど、data-turbo-permanent属性を使うと画面遷移しても<audio>要素は保持されるため、そのまま再生され続けるよ。

🐱 具体的なコードはこんな感じだよ。

app/views/application/_sidebar.html.erb
<audio
  data-turbo-permanent
  id="permanent-audio"
  controls
  src="https://xxxx.xxxx.mp3">
</audio>

🐱 レンダリングの前にTurbo Driveがdata-turbo-permanent属性を持つDOMのidを照合して、それを元のページから新しいページに転送して、データとイベントリスナーを保持するよ。

🐱 これでaudioを再生中に画面遷移しても音声を止めずに済むよ。他にも<video>タグに対して使ったり、あるいはサイドバーに対して利用して画面遷移時に右側のコンテンツ領域だけを更新させるような使い方も可能だよ。

リンクでPOST・PUT・PATCH・DELETEを使う

🐱 リンクのデフォルトのHTTPメソッドはGETだよ。これをGET以外(POST・PUT・PATCH・DELETE)のメソッドに変えるにはdata-turbo-method属性を使うよ。

<%= link_to "リンク", cat, data: { turbo_method: :delete } %>
<%= link_to "リンク", cat, data: { turbo_method: :post } %>
<%= link_to "リンク", cat, data: { turbo_method: :put } %>
<%= link_to "リンク", cat, data: { turbo_method: :patch } %>

🐱 HTMLだとこうなるよ。

<a href="/cats/1" data-turbo-method="delete">リンク</a>
<a href="/cats/1" data-turbo-method="post">リンク</a>
<a href="/cats/1" data-turbo-method="put">リンク</a>
<a href="/cats/1" data-turbo-method="patch">リンク</a>

🐱 data-turbo-methodを使うと内部的にはリンクはフォームに変換されるよ。フォームは入れ子にはできないため、これらのリンクはフォーム内には置けないことに注意してね。

🐱 Rails6ではrails-ujsというライブラリの機能のdata-method属性を使うことで同じようなことができたよ。でもRails7からはTurboがデフォルトになり、rails-ujsの機能はTurboが引き受ける形になったので、data-methodではなくdata-turbo-methodを使うようになるよ。

履歴操作方法の指定

🐱 Turbo Driveは画面遷移時にブラウザの画面遷移を模倣するために、History APIというのを使ってブラウザの履歴を操作しているよ。デフォルトではhistory.pushState(履歴エントリを追加)を使い履歴を残すよ。この挙動はdata-turbo-action属性で変更できるよ。

<%# デフォルトではhistory.pushState(履歴エントリを追加)を使う %>
<%= link_to "リンク", cat %>

<%# 明示的に指定することもできる %>
<%= link_to "リンク", cat, data: { turbo_action: :advance } %>

<%# history.replaceState(履歴エントリを上書き)を使う %>
<%= link_to "リンク", cat, data: { turbo_action: :replace } %>

🐱 HTMLだとこうなるよ。

<a href="/cats/1">リンク</a>
<a href="/cats/1" data-turbo-action="advance">リンク</a>
<a href="/cats/1" data-turbo-action="replace">リンク</a>

🐱 デフォルトのadvanceだと履歴を追加して、replaceだと履歴を上書きするよ。

Turboの無効化

🐱 data-turbo属性でTurboを無効化できるよ。

<!-- リンクの場合 -->
<a href="/" data-turbo="false">無効</a>

<!-- フォームの場合 -->
<form action="/messages" method="post" data-turbo="false">...</form>

🐱 親要素に対して使うこともできるよ。

<!-- divの中ではTurboが無効になる -->
<div data-turbo="false">
  <a href="/">無効</a>
</div>

🐱 全体を無効にしておいて一部だけ有効にすることもできるよ。

<!-- divの中ではTurboが無効になる -->
<div data-turbo="false">

  <!-- 無効 -->
  <a href="/">無効</a>

  <!-- 有効 -->
  <a href="/" data-turbo="true">有効</a>

</div>

rootロケーションの設定

🐱 デフォルトではTurbo Driveは現在のページと同じオリジン(プロトコル、ドメイン、ポート)のURLの場合のみ機能する。他のURLにアクセスする場合は通常の画面遷移にフォールバックするよ。

🐱 <meta name="turbo-root">を使うとTurbo Driveが機能するスコープを更に狭くできるよ。/app配下(例えば/app/hogeとか)ではTurbo Driveを有効にしたいけど、/helpの場合は無効にしたい、という場合には以下のように書くことができるよ。

<head>
  ...

  <!-- `/app/hoge`のように`/app`から始まるパスへのリンクだけTurbo Driveが有効になる -->
  <meta name="turbo-root" content="/app">
</head>

Turbo DriveをJavaScriptから操作

🐱 TurboはJavaScriptからプログラマブルに操作することもできるよ。

画面遷移

🐱 Turbo.visit()を使うと画面遷移できるよ。

// history.pushState(履歴エントリを追加)を使う場合
// デフォルトはこちら
Turbo.visit("/cat/1")
Turbo.visit("/cat/1", { action: "advance" })

// history.replaceState(履歴エントリを上書き)を使う場合
Turbo.visit("/cat/1", { action: "replace" })

キャッシュをクリア

🐱 Turbo.clearCache()でTurboのキャッシュを全てクリアできるよ。

Turbo.clearCache()

プログレスバーが表示されるまでの時間を設定

🐱 Turbo.setProgressBarDelayでプログレスバーが表示されるまでの時間を設定できるよ。デフォルトだと500msで、500ms後にプログレスバーが表示されるよ。

Turbo.setProgressBarDelay(1000)

Turbo Driveを無効化

🐱 アプリケーション全体でTurbo Driveを無効化するよ。

Turbo.session.drive = false

🐱 全体を無効にしておいて、リンク・フォームにdata-turbo="true"を設定することで個別に有効にすることもできるよ。

確認ダイアログの挙動をカスタマイズ

🐱 Turboではdata-turbo-confirm属性で確認ダイアログを出すことができるよ。

<%= link_to '削除', cat, data: { turbo_method: :delete, turbo_confirm: "本当に削除しますか?" } %>

🐱 Turbo.setConfirmMethod()を使うと、この確認ダイアログの挙動をカスタマイズできるよ。

// 自前のconfirm関数を用意する
// デフォルトではJavaScriptの`confirm()`を利用するが、その挙動をカスタマイズできる
const newConfirmMethod = (message, formElement) => confirm("overriden method")

// confirm関数をセットする
Turbo.setConfirmMethod(newConfirmMethod)

参考
https://github.com/hotwired/turbo/pull/379#issuecomment-921687912

Turbo Driveのイベントの流れ

🐱 フォームでサブミットするとこんな流れでイベントが発火されるよ。

1. turbo:submit-start: フォーム送信開始時
2. turbo:before-fetch-request: fetchリクエスト前
3. turbo:before-fetch-response: fetchレスポンス前
4. turbo:submit-end: フォーム送信終了時
5. turbo:before-visit: ページ訪問前
6. turbo:visit: ページ訪問時
7. turbo:before-cache: ページのキャッシュ保存前
8. turbo:before-render: ページのレンダリング前
9. turbo:render: ページのレンダリング後
10. turbo:load: ページのロード後

🐱 リンククリック時はこんな感じ。

1. turbo:click: リンククリック時
2. turbo:before-visit: ページ訪問前
3. turbo:visit: ページ訪問時
4. turbo:before-cache: ページのキャッシュ保存前
5. turbo:before-render: ページのレンダリング前
6. turbo:render: ページのレンダリング後
7. turbo:load: ページのロード後

Turbo DriveとTurbolinksの違い

🐱 Turbo Driveは元々はTurbolinksというライブラリだったんだ。TurbolinksというのはRails4からデフォルトとして導入されたアプリケーションを高速化するためのgemだよ。Turbo DriveとTurbolinksは機能的にはほとんど同じなのだけれども、いくつか違いがあるから紹介するね。

リンクだけなくフォームも扱うようになった

🐱 最も大きな変更はTurbolinksではリンクだけが対象だったんだけど、Turbo Driveになってフォームも扱うようになったことだよ。これによりフォームからのリクエストもデフォルトでfetchされるようになったんだ。今まではform_withlocal: falseを設定することで、rails-ujsというライブラリが非同期のリクエストをしてくれていたんだけど、Rails7からrails-ujsはデフォルトのgemから外れて、rails-ujsの役割は全てTurboが担うことになったよ。

イベント名の変更

🐱 TurbolinksからTurboに名前が変わったのに対応して、イベント名も変わったよ。

turbolinks:click         -> turbo:clickTurbo
turbolinks:before-visit  -> turbo:before-visit
turbolinks:visit         -> turbo:visit
turbolinks:request-start -> turbo:before-fetch-request
turbolinks:request-end   -> turbo:before-fetch-response
turbolinks:before-cache  -> turbo:before-cache
turbolinks:before-render -> turbo:before-render
turbolinks:render        -> turbo:render
turbolinks:load          -> turbo:load

🐱 イベントの詳細は「10 Turboのイベント」を参考にしてね。

クラス名の変更

🐱 JavaScriptから呼び出す際のクラス名も変更になったよ。

// Turbolinks
Turbolinks.visit("/edit")
Turbolinks.clearCache()

// Turbo
Turbo.visit("/edit")
Turbo.clearCache()

Turbo Driveを使う際の注意点

画面遷移時にDOMContentLoadedが発火しない

🐱 通常はDocumentの読み込み時にJSを実行するのに、DOMContentLoadedイベントを監視する。(jQueryであればready

// 通常
document.addEventListener("DOMContentLoaded", function() {
  // ...
})

// jQuery
$(function() {
  // ...
})

🐱 でもTurbo Driveの場合はDocumentの読み込みが発生するのは最初の1回だけで、あとはfetchを利用するので、DOMContentLoadedは最初の1回しか発火されないよ。

🐱 Turbo DriveではDOMContentLoadedの代わりにturbo:loadを利用できるよ。これは最初のページの読み込み時に1回発生して、あとはTurbo Driveで画面遷移する度に発生するよ。

document.addEventListener("turbo:load", function() {
  // ...
})

🐱 DOMContentLoadedと同じでloadも初回しか発火しないよ。これも代わりにturbo:loadを利用するといいよ。

バリデーションエラーでHTMLを返す場合は422 Unprocessable Entityにする

🐱 Turboはフォーム送信に対してリダイレクトを期待する。そのため今までのやり方でバリデーションエラーをHTMLをレスポンスしようとしても上手くいかない。

app/controllers/cats_controller.rb
def create
  @cat = Cat.new(cat_params)
  if @cat.save
    redirect_to cat_url(@cat), notice: '登録しました'
  else
    # ステータスコードが200 OKだと上手くいかない
    render :new
  end
end

🐱 こんな感じでステータスコードを422 Unprocessable Entityに変更すると上手くいくよ。

app/controllers/cats_controller.rb
def create
  @cat = Cat.new(cat_params)
  if @cat.save
    redirect_to cat_url(@cat), notice: '登録しました'
  else
    # ステータスコードを422 Unprocessable Entityにすると上手くいく
    render :new, status: :unprocessable_entity
  end
end

🐱 ちなみにRails 7のscaffoldで生成されるコードはデフォルトでstatus: :unprocessable_entityになっているので、特に手を加える必要はないよ。

🐱 Turbo Driveだけでなく、Turbo Framesもこの方法で動作するよ。

参考
https://github.com/hotwired/turbo-rails/issues/12

Hotwireのお仕事を探しています🙇‍♂️

現在、業務委託で入れるHotwireのお仕事を探しています。

Hotwireの経験があるRailsエンジニアをお探しの方は、ぜひ https://twitter.com/shita1112 にDMください🙇‍♂️

(Hotwire便利で楽しいのでもっと使っていきたいのですが、現状だとHotwireの案件はなかなか見つからない感じなのでした......この本が参考になったと感じて、これからHotwireやっていくぞ〜となった方は、ぜひぜひお気軽にDMください〜。とても喜びます!)