3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数の 非同期イテレータを 発生順に イテレートしたい場合

Last updated at Posted at 2024-11-21

課題

例えば 非同期イテレータを返すメソッド loop1() があったとします。

async function* loop1({ signal } = {}) {
  let num = 0;
  const milliseconds = 2000;
  while (!(signal?.aborted ?? false)) {
    await timeout({ signal, milliseconds });
    yield `loop1:${num++}:${milliseconds}ms`;
  }
}

例えば loop1() とはまた違ったタイミングの 非同期イテレータを返すメソッド loop2() があったとします。

async function* loop2({ signal } = {}) {
  let num = 0;
  let milliseconds = 1000;
  const add = 100;
  while (!(signal?.aborted ?? false)) {
    await timeout({ signal, milliseconds });
    yield `loop2:${num++}:${milliseconds}ms`;
    milliseconds += add;
  }
}

さて、どうやって タイミング 発生順に イテレートさせたらいいでしょうか?

というのが今回の問題。

回答のメソッドは一旦 iterator() とでもしておきます。

例えばこんなコードが完成形としておきましょう

// #region entry point
try {
  // #region cancellation
  const controller = new AbortController();
  const signal = controller.signal;

  /** @type {HTMLButtonElement} */
  const cancel = document.getElementById("cancel");
  cancel.addEventListener("click", (e) => {
    controller.abort();
  });
  // #endregion

  const format = Intl.DateTimeFormat("ja-jp", {
    dateStyle: "short",
    timeStyle: "long",
  });
  for await (const [v, tag] of iterator({ signal })) {
    output(`${format.format(new Date())}: ${v}`, tag);
  }
} catch (e) {
  output(`${e?.message ?? e}`, "error");
}
// #endregion

// #region utility
/**
 * @param {{
 *   milliseconds?: number;
 *   signal?: AbortSignal;
 * }} param0
 */
function timeout({ milliseconds, signal } = {}) {
  const { promise, resolve, reject } = Promise.withResolvers();
  let clear;
  if (signal) {
    if (signal.aborted) {
      abort();
      return;
    }
    signal.addEventListener("abort", abort);
    promise.finally(() => signal.removeEventListener("abort", abort));
    function abort() {
      if (typeof clear === "number") clearTimeout(clear);
      reject(signal.reason);
    }
  }
  clear = setTimeout(resolve, milliseconds);
  return promise;
}
function output(message, ...classes) {
  const node = template.content.cloneNode(true);
  const $row = node.querySelector("*");
  $row.innerText = `${message}`;
  if (classes.length > 0) $row.classList.add(...classes);
  target.insertAdjacentElement("afterbegin", $row);
  target.querySelectorAll(":scope > :nth-child(1n + 100 of :not(template))").forEach(e => e.remove());
}
// #endregion

私の手法

私の回答としては次の方法です。(他にいい方法があったら記事にしてリンクしていただけるととてもありがたい。)

javascript に於いては UI スレッドは シングルスレッドです。
ということは つまり FIして 完了したものを上から順に削除していけばいいのです。

ということで 非同期状態を 管理するのが Promise.withResolvers() では足りないので 完了状態のプロパティ done をつけた makeResolver() を作成します。

/**
 * @template {any} T
 * @returns {{
 *   promise: Promise<T>;
 *   resolve: (v:T) => void;
 *   reject: (v:any) => void;
 *   done: boolean;
 * }}
 */
function makeResolvers() {
  const { promise, resolve, reject } = Promise.withResolvers();
  const resolvers = {
    promise,
    resolve,
    reject,
    done: false
  };
  promise.finally(() => (resolvers.done = true));
  return resolvers;
}

そしてそれを 配列に追加して 先頭側のまだ未完了に設定していく iterate() を 追加します。

/**
 * @param {() => AsyncIterator} getIterator イテレータ取得関数
 * @param {any} tag 識別用タグ
 * @param {ReturnType<typeof makeResolvers>[]} resolvers 登録先リスト
 */
async function iterate(getIterator, tag, resolvers) {
  try {
    resolvers.push(makeResolvers());
    for await (const v of getIterator()) {
      resolvers.find((v) => !v.done).resolve([v, tag]);
      resolvers.push(makeResolvers());
    }
  } catch (e) {
    resolvers.find((v) => !v.done)?.reject(e);
  }
}

それらを使った iterater() はこうなります。

/**
 * @param {{
 *   signal?:AbortSignal
 * }}
 */
async function* iterator({ signal: parentSignal } = {}) {
  const controller = new AbortController();
  const signal = AbortSignal.any([
    controller.signal,
    ...(parentSignal ? [parentSignal] : [])
  ]);
  try {
    /** @type {ReturnType<typeof makeResolvers[]} */
    const resolvers = [];
    iterate(() => loop1({ signal }), "loop1", resolvers);
    iterate(() => loop2({ signal }), "loop2", resolvers);
    while (resolvers.length) {
      yield await resolvers[0].promise;
      resolvers.shift();
    }
  } finally {
    controller.abort();
  }
}

動作サンプル

See the Pen Untitled by juner clarinet (@juner) on CodePen.

以上。

3
1
13

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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?