JavaScript Tips | 配列ユーティリティ:非同期 map

JavaScript JavaScript
スポンサーリンク

何をしたいユーティリティか:「非同期 map」

「非同期 map」は、配列の各要素に対して「async な処理」をして、その結果を配列として集めるユーティリティです。
普通の map は同期処理専用ですが、業務ではこういうケースがよく出てきます。

API を配列分だけ叩いて、そのレスポンスを配列で受け取りたい。
ファイルや DB を順番に読み書きして、その結果を配列にしたい。

ここで大事なのは、「全部並列でやっていいのか」「順番にやりたいのか」「同時実行数を制限したいのか」を、ユーティリティとして決めておくことです。


基本形:全部並列で実行する asyncMap(Promise.all)

実装と考え方

まずは「全部並列で実行して、全部終わったら結果を返す」一番シンプルな形です。

async function asyncMap(array, asyncMapper) {
  if (!Array.isArray(array)) {
    return [];
  }
  if (typeof asyncMapper !== "function") {
    return array.slice();
  }

  const promises = array.map((item, index) => asyncMapper(item, index));
  return Promise.all(promises);
}
JavaScript

重要ポイントをかみ砕きます。

配列じゃなければ空配列を返す。
asyncMapper は「非同期な変換関数」(async 関数 or Promise を返す関数)。
まず普通の map で「Promise の配列」を作る。
Promise.all で「全部の Promise が終わるのを待って、結果を配列で受け取る」。

つまり、「同期 map の非同期版」を素直に書いた形です。

例題:ユーザー ID の配列から、API でユーザー情報を取得する

async function fetchUser(id) {
  // 実際には fetch などで API を叩く想定
  return { id, name: `User-${id}` };
}

async function main() {
  const ids = [1, 2, 3];

  const users = await asyncMap(ids, (id) => fetchUser(id));

  console.log(users);
  // [
  //   { id: 1, name: "User-1" },
  //   { id: 2, name: "User-2" },
  //   { id: 3, name: "User-3" },
  // ]
}

main();
JavaScript

asyncMap のおかげで、「ID 配列 → ユーザー情報配列」という流れが、同期 map とほぼ同じ感覚で書けます。


順番に実行する asyncMapSeries(直列版)

なぜ直列版が必要になるのか

全部並列で実行すると速いですが、業務ではこういう制約もあります。

API のレート制限が厳しいので、一気に叩きたくない。
DB やファイルへのアクセスを順番にやりたい。

その場合は、「1 件ずつ await してから次へ進む」直列版が欲しくなります。

実装

async function asyncMapSeries(array, asyncMapper) {
  if (!Array.isArray(array)) {
    return [];
  }
  if (typeof asyncMapper !== "function") {
    return array.slice();
  }

  const result = [];
  for (let i = 0; i < array.length; i++) {
    const value = await asyncMapper(array[i], i);
    result.push(value);
  }
  return result;
}
JavaScript

ここでは for ループを使って、1 要素ごとに await してから次へ進むようにしています。
これで、「必ず順番に実行される非同期 map」が手に入ります。

例題:ログを順番に書き込む

async function writeLog(line) {
  // 実際にはファイルや外部サービスに書き込む想定
  console.log("write:", line);
}

async function main() {
  const lines = ["A", "B", "C"];

  await asyncMapSeries(lines, (line) => writeLog(line));

  console.log("done");
}

main();
JavaScript

asyncMapSeries を使うことで、「A → B → C の順に書き込む」という順序が保証されます。


同時実行数を制限する asyncMapLimit(業務でかなり実用的)

「全部並列は怖い、でも完全直列は遅い」をどう解決するか

API を 1000 件分叩くときに、全部並列で投げるのは危険です。
一方で、完全に 1 件ずつ直列でやると時間がかかりすぎることもあります。

そこで、「同時に動かす数を制限する」ユーティリティがあると便利です。

実装イメージ

