12k
All articles

AbortControllerを使用したフライト中のFetchリクエストのキャンセル

AbortControllerとAbortSignalを使い、検索入力・アンマウント・タイムアウト時のfetchリクエストをキャンセルして古いデータを防ぐ方法を解説する。

OpenReplay Team
OpenReplay Team
AbortControllerを使用したフライト中のFetchリクエストのキャンセル

現代のWebアプリケーションでは、ユーザーが完了する必要のないHTTPリクエストが頻繁に発生します。検索ボックスに入力するユーザーは複数のリクエストを生成しますが、重要なのは最新のもののみです。ページから離れるユーザーにとって、そのページの保留中のリクエストは無関係になります。適切なキャンセル処理がなければ、これらの不要なリクエストは帯域幅を無駄にし、サーバーリソースを消費し、UIに古いデータが表示される原因となります。

AbortController APIは、fetchリクエストやその他の非同期操作をキャンセルするための、クリーンで標準化された方法を提供します。この記事では、AbortControllerを使用したリクエストキャンセルの実装方法を示し、検索のデバウンス、コンポーネントのクリーンアップ、タイムアウト処理などの実用的なパターンをカバーします。

重要なポイント

  • AbortControllerは、fetchがキャンセルイベントを監視するシグナルを作成します
  • キャンセルと失敗を区別するため、catchブロックでは常にAbortErrorをチェックしてください
  • 検索インターフェースでは、レースコンディションを防ぐために前のリクエストをキャンセルしてください
  • フレームワークコンポーネントでクリーンアップ関数を使用し、アンマウント時にリクエストをキャンセルしてください
  • AbortControllerとsetTimeoutを組み合わせて、リクエストタイムアウト機能を実現してください
  • 各AbortControllerは単一使用です - リトライロジックには新しいものを作成してください
  • Node.js 18+にはネイティブAbortControllerサポートが含まれています

AbortControllerとAbortSignalの役割

AbortControllerは、関連するAbortSignalを通じてキャンセルを管理するコントローラーオブジェクトを作成します。コントローラーには一つの仕事があります:操作をキャンセルしたいときにabort()を呼び出すことです。シグナルは、fetchやその他のAPIがキャンセルイベントを監視する通信チャネルとして機能します。

const controller = new AbortController()
const signal = controller.signal

// シグナルは最初は中止されていない状態
console.log(signal.aborted) // false

// abort()を呼び出すとシグナルの状態が変わる
controller.abort()
console.log(signal.aborted) // true

シグナルをfetchに渡し、後でabort()を呼び出すと、fetchのPromiseはAbortErrorという名前のDOMExceptionで拒否されます。これにより、キャンセルと実際のネットワーク障害を区別できます。

基本的なAbortControllerの実装

fetchリクエストをキャンセルするには、AbortControllerを作成し、そのシグナルをfetchに渡し、必要に応じてabort()を呼び出します:

const controller = new AbortController()

fetch('/api/data', { signal: controller.signal })
  .then(response => response.json())
  .then(data => console.log('Data received:', data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request was cancelled')
    } else {
      console.error('Request failed:', error)
    }
  })

// リクエストをキャンセル
controller.abort()

重要なポイント:

  • リクエストを行う前にコントローラーを作成する
  • fetchオプションにcontroller.signalを渡す
  • キャンセルするためにcontroller.abort()を呼び出す
  • catchブロックでAbortErrorをチェックする

ユーザーが入力する際の検索リクエストのキャンセル方法

検索インターフェースは、キーストロークごとにリクエストをトリガーすることがよくあります。キャンセル処理がなければ、遅いレスポンスが順序外で到着し、古いクエリの結果が表示される可能性があります。以下は、前の検索リクエストをキャンセルする方法です:

let searchController = null

function performSearch(query) {
  // 既存の検索をキャンセル
  if (searchController) {
    searchController.abort()
  }

  // この検索用の新しいコントローラーを作成
  searchController = new AbortController()

  fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: searchController.signal
  })
    .then(response => response.json())
    .then(results => {
      console.log('Search results:', results)
      updateSearchUI(results)
    })
    .catch(error => {
      if (error.name === 'AbortError') {
        console.log('Search cancelled')
      } else {
        console.error('Search failed:', error)
        showSearchError()
      }
    })
}

