Turbo アプリケーションの構築

Turbo は、リンクを押す、あるいはフォームを送信する際に、全ページの読み込み直しを避けることで速さを実現しています。アプリケーションはブラウザの中で持続的な、息の長いプロセスとなります。これによって、JavaScript の構成も考え方を変えなければなりません。

実際に、ナビゲーションごとに環境をリセットするための、全ページの読み込み直しに頼ることはもうできません。 JavaScript の windowdocument オブジェクトはページの変更をまたいでその状態を保持します。そして、メモリ上に置いた他のオブジェクトもそのまま残るのです。

この事実に気づき、そしてそのためにほんの少しのケアをすれば、アプリケーションを Turbo に強固に結びつけることなく、この制約を洗練された形で扱えるようデザインできます。

Script 要素と協働する

最初のページロードの際に存在する <script> 要素を、ブラウザは自動的に読み込んで評価します。

新しいページにアクセスするとき、 Turbo ドライブは新しいページの <head> 要素に、何か現在のページにはなかった <script> 要素がないかを探します。そして、あった場合、現在のページの <head> に追加し、ブラウザによる読み込みと評価が行われます。これによって、必要な時にのみ、JavaScript を読み込むことができるのです。

Turbo ドライブはページの <body> 内にある <script> 要素を、ページを描画するたびに評価します。ページごとの JavaScript の状態をセットしたり、クライアント側のモデルのブートストラップに、インラインのbody scriptを使うことができます。ページの変更時に、振る舞いをつけくわえたり、もっと複雑な操作を行いたい時は、 <script> 要素を避けて代わりに turbo:load イベントを使いましょう。

描画後に<script> 要素を Turbo に評価させたくない場合、data-turbo-eval="false" 要素をともなってアノテーションしましょう。このアノテーションは、ブラウザが最初のページロードの際の<script> 要素の評価は防がないので注意です。

アプリケーションの JavaScript バンドルを読み込む

アプリケーションの JavaScript バンドルが必ず読み込まれるようにするために、<script> 要素をドキュメントの <head> 内に配置しましょう。そうしなければ、 Turbo ドライブはページの変更ごとにバンドルを再読み込みするでしょう。

<head>
  ...
  <script src="/application-cbd3cd4.js" defer></script>
</head>

使っているアセット・パッキングシステムの、内容が変わった際に新しいURLを付与するために各スクリプトにフィンガープリントを付与する設計についても考慮が必要です。その際は、data-turbo-track 属性を使って、新しい JavaScript のバンドルがデプロイされた際にはページがすべて再読み込みされるようにできます。詳しくはアセット変更時のリロードを見てください。

キャッシュを理解する

Turbo ドライブは、最近アクセスしたページのキャッシュを維持します。このキャッシュには、二つの目的があります。ページの再構成の間、ネットワークにアクセスすることなくページを表示することと、アプリケーションのアクセスの間、一時的なプレビューを表示することで体感でのパフォーマンスを上げることです。

履歴によるナビゲーション (リストア・アクセス経由)の場合、Turbo ドライブは可能であれば、ネットワークを介して新たなコピーを読み込むことなく、キャッシュからページを復元します。

一方で、通常のナビゲーション(アプリケーション・アクセス経由)の場合、Turbo ドライブは即時にキャッシュからページを復元し、並行してネットワークを介して最新のコピーを読み込む間、プレビューとして復元したページを表示します。これによって、頻繁にアクセスされるロケーションについては、瞬間的にページがロードされるような錯覚を与えることができます。

Turbo ドライブは現在のページを、新しいページを描画する直前にキャッシュにコピーします。Turbo ドライブはページをcloneNode(true)を使ってコピーすることに注意してください。つまり、アタッチされたイベントリスナーや、紐づけられたデータはすべて破棄されます。

ページキャッシュへの備え

もし、Turbo Drive が document をキャッシュする前に準備する必要があるなら、turbo:before-cache イベントをリッスンするといいでしょう。このイベントにより、フォームをリセットしたり、展開したUIを戻したり、サードパーティのウィジェットを破棄したりして、ページがもう一度表示される準備をすることができます。

document.addEventListener("turbo:before-cache", function() {
  // ...
})

