AbortControllerとは?
AbortControllerはJavaScriptに組み込まれているAPIの一つで、何かを中断する処理の実装手段です。
特に非同期処理を中断するのに便利ですが、非同期処理以外でも使うことができます。
AbortControllerを使うには、中断したい処理(関数 / メソッド)がAbortControllerに対応している必要があります。
簡単に言うと、関数がAbortSignalを受け取れるなら対応してると思います。
使い方
AbortControllerオブジェクトはコンストラクタで作成できます。
const controller = new AbortController()
controller // AbortController
このcontrollerは以下のプロパティ / メソッドを持っています。
-
abort: 処理を中断する -
signal:abortで中断する処理を決める、AbortSignalのインスタンス
この記事では、AbortControllerのインスタンスのことをコントローラー、AbortSignalのインスタンスのことをシグナルと呼びます。
例えば以下のsampleAPI関数は、オプションとしてsignalを受け取ります。
この関数の処理は以下の手順で停止できます。
- オプションの
signalにコントローラーのsignalプロパティ(AbortSignal)を入れる - 同じコントローラーの
abortメソッドを呼び出す
コードはこのようになります。
// あらかじめコントローラーを作成する
const controller = new AbortController()
// sampleAPIを呼び出す
sampleAPI({ signal: controller.signal })
controller.abort() // 中断される
このcontroller.abortを呼び出すと、sampleAPI関数は内部で自動的に処理を中断してくれます。
fetch関数の例
fetch関数はHTTPリクエストを送る関数です。
そして、まさに上のsampleAPI関数のような引数を取ります。
この関数は以下の引数を取ります。
- リクエストしたいURL(文字列)
- オプションが入るオブジェクト
-
signal:AbortSignalのインスタンス - それ以外にもいろいろなオプションがある
-
AbortControllerを使わない場合の呼び出しは単純です。
fetchはプロミスを返すのでawaitし、第一引数にURLを入れます。
const response = await fetch('https://example.com')
resposne.header // Header { ... }
AbortControllerを使う
HTTPリクエストにはそれなりに時間やコストがかかります。
そのため何らかの理由でそのリクエストが不要になったら、早めに中断したいです。
そして、その中断する方法がAbortControllerです。
以下のようなコードで実行を中断できます。
const controller = new AbortController()
await fetch('https://example.com', { signal: controller.signal })
controller.abort() // リクエストを中断する
// キャンセルされたことを示す例外が発生する
fetch関数はキャンセルされた時に、AbortErrorという名前のDOMException例外を発生させます。
そのため実際に使う際は、try-catchなどでエラーを補足する必要があります。
関数を自作する
AbortSignalを受け取ることで、処理を中断できるようにする関数を自作することもできます。
AbortSignalとは
APIを作る上で欠かせないのがAbortSignalです。
AbortSignalは以下の特徴を持っています。
-
EventTargetを継承している - ↑なので、DOMでおなじみの
addEventListenerが使える - コントローラーで
abortを呼び出すとabortイベントが配信される
コントローラーで
abortを呼び出すとabortイベントが配信される
このため、どのように処理をキャンセルするか、もっと言うと abortイベントが配信されたときどんな処理をするかがキャンセルの実態になります。
function sampleAPI({ signal }) { // AbortSignalを受け取る
// メイン処理
// abortイベントをリッスンする
signal.addEventListener('abort', () => {
// ここでメイン処理をどう中断するかが重要
})
}
詳細はMDNにもあるので、併せてご覧ください。
もしAbortSignalに対応しているAPIをラップするだけなら、シグナルをそのまま渡せばOKです。
これはfetchのラップなどが含まれます。
async function wrap({ signal }) {
// fetchにシグナルを渡す
return await fetch('https://example.com', { signal })
}
作例: タイマー
ここでは、一定時間処理を待機するsleep関数を実装します。
この関数は以下の引数を取ります。
-
ms: 待つ時間、ミリ秒 -
{ signal }: 処理を中断するためのAbortSignal- ここでは一定時間待機するのを中断する
また、sleep関数は一定時間待ったら履行されるプロミスを返します。
このプロミスは処理が中断されても履行されます。
実装は例えばこのようになります。
function sleep(ms, { signal } = {}) {
return new Promise(resolve => {
const id = setTimeout(resolve, ms)
// abortされたらタイムアウトをキャンセル
signal?.addEventListener('abort', () => {
clearTimeout(id)
resolve() // プロミスは履行する
})
})
}
使用例:
// コントローラーを準備
const controller = new AbortController()
// 3000ms経ったらログを出力
sleep(3000, { signal: controller.signal })
.then(() => console.log('完了'))
// 300秒経ったらタイマーをキャンセル
await sleep(300) // このタイマーはキャンセルされない
controller.abort()
// 3000ms経たずに「完了」が出力される
実装方法
まず、キャンセル処理がないsleepを実装してみます。
これはsetTimeoutを使い、一定時間経ったらresolveが呼び出されるようになっています。
function sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}
// 使用例
await timer(300)
console.log('300ms経過') // 300ms後に出力される
そして、ここにキャンセル処理を書き足していきます。
実装方法は以下の通りです。
-
setTimeoutにはキャンセル方法としてclearTimeoutがある - プロミス内で
abortイベントをリッスンし、そこでclearTimeoutを呼ぶ
clearTimeout
clearTimeoutは引数にタイムアウトのIDを取ります。
このIDはsetTimeout関数が戻り値として返すので、これを保管する必要があります。
// ms経ったらfuncを実行する
const id = setTimeout(func, ms)
// タイマーをキャンセルする
clearTimeout(id) // funcは実行されなくなる
abortイベントをリッスンする
シグナルのabortイベントをリッスンし、配信されたら上述のclearTimeoutを実行します。
また、プロミスは履行しておきます。
signal?.addEventListener('abort', () => {
clearTimeout(id)
resolve() // プロミスは履行する
})
ここではオプショナルチェーン(?.)を使って、signalがあるときのみaddEventListenerを実行しています。
そして、ここまでの処理を組み合わせると、先ほどのような実装になります。
function sleep(ms, { signal } = {}) {
return new Promise(resolve => {
const id = setTimeout(resolve, ms)
// abortされたらタイムアウトをキャンセル
signal?.addEventListener('abort', () => {
clearTimeout(id)
resolve() // プロミスは履行する
})
})
}
ここではsetTimeoutの戻り値であるIDをidに入れ、abortイベントが配信されたらclearTimeout(id)を実行するようにしています。
これによって、呼び出し側からsleepの実行を中断できるようになっています。
中断後の動作は好きにカスタマイズできます。
ここではプロミスを履行し、その後のコードが即座に実行されるようにしていますが、反対にプロミスを拒否することも可能です。
return new Promise((resolve, reject) => {
// ...
signal?.addEventListener('abort', () => {
clearTimeout(id)
reject(new Error('cancel')) // プロミスを拒否する
})
}