// 使用例:新しい検索は前の検索をキャンセルする
performSearch('javascript')
performSearch('javascript frameworks') // 'javascript'検索をキャンセル

このパターンにより、最新の検索結果のみがUIに表示され、遅いリクエストが速いリクエストを上書きするレースコンディションが防がれます。

コンポーネントのアンマウント時のリクエストキャンセル

フロントエンドフレームワークでは、コンポーネントがアンマウントされるときにキャンセルされるべきfetchリクエストを行うことがよくあります。これにより、メモリリークやアンマウントされたコンポーネントを更新しようとするエラーが防がれます。

React例

import { useEffect, useState } from 'react'

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const controller = new AbortController()

    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`)
        }
        
        const userData = await response.json()
        setUser(userData)
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Failed to fetch user:', error)
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false)
        }
      }
    }

    fetchUser()

    // クリーンアップ関数がリクエストをキャンセル
    return () => controller.abort()
  }, [userId])

  if (loading) return <div>Loading...</div>
  return <div>{user?.name}</div>
}

Vue例

export default {
  data() {
    return {
      user: null,
      controller: null
    }
  },
  async mounted() {
    this.controller = new AbortController()
    
    try {
      const response = await fetch(`/api/users/${this.userId}`, {
        signal: this.controller.signal
      })
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      
      this.user = await response.json()
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('Failed to fetch user:', error)
      }
    }
  },
  beforeUnmount() {
    if (this.controller) {
      this.controller.abort()
    }
  }
}

AbortControllerを使用したリクエストタイムアウトの実装

ネットワークリクエストは無期限にハングする可能性があります。AbortControllerとsetTimeoutを組み合わせることで、クリーンなタイムアウトメカニズムが提供されます:

function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const controller = new AbortController()
  
  const timeoutId = setTimeout(() => {
    controller.abort()
  }, timeoutMs)

  return fetch(url, {
    ...options,
    signal: controller.signal
  }).finally(() => {
    clearTimeout(timeoutId)
  })
}

// 使用例
fetchWithTimeout('/api/slow-endpoint', {}, 3000)
  .then(response => response.json())
  .then(data => console.log('Data:', data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request timed out')
    } else {
      console.error('Request failed:', error)
    }
  })

再利用可能なタイムアウトロジックには、ユーティリティ関数を作成します:

function createTimeoutSignal(timeoutMs) {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
  
  // シグナルが使用されたときにタイムアウトをクリーンアップ
  controller.signal.addEventListener('abort', () => {
    clearTimeout(timeoutId)
  }, { once: true })
  
  return controller.signal
}

// 使用例
fetch('/api/data', { signal: createTimeoutSignal(5000) })
  .then(response => response.json())
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request timed out or was cancelled')
    }
  })

キャンセルされたリクエストの適切なエラーハンドリング

常にキャンセルと実際のエラーを区別してください。AbortErrorは意図的なキャンセルを示し、失敗ではありません:

async function handleRequest(url) {
  const controller = new AbortController()
  
  try {
    const response = await fetch(url, { signal: controller.signal })
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
    }
    
    return await response.json()
  } catch (error) {
    if (error.name === 'AbortError') {
      // これはキャンセル時に期待される - エラーとしてログに記録しない
      console.log('Request was cancelled')
      return null
    }
    
    // これは処理が必要な実際のエラー
    console.error('Request failed:', error)
    throw error
  }
}

エラートラッキングを使用するアプリケーションでは、AbortErrorをエラーレポートから除外します:

.catch(error => {
  if (error.name === 'AbortError') {
    // キャンセルをエラートラッキングに報告しない
    return
  }
  
  // 実際のエラーを報告
  errorTracker.captureException(error)
  throw error
})

複数リクエストの管理

複数の同時リクエストを扱う場合、すべてを一度にキャンセルするか、個別に管理する必要があるかもしれません:

class RequestManager {
  constructor() {
    this.controllers = new Map()
  }
  
  async fetch(key, url, options = {}) {
    // 同じキーの既存リクエストをキャンセル
    this.cancel(key)
    
    const controller = new AbortController()
    this.controllers.set(key, controller)
    
    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      })
      return response
    } finally {
      this.controllers.delete(key)
    }
  }
  
  cancel(key) {
    const controller = this.controllers.get(key)
    if (controller) {
      controller.abort()
      this.controllers.delete(key)
    }
  }
  
  cancelAll() {
    for (const controller of this.controllers.values()) {
      controller.abort()
    }
    this.controllers.clear()
  }
}

// 使用例
const requestManager = new RequestManager()

// これらのリクエストは独立して管理できる
requestManager.fetch('user-profile', '/api/user/123')
requestManager.fetch('user-posts', '/api/user/123/posts')

// 特定のリクエストをキャンセル
requestManager.cancel('user-profile')

// すべての保留中リクエストをキャンセル
requestManager.cancelAll()

ブラウザサポートとNode.js互換性

AbortControllerは優れたブラウザサポートを持っています:

  • Chrome 66+
  • Firefox 57+
  • Safari 12.1+
  • Edge 16+

Node.jsでは、AbortControllerはNode 18+でネイティブに利用可能です。以前のバージョンでは、abort-controllerポリフィルを使用してください:

npm install abort-controller
// Node.js < 18の場合
const { AbortController } = require('abort-controller')

// 通常通り使用
const controller = new AbortController()

結論

AbortControllerは、現代のWebアプリケーションでfetchリクエストをキャンセルするためのクリーンで標準化された方法を提供します。重要なパターンは:各リクエストの前にコントローラーを作成し、そのシグナルをfetchに渡し、キャンセルが必要なときにabort()を呼び出すことです。意図的なキャンセルをエラーとして扱わないよう、AbortErrorを実際のネットワーク障害とは別に処理してください。

最も一般的な使用例である検索リクエスト管理、コンポーネントクリーンアップ、タイムアウト処理は、特定のニーズに合わせて適応できる直感的なパターンに従います。適切な実装により、リクエストキャンセルは不要なネットワーク活動と古いデータ更新を防ぐことで、ユーザーエクスペリエンスとアプリケーションパフォーマンスの両方を向上させます。

アプリケーションでリクエストキャンセルを実装する準備はできましたか?最も一般的な使用例の基本パターンから始めて、必要に応じてより複雑なシナリオに拡張してください。適切なリクエスト管理への投資は、アプリケーションパフォーマンスとユーザーエクスペリエンスで大きな利益をもたらします。

よくある質問

abort()を呼び出した後、AbortControllerを再利用できますか?

いいえ、abort()が呼び出されると、コントローラーのシグナルは永続的に中止状態のままになります。新しいリクエストやリトライの試行ごとに新しいAbortControllerを作成する必要があります。

abort()を呼び出すと、ネットワークリクエストがサーバーに到達するのを停止しますか?

いいえ、abort()はJavaScriptコードがレスポンスを処理することのみを防ぎます。リクエストがすでにネットワーク経由で送信されている場合、サーバーはそれを受信して処理する可能性があります。

複数のfetchリクエストを一度にキャンセルするにはどうしますか?

複数のコントローラーを作成して配列やMapに保存し、それぞれでabort()を呼び出します。または、単一のコントローラーを使用してそのシグナルを複数のfetch呼び出しに渡す - 一度abort()を呼び出すとすべてがキャンセルされます。

fetchがすでに完了した後にabort()を呼び出すとどうなりますか?

何も起こりません。リクエストがすでに正常に完了しているか、他の理由で失敗している場合、abort()呼び出しは無視されます。

AbortControllerはasync/await構文で使用できますか?

はい、AbortControllerはPromiseチェーンとasync/awaitの両方で同じように動作します。キャンセルされると、fetchのPromiseはAbortErrorで拒否され、try/catchブロックでキャッチできます。

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers

We use cookies to improve your experience. By using our site, you accept cookies.