LoginSignup
5
2

More than 3 years have passed since last update.

【TypeScript】setTimeout 関数を、キャンセル可能な Promise を返す関数にする

Last updated at Posted at 2020-04-28

setTimeout 関数 はコールバック方式であり使い勝手が悪いので
これを Promise 化する。
キャンセルできるようにもする。

準備:型定義

Promise オブジェクトとキャンセル関数を次のようなオブジェクトにまとめて返してもよいが、

{
    promise: Promise,
    cancel: () => void,
}

これだと使う際にひと手間かかる。

そこで、Promisecancel() メソッドを追加した CancellablePromise 型を定義し、
その型のオブジェクトを返すことにする。
こうすれば、呼び出し側でキャンセルを使わない場合には単なる Promise として扱うことができる。

/**
 * キャンセル可能なオブジェクトのインターフェイス。
 */
interface Cancellable {
  cancel(): void;
}

/**
 * キャンセル可能な Promise。
 * 
 * キャンセルするためのメソッド cancel() を持つ。
 * cancel() を呼び出すと Promise が reject される。
 * このときの reason には Cancelled クラスのインスタンスが渡される。
 */
type CancellablePromise<T> = Promise<T> & Cancellable;

オブジェクトに cancel メソッドを追加する際にはどうしても any 型へのキャストが必要になる。
それを隠蔽する関数を定義する。

/**
 * 指定されたオブジェクトに cancel メソッドを追加して Cancellable にする。
 * 
 * @param base Cancellable にするオブジェクト。
 * @param cancel cancel メソッドとして base のメンバに追加する関数。
 */
function toCancellable<T>(
  base: T,
  cancel: () => void
): T & Cancellable {
  const baseAsAny = base as any;
  baseAsAny.cancel = cancel;
  return baseAsAny;
}

CancellablePromise がキャンセルされたときには、キャンセルされずにタイムアウトしたときと区別できるよう、reject することにしたい。
キャンセルによる reject は、それ以外の理由による reject と区別できるようにしたい。

そのため、キャンセルによる reject での reason として使うクラスを定義する。

/**
 * CancellablePromise がキャンセルされたときに
 * reject の reason として渡されるオブジェクトのクラス。
 */
class CancellablePromiseCancelled { }

関数実装

/**
 * 指定された時間の後に resolve される、キャンセル可能な Promise を生成して返す。
 * 
 * @param delay 返した Promise が resolve されるまでの時間[ミリ秒]。
 */
function timeoutAsync(delay: number): CancellablePromise<void> {
  // setTimeout 関数が返した ID。
  // 指定時間が経過するかキャンセルされたら null。
  let timeoutID: number | null = null;
  let rejectPromise: (reason: CancellablePromiseCancelled) => void
    = (_) => { }; // non-nullable 型にするため、ダミーの null オブジェクトで初期化する。

  const promise = new Promise<void>((resolve, reject) => {
    timeoutID = setTimeout(() => {
      timeoutID = null;

      resolve();
    }, delay);
    rejectPromise = reject;
  });

  const cancel = () => {
    if (timeoutID !== null) {
      clearTimeout(timeoutID);
      timeoutID = null;

      rejectPromise(new CancellablePromiseCancelled());
    }
  };

  return toCancellable(promise, cancel);
}

動作確認

キャンセルなし

(async () => {
    const beginAt = performance.now();
    await timeoutAsync(1_000);
    const endAt = performance.now();
    console.log(`${endAt - beginAt}`); // 約 1,000 が出力される。
})();

約 1,000 の数値が出力されることから、正常に動作していることが確認できる。

キャンセルあり

(async () => {
    // 2秒後に resolve される Promise を生成する。
    const promise = timeoutAsync(2_000);

    // 1秒経過した時点でキャンセルする。
    (async () => {
        await timeoutAsync(1_000);
        promise.cancel();
    })();

    try {
        await promise;
    } catch (e) {
        if (e instanceof CancellablePromiseCancelled) {
            console.log("promise はキャンセルされました。"); // > promise はキャンセルされました。
        }
    }
})();

2秒後に resolve される Promise を生成し、1秒経過した時点でそれをキャンセルしている。
キャンセルされた Promise は、それを await した際に CancellablePromiseCancelled オブジェクトをスローする。

promise はキャンセルされました。 と出力されることから、正常に動作していることが確認できる。

補足

CancellablePromiseCancelled クラスに、どの関数で生成した CancellablePromise がキャンセルされたのかなどが分かるよう情報を追加してもよいだろう。
派生クラスで追加してもよい。

/以上

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2