本質的に_一時的_ なページ要素というのもあります。たとえばフラッシュメッセージやアラートなどです。もしそれらが document とともにキャッシュされてしまうと、復元時に再表示されてしまいますが、大抵の場合それは望ましい挙動ではありません。そのような要素には、data-turbo-temporary をアノテートすることで、 Turbo ドライブは自動的に、キャッシュ時にそれらの要素を取り除きます。

<body>
  <div class="flash" data-turbo-temporary>
    カートが更新されました!
  </div>
  ...
</body>

Previewが表示しているかどうかの検出

Turbo ドライブは、キャッシュからプレビューを表示する際に、<html> 要素にdata-turbo-preview 属性を付与します。この属性の有無を調べることで、プレビュー表示時の振る舞いを選択的に有効にしたり無効にしたりできます。

if (document.documentElement.hasAttribute("data-turbo-preview")) {
  // Turbo ドライブはプレビューを表示している
}

キャッシュのオプトアウト

キャッシュのページごとの振る舞いは、<meta name="turbo-cache-control">要素をページの<head>に含め、キャッシュのディレクティブを宣言することでコントロールできます。

ページのキャッシュ版を、アプリケーションのアクセス時のプレビューとして見せたくない場合は、no-previewディレクティブを使います。no-preview とされたページのキャッシュは、再構成の場合にのみ利用されます。

キャッシュを全く使わないように指定するには、no-cacheディレクティブを使います。no-cacheとされたページは、常にネットワークを通じて内容を取得します。ページの再構成時も同様です。

<head>
  ...
  <meta name="turbo-cache-control" content="no-cache">
</head>

アプリケーションのキャッシュを完全に無効にするためには、全てのページにno-cache ディレクティブが含まれるようにしてください。

クライアントサイドのキャッシュのオプトアウト

<meta name="turbo-cache-control"> 要素の値はまた、Turbo.cacheを通じて参照できるクライアントサイドのAPIによってもコントロールできます。

// 現在のページのキャッシュコントロールを`no-cache`に設定する
Turbo.cache.exemptPageFromCache()

// 現在のページのキャッシュコントロールを`no-preview`に設定する
Turbo.cache.exemptPageFromPreview()

どちらの関数も<meta name="turbo-cache-control">要素がまだなければ、<head>の中に<meta name="turbo-cache-control">を書き込むことができます。

前に設定したキャッシュコントロールの値は、以下のようにリセットできます。

Turbo.cache.resetCacheControl()

JavaScriptのふるまいを取りこむ

window.onloadDOMContentLoaded、それにjQuery のreadyイベントに応じて、JavaScriptのふるまいをレスポンスに注入するのはおなじみのやり方です。Turbo では、これらのイベントは一番最初のページロードに対するレスポンスでのみ発火します。後続のページの変更の際には何も起こりません。JavaScriptの振る舞いをDOM配下に連結するための2つの戦略を比べてみましょう。

ナビゲーションイベントを監視する

Turbo ドライブはナビゲーション中の一連のイベントを開始します。これらの中でもっとも重要なものは turbo:load イベントです。これは最初のページロードの際に発火し、Turbo ドライブのvisitごとにも発火します。

DOMContentLoaded の代わりにturbo:load イベントを監視することで、ページの変更ごとにJavaScriptの振る舞いをセットすることができます。

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

アプリケーションは、イベントが発火した際にいつでも初期状態なわけではなく、前のページのためにセットされた振る舞いを綺麗にする必要があるかもしれない、ということを心にとめておいてください。

また、Turboドライブのナビゲーションだけが アプリケーションでのページ更新の唯一の源というわけではないことも心にとめておいてください。そのため、初期化のコードを関数化して分離し、turbo:loadからも、DOMを変更するかもしれない他のどこからでも呼べるようにしたくなるかもしれません。

他のイベントリスナーをページ・ボティに直接追加するのにturbo:loadイベントを使うのは、できるだけ避けましょう。その代わり、 event delegation を利用してイベントリスナーをdocument あるいは window に追加することを考慮してください。

より詳しい情報は、 イベント全リスト にあります。

Stimulus を使ってふるまいを追加する

あたらしいDOMは、フレームのナビゲーション、ストリーム・メッセージ、それにクライアント・サイドのレンダリング操作という方法によっていつでもページに現われる可能性があります。そしてこれらの新しい要素は、まるで新しいページロードが走ったかのように初期化される必要があることも、よくあります。

