はじめに
本記事は錆びかけたRailsの知識を頑張ってアップデートするアドベントカレンダー7日目です。
引き続き猫Rails様の猫でもわかるHotwire入門 Turbo編の一説に、以下のような文章がありました。
Turbo DriveはTurbolinksの名前を変えたもので、基本的な機能はTurbolinksと同じだよ。リンク、フォームのリクエストをTurbo Driveがインターセプトして、fetchによる非同期リクエストに差し替える。
ここを読んだ時、
「『インターセプト』ってどういうことだろう?」
と疑問に思いました。
そこで今回は、Turbo Driveによるリクエスト ~ レスポンスの流れと、実際のTurbo Driveのコードでどのようにそれが実現されているかについて見ていきます。
前提として「猫でもわかるHotwire入門」は非常にわかりやすく、悩むことなく実装を進められています。
その中でも「こう考えた方が理解しやすいかも?」と感じた部分を、後で読み返すためにまとめておきます。
併せて読んでいただくと役に立つ方もいるかもしれません。
結論
Turboのコードを読んでみたところ、Turbo DriveによるリクエストのインターセプトとはpreventDefault()
メソッドを使うということでした。
preventDefault()とは?
preventDefault()は、ブラウザ操作による動きをキャンセルするJavaScriptのメソッドです。
例えば、フォーム送信ボタンを押した時フォームからリクエストがされないようにしたり、リンクをクリックした時に遷移させないようにすることができます。preventDefault()メソッドは特定のライブラリに属しているのではなく、JavaScriptの標準Web API の一部としてブラウザに組み込まれています。
Turbo Driveによるリクエストのインターセプトは、具体的には以下のようなコードで行われています。
export class LinkClickObserver {
started = false
constructor(delegate, eventTarget) {
this.delegate = delegate
this.eventTarget = eventTarget
}
start() {
if (!this.started) {
this.eventTarget.addEventListener("click", this.clickCaptured, true)
this.started = true
}
}
// 省略
clickBubbled = (event) => {
if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
const target = (event.composedPath && event.composedPath()[0]) || event.target
const link = this.findLinkFromClickTarget(target)
if (link && doesNotTargetIFrame(link)) {
const location = this.getLocationForLink(link)
if (this.delegate.willFollowLinkToLocation(link, location, event)) {
event.preventDefault()
this.delegate.followedLinkToLocation(link, location)
}
}
}
}
// 省略
}
event.preventDefault()
によって一度リクエストがキャンセルされます。その後はTurboDriveを使って非同期リクエストするかどうかの判定などを行い、最終的に以下のメソッドが動きます。
// 省略
issueRequest() {
if (this.hasPreloadedResponse()) {
this.simulateRequest()
} else if (this.shouldIssueRequest() && !this.request) {
this.request = new FetchRequest(this, FetchMethod.get, this.location)
this.request.perform()
}
}
// 省略
最後に書かれているperform()
が、実際に非同期のリクエストを実行している箇所です。
perform()
以降の流れは以下です。
// 省略
async perform() {
const { fetchOptions } = this
this.delegate.prepareRequest(this)
await this.#allowRequestToBeIntercepted(fetchOptions)
try {
this.delegate.requestStarted(this)
const response = await fetch(this.url.href, fetchOptions)
return await this.receive(response)
} catch (error) {
if (error.name !== "AbortError") {
if (this.#willDelegateErrorHandling(error)) {
this.delegate.requestErrored(this, error)
}
throw error
}
} finally {
this.delegate.requestFinished(this)
}
}
async receive(response) {
const fetchResponse = new FetchResponse(response)
const event = dispatch("turbo:before-fetch-response", {
cancelable: true,
detail: { fetchResponse },
target: this.target
})
if (event.defaultPrevented) {
this.delegate.requestPreventedHandlingResponse(this, fetchResponse)
} else if (fetchResponse.succeeded) {
this.delegate.requestSucceededWithResponse(this, fetchResponse)
} else {
this.delegate.requestFailedWithResponse(this, fetchResponse)
}
return fetchResponse
}
// 省略
Turbo Driveを使う場合のリクエスト ~ レスポンスの流れ
Turbo Driveを使う場合のリクエストからレスポンスの流れは以下のようになっています。
リンクやフォームの送信のインターセプト
Ajaxリクエストの発行
サーバーからのレスポンス
ページの部分的な更新
リンクやフォームの送信のインターセプト
ユーザーがリンクをクリックするかフォームを送信すると、Turbo Driveはこの操作をインターセプトします。これは、JavaScriptがブラウザのデフォルトの動作(新しいページへの完全なリロード)を止めて、代わりにAjaxリクエストを送信することを意味します。
Ajaxリクエストの発行
次に、Turbo DriveはAjaxリクエストをサーバーに送信します。このリクエストは、ユーザーが要求したページのデータを含んでいます。
サーバーからのレスポンス
サーバーはこのリクエストを受け取り、必要なデータ(通常はHTMLコンテンツ)を含んだレスポンスを返します。
ページの部分的な更新
Turbo Driveはこのレスポンスを受け取り、ページの必要な部分だけを更新します。これは、ブラウザが新しいHTMLを現在のページに組み込むことを意味します。ページ全体のリロードは発生しません。
終わりに
今回はサーバへ非同期のリクエストを送信する箇所までコードを読めたので、引き続きサーバからのレスポンスを受け取った後についてもどのようにページを更新しているのか確認します。差分を見つけてそこだけ更新する、ということをやっているとすると結構難しそうですね。
疑問点
- TurboDriveを利用するにあたり、「こういう時はこういうレスポンスにしてください」という決まりはあるのだろうか。