📝

Turbo.FetchRequest の使用例

2024/11/11に公開

前書き

Hotwire の Turbo Frames のフレームによる部分的な HTML の置き換えについて、通常は A タグのクリックや FORM の送信によって駆動しますが、 JavaScript から任意に実行させたいと思います。

なおこの記事において、 Turbo のソースコードの確認は https://github.com/hotwired/turbo/blob/v8.0.11 で見ておりますが、動作確認は Ruby on Rails のアプリ上、すなわち Gem "turbo-rails" のバージョン 2.0.11 に含まれているものです。(いずれも記事を書いている時点での最新バージョンです)

題材 HTML

例として次のようなものを考えます。各リスト・アイテムには A タグのボタンがあり、それをクリックすると、サーバー上でそのアイテムのレコードの "更新日時" のタイムスタンプが更新され、結果としてそのリスト・アイテム内の <turbo-frame> 部分が置き換えられる、とします:

<ul id="memos">
  <li>
    <turbo-frame id="memo_1">
      <span>買い物</span>
      <span>更新日時 2024-11-08 11:54:40</span>
    </turbo-frame>

    <a href="/memos/1/touch" data-turbo-frame="memo_1" data-turbo-method="PATCH">
      Touch
    </a>
  </li>
  
  <li>
    <turbo-frame id="memo_2">
      <span>家賃振込</span>
      <span>更新日時 2024-11-10 12:33:01</span>
    </turbo-frame>

    <a href="/memos/2/touch" data-turbo-frame="memo_2" data-turbo-method="PATCH">
      Touch
    </a>
  </li>
</ul>

実装例

click イベントを呼び出す

すぐに思いつくのは、要素に対してイベントを着火させるという、単純なやりかたです。 A タグであればその要素に対して "click" を呼び出すだけなので、お手軽です。というか、結論としてこれでよいのかもしれません。イベントを介して各コンポーネントが連携するといった思想に合っているように思います。

コードの断片で表現すると、このような感じです:

const button = document.getElementById('memo_1').closest('li').querySelector('a')
button.click()

Turbo.FetchRequest を使う

そしてもうひとつ別の手法があります。この記事はこちらが本旨なのですが、 Turbo.FetchRequest を使います。

これは Hotwire 公式のドキュメントにはないため、使うべきかどうかはいちど考える必要がありそうです。ただこれが使えるのであれば、任意のタイミングで Turobo Frame を処理したいときに、イベントを着火させる手法よりも有用な場合があるかもしれません。

クラス Turbo.FetchRequest はここにあります:

https://github.com/hotwired/turbo/blob/v8.0.11/src/http/fetch_request.js#L50

コンストラクタの仮引数に delegate というのがありますが、これは Turbo.FetchRequest のインスタンスが perform する(後述)処理の中で、その delegate に対していくつかメソッドを呼ぶようなので、つまり呼ばれる可能性があるメソッドを実装したクラスを用意し、そのインスタンスを与えるようにします。

Turbo.FetchRequest を使っている他のソースコードを参考にして(たとえば https://github.com/hotwired/turbo/blob/v8.0.11/src/core/drive/form_submission.js#L39 )、単純なデリゲートクラスを書いてみます。

class TurboFrameDelegatee {
  constructor(location, method, targetFrameId, requestBody = new URLSearchParams()) {
    this.location = location
    this.method = method
    this.targetFrameId = targetFrameId
    this.requestBody = requestBody
    this.fetchRequest = new Turbo.FetchRequest(this, this.method, this.location, this.requestBody, this.targetFrameId)
  }

  async perform() {
    return this.fetchRequest.perform()
  }

  cancel() {
    return this.fetchRequest.cancel()
  }

  // (1)
  prepareRequest(request) {}

  // (2)
  requestStarted(request) {}

  // (3)
  requestSucceededWithResponse(request, response) {
    document.getElementById(this.targetFrameId).delegate.loadResponse(response)
  }

  // (4)
  requestFinished(request) {}

  requestPreventedHandlingResponse(request, response) {}

  requestFailedWithResponse(request, response) {}

  requestErrored(request, error) {}
}

このようなクラスを用意したら、それをインスタンス化し、その perfom メソッドを呼んで、所望の処理を実行します:

  const button = document.getElementById('memo_1').closest('li').querySelector('a')
  const url = button.getAttribute('href')
  const method = button.getAttribute('data-turbo-method') || 'GET'
  const frame = button.getAttribute('data-turbo-frame')

  new TurboFrameDelegatee(url, method, frame).perform()

作成したクラスのメソッドに番号を (1) 〜 (4) と振りましたが、 Turbo.FetchRequest インスタンスのメソッド perform を実行した際の正常系はこの順序で、そのメソッド名が示唆するタイミングで、メソッドが呼ばれます。また番号を振っていないメソッドは異常系を通るときに呼ばれます。いずれも特に処理することがなければ空っぽでもよいのですが、メソッドは必要です。

最初に例として挙げた HTML は、 A タグからのリクエストのメソッドを "PATCH" にしていました。すると、多くの場合 CSRF トークンが必要になってくると思います。その場合は (1) の中で、リクエスト・ヘッダに CSRF トークンをセットするコードを置くようにします:

prepareRequest(request) {
  if (!request.isSafe) {
    const token = getCsrfToken() // ... 関数の中身は割愛
    if (token) {
      request.headers['X-CSRF-Token'] = token
    }
  }
}

そして要となっているのが (3) の中に書いている部分です。これは、サーバからのレスポンスから <turbo-frame> を抜き出して、元の HTML の当該箇所を置き換えるための命令になっています。

後書き

注意事項として、まず文中でも述べましたが Turbo.FetchRequest は公式ドキュメントにありません。また、ここに記述した内容は私が Turbo のソースコードを探したり、ブラウザのコンソールで手を動かしてどんなメソッドやプロパティがあるか、手探りでの動作確認をしたまでで、どんな副作用が隠されているかなど、見えていない部分も多くあり、まったく動作の保証がありません。ご参考いただく場合は、そのことを承知のうえでお願いします。この記事が Turbo のソースコードを探すための手がかりやヒントになれば、さいわいに思います。

Discussion