これらの、Turboドライブからのページロードを含めたすべての更新を、単一の箇所とのやりとりとライフサイクル・コールバックで管理することができます。 Turboの姉妹フレームワークであるStimulusがそれを提供します。

Stimulusを使ってアプリのHTMLにコントローラー、アクション、そしてターゲット属性をアノテーションすることができます。

<div data-controller="hello">
  <input data-hello-target="name" type="text">
  <button data-action="click->hello#greet">挨拶</button>
</div>

対応したコントローラーを実装すれば、Stimulus は自動的に接続してくれます。

// hello_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  greet() {
    console.log(`こんにちは、 ${this.name}さん!`)
  }

  get name() {
    return this.targets.find("name").value
  }
}

Stimulus はドキュメントが変更されたときにはいつでも、これらのコントーローラーへの接続と接続切断、さらにイベント・ハンドラの統合を行います。それには、 MutationObserver API が利用されます。その結果、Turbo ドライブのページ変更、 Turbo フレームのナビゲーション、そして Turbo ストリームのメッセージを、他の方法でのDOM更新を扱うのと同じ方法で扱うことができるのです。

変更をべき等にする

サーバーから受け取ったHTMLに、クライアントサイドで変更を施したい場合というのはよくあります。 例えば、要素を日毎にグルーピングするのに、ブラウザが認識している、ユーザーの現在のタイムゾーンを使いたい、というような場合です。

要素のセットにdata-timestamp属性をアノテートするとしましょう。これらの要素の作成日時はUTCです。そして、こういった要素をドキュメントの中からすべて探しだし、タイムスタンプをローカルタイムに変更し、新しい日付に変わった要素の前に日付の見出しを挿入するJavaScriptの関数を用意します。

もし、この関数がturbo:load時に実行されるよう設定したら、何が起こるでしょう。このページにナビゲートしてきたら、関数が日付の見出しを挿入します。ページを去る際に、Turbo ドライブが変更された(日付の挿入された)ページのコピーをキャッシュします。さて、ユーザーがブラウザの戻るボタンを押し、Turbo ドライブがページを復元したとき、turbo:load がもう一度発火し、関数は二つ目の日付の見出したちを重ねて挿入することになります。

この問題を避けるために、変更する関数を べき等 にしましょう。べき等な変更は、複数回それを適用しても、その最初の適用以上に結果を変えることはありません。

べき等な変更をつくるテクニックの一つは、すでに実行されたかどうかを、それぞれの処理された要素にdata 属性をセットすることで追跡できるようにすることです。Turbo ドライブがキャッシュからページを復元する際、これらの属性は残っています。これらの属性を変更のための関数で走査し、どの要素がすでに処理済みなのかを決定するのです。

より堅牢なテクニックは、ただ変更自体を走査することです。前述の日付でのグルーピングの例でいえば、新しい日付を挿入する前に、その日付がすでにあるかどうかをチェックするのです。このやり方は元の変更で処理されていない新しい挿入要素だけを無駄なく取り扱うことができます。

ページのロードにまたがって要素を永続化する

Turbo ドライブではある要素に permanent とマーキングすることができます。永続化要素は、ページのロードにまたがって保持されるため、これらの要素に施した変更を、ナビゲーション後に再び施す必要はありません。

ショッピングカートを実装するTurbo ドライブを考えてみましょう。各ページのトップには、現在カートに入っている商品の数がアイコンで表示されています。このカウンターは、商品が追加されたり削除されるたび、 JavaScript で動的に更新されます。

さて、ユーザーがアプリケーション内のいくつかのページを移動することを考えてみましょう。カートに商品を追加し、ブラウザの「戻る」ボタンを押します。ナビゲーション上で、Turbo ドライブは以前のページの状態をキャッシュから復元します。すると、カート内の商品数は、誤って1から0に変わるのです。

この問題は、カウンター要素をパーマネントなものとしてマーキングすることで避けられます。HTMLのid を付与し、data-turbo-permanent属性をアノテーションすることで、パーマネント指定をしましょう。

<div id="cart-counter" data-turbo-permanent>1 アイテム</div>

それぞれの描画の前に、Turbo ドライブはすべての永続要素をIDでマッチし、それを元ページから新ページに移し、そのデータとイベント・リスナーを保存します。