async function asyncMapLimit(array, asyncMapper, limit = 5) {
  if (!Array.isArray(array)) {
    return [];
  }
  if (typeof asyncMapper !== "function") {
    return array.slice();
  }
  if (typeof limit !== "number" || limit <= 0) {
    limit = 1;
  }

  const result = new Array(array.length);
  let currentIndex = 0;

  async function worker() {
    while (currentIndex < array.length) {
      const index = currentIndex;
      currentIndex += 1;

      const value = await asyncMapper(array[index], index);
      result[index] = value;
    }
  }

  const workers = [];
  const workerCount = Math.min(limit, array.length);
  for (let i = 0; i < workerCount; i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
  return result;
}
JavaScript

重要ポイントをかみ砕きます。

limit は「同時に動かす最大数」。
worker という「仕事を取りに行く非同期関数」を複数立ち上げる。
currentIndex を共有して、「まだ処理していないインデックス」を取り合う。
各 worker は「仕事がなくなるまでループ」して処理する。
最後に Promise.all(workers) で、全部の worker が終わるのを待つ。

これで、「最大 limit 個まで並列で動く非同期 map」が実現できます。

例題:API を最大 3 並列で叩く

async function fetchUser(id) {
  console.log("start", id);
  await new Promise((r) => setTimeout(r, 500));
  console.log("end", id);
  return { id, name: `User-${id}` };
}

async function main() {
  const ids = [1, 2, 3, 4, 5, 6];

  const users = await asyncMapLimit(ids, (id) => fetchUser(id), 3);

  console.log(users);
}

main();
JavaScript

ログを見てみると、「常に最大 3 件まで同時に動いている」ことが分かるはずです。
これが、レート制限や負荷を意識した“業務レベルの非同期 map”です。


非同期 map を使うときの重要な意識ポイント

「戻り値は Promise(async 関数)になる」

asyncMap / asyncMapSeries / asyncMapLimit は、どれも async function です。
つまり、戻り値は必ず Promise になります。

使う側は必ず await するか、.then(...) で受け取る必要があります。

const result = asyncMap(ids, fetchUser); // これは Promise
const users  = await asyncMap(ids, fetchUser); // これで配列になる
JavaScript

ここを忘れると、「配列だと思っていたら Promise だった」という典型的なハマり方をします。

「エラーはどう扱うか」を決める

Promise.all は、1 つでも reject すると全体が reject します。
業務によっては、「失敗したものだけスキップしたい」「成功したものだけ集めたい」などの要件も出てきます。

その場合は、asyncMapper の中で try/catch して「失敗時は null を返す」など、
「エラーをどう扱うか」をユーティリティのルールとして決めておくとよいです。


手を動かして非同期 map の感覚をつかむ

次のコードをコンソール(Node など)で実行して、挙動を自分の目で確認してみてください。

async function asyncMap(array, asyncMapper) {
  if (!Array.isArray(array)) return [];
  if (typeof asyncMapper !== "function") return array.slice();
  const promises = array.map((item, index) => asyncMapper(item, index));
  return Promise.all(promises);
}

async function asyncMapSeries(array, asyncMapper) {
  if (!Array.isArray(array)) return [];
  if (typeof asyncMapper !== "function") return array.slice();
  const result = [];
  for (let i = 0; i < array.length; i++) {
    const value = await asyncMapper(array[i], i);
    result.push(value);
  }
  return result;
}

async function asyncMapLimit(array, asyncMapper, limit = 2) {
  if (!Array.isArray(array)) return [];
  if (typeof asyncMapper !== "function") return array.slice();
  if (typeof limit !== "number" || limit <= 0) limit = 1;

  const result = new Array(array.length);
  let currentIndex = 0;

  async function worker() {
    while (currentIndex < array.length) {
      const index = currentIndex;
      currentIndex += 1;
      const value = await asyncMapper(array[index], index);
      result[index] = value;
    }
  }

  const workers = [];
  const workerCount = Math.min(limit, array.length);
  for (let i = 0; i < workerCount; i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
  return result;
}

async function demo() {
  const ids = [1, 2, 3, 4];

  console.log("=== asyncMap (parallel) ===");
  await asyncMap(ids, async (id) => {
    console.log("start", id);
    await new Promise((r) => setTimeout(r, 300));
    console.log("end", id);
    return id * 10;
  });

  console.log("=== asyncMapSeries (series) ===");
  await asyncMapSeries(ids, async (id) => {
    console.log("start", id);
    await new Promise((r) => setTimeout(r, 300));
    console.log("end", id);
    return id * 10;
  });

  console.log("=== asyncMapLimit (limit=2) ===");
  await asyncMapLimit(ids, async (id) => {
    console.log("start", id);
    await new Promise((r) => setTimeout(r, 300));
    console.log("end", id);
    return id * 10;
  }, 2);
}

demo();
JavaScript

並列・直列・同時実行数制限の違いが、ログの出方で体感できるはずです。


まとめ:非同期 map ユーティリティで「配列 × async」を標準化する

業務コードでは、「配列の各要素に対して非同期処理をする」場面が本当に多いです。
そのたびに Promise.all や for ループをベタ書きするのではなく、

export async function asyncMap(...) { ... }        // 全並列
export async function asyncMapSeries(...) { ... }  // 直列
export async function asyncMapLimit(...) { ... }   // 同時実行数制限
JavaScript

のようなユーティリティを用意して、「配列 × async は必ずこれを通す」と決めておくと、
コードの意図が一気に読みやすくなります。

「速さを優先するのか」「順番を守るのか」「負荷を抑えるのか」――
その選択を関数名に埋め込んでおくことが、非同期処理を“業務レベル”で扱ううえでの大事な設計になります。

タイトルとURLをコピーしました