【JavaScript】AbortControllerって何？〜キャンセル可能なタイマーを作ってみた〜

# AbortControllerとは？
[`AbortController`](https://developer.mozilla.org/ja/docs/Web/API/AbortController)はJavaScriptに組み込まれているAPIの一つで、**何かを中断する**処理の実装手段です。

特に**非同期処理を中断する**のに便利ですが、非同期処理以外でも使うことができます。

`AbortController`を使うには、中断したい処理（関数 / メソッド）が`AbortController`に対応している必要があります。
簡単に言うと、関数が`AbortSignal`を受け取れるなら対応してると思います。

## 使い方

`AbortController`オブジェクトはコンストラクタで作成できます。

```js
const controller = new AbortController()
controller // AbortController
```

この`controller`は以下のプロパティ / メソッドを持っています。

- `abort`: 処理を中断する
- `signal`: `abort`で中断する処理を決める、`AbortSignal`のインスタンス

:::note
この記事では、`AbortController`のインスタンスのことをコントローラー、`AbortSignal`のインスタンスのことをシグナルと呼びます。
:::

例えば以下の`sampleAPI`関数は、オプションとして`signal`を受け取ります。
この関数の処理は以下の手順で停止できます。

1. オプションの`signal`にコントローラーの`signal`プロパティ（`AbortSignal`）を入れる
1. 同じコントローラーの`abort`メソッドを呼び出す

コードはこのようになります。

```js
// あらかじめコントローラーを作成する
const controller = new AbortController()

// sampleAPIを呼び出す
sampleAPI({ signal: controller.signal })

controller.abort() // 中断される
```

この`controller.abort`を呼び出すと、`sampleAPI`関数は内部で自動的に処理を中断してくれます。

## `fetch`関数の例
[`fetch`関数](https://developer.mozilla.org/ja/docs/Web/API/Window/fetch)はHTTPリクエストを送る関数です。
そして、まさに上の`sampleAPI`関数のような引数を取ります。

この関数は以下の引数を取ります。

1. リクエストしたいURL（文字列）
1. オプションが入るオブジェクト
    - `signal`: `AbortSignal`のインスタンス
    - それ以外にもいろいろなオプションがある

`AbortController`を使わない場合の呼び出しは単純です。
`fetch`はプロミスを返すので`await`し、第一引数にURLを入れます。

```js
const response = await fetch('https://example.com')
resposne.header // Header { ... }
```

### `AbortController`を使う
HTTPリクエストにはそれなりに時間やコストがかかります。
そのため何らかの理由でそのリクエストが不要になったら、早めに**中断したい**です。
そして、その中断する方法が`AbortController`です。

以下のようなコードで実行を中断できます。

```js
const controller = new AbortController()
await fetch('https://example.com', { signal: controller.signal })

controller.abort() // リクエストを中断する

// キャンセルされたことを示す例外が発生する
```

::: note warn
`fetch`関数はキャンセルされた時に、`AbortError`という名前の[`DOMException`](https://developer.mozilla.org/ja/docs/Web/API/DOMException)例外を発生させます。
そのため実際に使う際は、[`try-catch`](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/try...catch)などでエラーを補足する必要があります。
:::

# 関数を自作する
`AbortSignal`を受け取ることで、処理を中断できるようにする関数を自作することもできます。

## `AbortSignal`とは
APIを作る上で欠かせないのが[`AbortSignal`](https://developer.mozilla.org/ja/docs/Web/API/AbortSignal)です。
`AbortSignal`は以下の特徴を持っています。

- `EventTarget`を継承している
- ↑なので、DOMでおなじみの[`addEventListener`](https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener)が使える
- コントローラーで`abort`を呼び出すと`abort`イベントが配信される

> コントローラーで`abort`を呼び出すと`abort`イベントが配信される

このため、どのように処理をキャンセルするか、もっと言うと **`abort`イベントが配信されたときどんな処理をするか**がキャンセルの実態になります。

```js
function sampleAPI({ signal }) { // AbortSignalを受け取る
  // メイン処理

  // abortイベントをリッスンする
  signal.addEventListener('abort', () => {
    // ここでメイン処理をどう中断するかが重要
  })
}
```

詳細は[MDN](https://developer.mozilla.org/ja/docs/Web/API/AbortSignal#中止可能な_api_の実装)にもあるので、併せてご覧ください。

:::note
もし`AbortSignal`に対応しているAPIをラップするだけなら、シグナルをそのまま渡せばOKです。

これは`fetch`のラップなどが含まれます。

```js
async function wrap({ signal }) {
  // fetchにシグナルを渡す
  return await fetch('https://example.com', { signal })
}
```
:::

## 作例: タイマー

ここでは、一定時間処理を待機する`sleep`関数を実装します。
この関数は以下の引数を取ります。

- `ms`: 待つ時間、ミリ秒
- `{ signal }`: 処理を中断するための`AbortSignal`
    - ここでは一定時間待機するのを中断する

また、`sleep`関数は一定時間待ったら履行されるプロミスを返します。
このプロミスは**処理が中断されても履行されます。**

実装は例えばこのようになります。

```js
function sleep(ms, { signal } = {}) {
  return new Promise(resolve => {
    const id = setTimeout(resolve, ms)

    // abortされたらタイムアウトをキャンセル
    signal?.addEventListener('abort', () => {
      clearTimeout(id)
      resolve() // プロミスは履行する
    })
  })
}
```

使用例:

```js
// コントローラーを準備
const controller = new AbortController()

// 3000ms経ったらログを出力
sleep(3000, { signal: controller.signal })
  .then(() => console.log('完了'))

// 300秒経ったらタイマーをキャンセル
await sleep(300) // このタイマーはキャンセルされない
controller.abort()
// 3000ms経たずに「完了」が出力される
```

## 実装方法
まず、キャンセル処理がない`sleep`を実装してみます。
これは[`setTimeout`](https://developer.mozilla.org/ja/docs/Web/API/Window/setTimeout)を使い、一定時間経ったら`resolve`が呼び出されるようになっています。

```js
function sleep(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms)
  })
}

// 使用例
await timer(300)
console.log('300ms経過') // 300ms後に出力される
```

そして、ここにキャンセル処理を書き足していきます。
実装方法は以下の通りです。

- `setTimeout`にはキャンセル方法として[`clearTimeout`](https://developer.mozilla.org/ja/docs/Web/API/Window/clearTimeout)がある
- プロミス内で`abort`イベントをリッスンし、そこで`clearTimeout`を呼ぶ

### `clearTimeout`
`clearTimeout`は引数にタイムアウトのIDを取ります。
このIDは`setTimeout`関数が戻り値として返すので、これを保管する必要があります。

```js
// ms経ったらfuncを実行する
const id = setTimeout(func, ms)

// タイマーをキャンセルする
clearTimeout(id) // funcは実行されなくなる
```

### `abort`イベントをリッスンする
シグナルの`abort`イベントをリッスンし、配信されたら上述の`clearTimeout`を実行します。
また、プロミスは履行しておきます。

```js
signal?.addEventListener('abort', () => {
  clearTimeout(id)
  resolve() // プロミスは履行する
})
```

ここでは[オプショナルチェーン](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Optional_chaining)（`?.`）を使って、`signal`があるときのみ`addEventListener`を実行しています。

---

そして、ここまでの処理を組み合わせると、先ほどのような実装になります。

```js
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`の実行を中断できるようになっています。

:::note
中断後の動作は好きにカスタマイズできます。

ここではプロミスを履行し、その後のコードが即座に実行されるようにしていますが、反対にプロミスを拒否することも可能です。

```js
return new Promise((resolve, reject) => {
  // ...
  signal?.addEventListener('abort', () => {
    clearTimeout(id)
    reject(new Error('cancel')) // プロミスを拒否する
  })
}
```
:::