BeAct Co., Ltd.

BLOG
社員ブログ

JavaScript 配列の「ハマりがちな落とし穴」と回避方法まとめ

JavaScript の配列は非常に柔軟で便利ですが、その自由さゆえに「思わぬ落とし穴」にハマることがあります。

「なんでこれ動かないの?」と思ったことはありませんか?

こんかいは、実務でよく遭遇する配列の落とし穴と、その原因・回避方法をセットで紹介します。


forEach() では await できない(罠)

👉await してるのに待たれない」現象

例として、一連の URL 文字列から、GET 通信で JSON データを 順番に 取得し、直列処理を行う関数を考えてみます。

/**
 * @param {string[]} urlList 対象のURLの配列
 */
function serialFetch(urlList) {
  urlList.forEach(async url => {
    const res = await fetch(url);
    const { data } = await res.json();
    // 以下、通信の完了と JSON パースを待って `data` を処理したい……が、できない!
  });
}

この .forEach() による実装。順番に処理されるかと思いきや、await で待たれることはありません。

なぜ起きる?

それは、.forEach() メソッドが以下の仕様を持つからです。

forEach() は同期関数を期待します。プロミスを待ちません。forEach のコールバックとしてプロミス(または非同期関数)を使用する場合は、その挙動を理解しておく必要があります。

async 関数は Promise を返しますが、.forEach() はそれを待ちません。そのため、処理はほぼ同時に実行され、順番も保証されません。

.forEach() 自体は同期処理ですが、コールバック内の非同期処理は待たれず、結果としてほぼ同時に開始される(並行的に進む)挙動になります。

解決策1:for…of や for await…of に置き換えてしまう

配列の走査メソッドを使わず、for...of に置き換えてしまうのが簡単です。

// `for...of` は、関数ではなく「構文」なので……
for (const url of urlList) {
  // 制約無く、 `await` できる!
  const res = await fetch(url);
  const { data } = await res.json();
  // 以下、通信の完了と JSON パースを待って `data` を処理できる。
}

また上記の例は、Promise の配列を順番に解決しながらループする for await...of だと、もっと短く書けます。urlList.map() メソッドで Promise の配列に変形しているところがミソです。

※この書き方では、.map() の時点で fetch() が並列に開始されますが、for await...of配列の順番通りに await するため、結果の処理順は保証されます(完了順ではありません)。

// `fetch()` が `Promise<Response>` を返すことを利用し、`.map()` で配列の要素を変形!
for await (const res of urlList.map(url => fetch(url))) {
  // もちろんこちらも、制約無く `await` できる!
  const { data } = await res.json();
  // 以下、通信の完了と JSON パースを待って `data` を処理できる。
}
どうしても「走査による直列処理」がしたい場合、専用関数を作るという選択肢もあります。

おまけ:専用の関数を作る

もしどうしても走査による直列処理をお望みであれば、別途、専用の関数を作ることをおすすめします(Array のプロトタイプにメソッドを生やすのではなく、対象の配列を引数として受け取る関数として作ることを強くおすすめします)。

/**
 * 配列を「順番で」await しながらループする関数
 *
 * @template T 配列の要素
 * @param {T[]} array 対象の配列
 * @param {(params: T, index?:number, target?:T[]) => Promise<void>} callback ループごとに行いたい処理。Array.forEach()とシグネチャを揃えた。
 * @returns {Promise<void>} 暗黙的に返されるプロミス。つまりこれ自体も `await` する前提。
 */
export async function asyncForEach(array, callback) {
  for (let index = 0; index < array.length; index++) {
    await callback(array[index], index, array);
  }
}

これを使うことで、こう書くことが可能となります。

/**
 * @param {string[]} urlList 対象のURLの配列
 */
async function serialFetch(urlList) {
  // この関数自体も `await` します。
  await asyncForEach(urlList, async url => {
    const res = await fetch(url);
    const { data } = await res.json();
    // 以下、通信の完了と JSON パースを待って `data` を処理できる。
  });
}

解決策2:並列処理でも良ければ、Promise.all() にする

処理の順番を気にしなくてもよい場合は、Promise.all() による並列処理にしてしまうのもアリです。

// 全ての Promise が成功するまで待つ。
await Promise.all(
  // `.map()` は、Promise を受け取れる。
  urlList.map(async url => {
    const res = await fetch(url);
    const { data } = await res.json();
    // 以下、通信の完了と JSON パースを待って `data` を処理できる。
  })
);

.map() で undefined を大量生産

👉 return を書き忘れると全部 undefined になる

これは配列そのものより => (アロー関数式)の見落としが原因ですが、やはりよくあるケースです。

// [2, 4, 6] としたい。
const result = [1, 2, 3].map(n => {
  n * 2;
});

console.log(result);
// [undefined, undefined, undefined]

なぜ起きる?

.map() は「値を変換する関数」なので、必ず値を返す必要があります。

アロー関数式は 引数 => 式 ならば、暗黙的に式の演算結果を返しますが、引数 => { 式 }中括弧でコードブロックを作ってしまう と、明示的な return が必要になります。

解決策

アロー関数式の使い方を復習し、正しく値を返すようにしましょう。

// 方法1:コードブロックを作らない
const result = [1, 2, 3].map(n => n * 2);

// 方法2:明示的に return する
const result = [1, 2, 3].map(n => {
  return n * 2;
});

配列が歯抜けになる(疎配列)

👉 要素が「ない」場合の対処法

配列を直接操作しているうちに、「添字で参照する要素が、なくなってしまった」(歯抜け状態)ということは珍しくありません。こうした配列のことを、疎配列 (Sparse arrays) と呼びます。

const sparseArray = [ 1, , , 4, , 6 ]; // 2, 3, 5 にあたる部分が「歯抜け」になっている!

しかし、歯抜けの要素に添字でアクセスすると、「undefined という要素である」と評価されてしまいます。

sparseArray[2]; // undefined

なぜ起きる?

疎配列では、その添字に値そのものが存在しません。こうした要素に添字でアクセスした場合は、 undefined を返すルールになっているためです。

ただし配列のメソッドを使う場合、(歴史的に)疎配列に対する挙動がまちまちです。「どんな場合でも undefined として処理される」わけではないことにご注意ください。


よくある事故1: 疎配列を作ったことに気づかない

Array(5).map(() => 1);
// (5) [ <空 × 5> ]

Array(5) は「要素のない配列(疎配列)」を作るため、.map() が実行されません。

解決策

// 方法1
Array.from({ length: 5 }, () => 1);

// 方法2
Array(5).fill(1);

よくある事故2:予期せぬ undefined

for (let i = 0; i < sparseArray.length; i++) {
  console.log(sparseArray[i]);
}
// undefined が混ざる

for (const element of sparseArray) {
  console.log(element);
}
// これも undefined が混ざる

解決策

// ?? を使ってフォールバック
for (let i = 0; i < sparseArray.length; i++) {
  console.log(sparseArray[i] ?? 0);
}
// あるいは、continue でループをスキップする
for (const element of sparseArray) {
  if (element === undefined) {
    continue; // 次のループに入る
  }
  console.log(element);
}

// 「存在する要素だけ」扱うメソッドでループさせる
sparseArray.forEach(element => {
  console.log(element);
});

破壊的メソッドと参照越しの破壊

👉「覚えがないのに配列が変わっている」現象

歴史的な経緯から、JavaScript では「配列自身を書き換えるメソッド( 破壊的メソッド )」が存在します(例: .splice(), .push(), .pop() など)。

これらの挙動は直感的ではあるのですが、例えば以下のような思わぬ波及を起こしてしまいます。


よくある事故1:うっかり自己破壊

const arr = [1, 2, 3];
// 添字 `1` の要素だけの、新しい配列が欲しいな。
const result = arr.splice(1, 1);

console.log(result); // [2] …よし、OK。
console.log(arr);    // [1, 3] …あれ、 `2` はどこに行った?!

なぜ起きる?

破壊的メソッドを使ったからです。自分自身を変えてしまうというのは、便利な半面とても危険なのです。

解決策1:既存の非破壊メソッドや、構文を駆使する

const arr = [1, 2, 3];
// .splice() の代わりに、これを使う。
const result = arr.filter((_, i) => i === 1);

console.log(result); // [2]
console.log(arr);    // [1, 2, 3] ちゃんと `2` が残ってる!

他にもこんな操作ができます。

const arr = [1, 2, 3];

const pushed = [...arr, 4, 5];      // .push(4, 5)
const unshifted = [-1, 0, ...arr];  // .unshift(-1, 0)

const popped = arr.slice(0, -1);    // .pop()
const shifted = arr.slice(1);       // .shift()

console.log(arr);    // [1, 2, 3] ちゃんと `2` が残ってる!

この「非破壊メソッドや、構文を駆使する」手法は、特に React では欠かせません。React 公式ページに、他の破壊的メソッドも含めた書き換え方法が掲載されているので、参考にしてみてください。

解決策2:新しい非破壊メソッドを使う

比較的新しい仕様で追加された、配列の「非破壊メソッド」で、新しい配列を得ましょう。古いブラウザにも対応させたい場合は、BabelTypeScript などを使ってトランスパイルするのも手です。

const arr = [1, 2, 3];
// 非破壊メソッド .toSpliced() を使う
const result = arr.toSpliced(1, 1);

console.log(result); // [2]
console.log(arr);    // [1, 2, 3] 

新しい非破壊メソッドでは、他にもこんな操作ができます。

const arr = [1, 2, 3];

const reversed = arr.toReversed();            // .reverse()
const sorted = arr.toSorted((a, b) => b - a); // .sort()

よくある事故2:参照越しに元の配列を破壊

// a は [1, 2] だ。
const a = [1, 2];
// b に a を「代入」しよう。
const b = a;

// b に .push() メソッドを発行しよう。
b.push(3);

// ……なんで a まで要素が追加されてるんだ?!
console.log(a); // [1, 2, 3]

なぜ起きる?

配列はオブジェクトなので、変数には「参照」が代入されます。

const a = [1, 2];
const b = a;

この時点で、ab参照越しに同じ配列を共有しているため、b を変更すると a も変わります。

解決策

まず配列のコピーを作って、そのコピーを代入しましょう。

次の例は、「浅い」コピーを作って、それを代入する例です。

// 方法1:わざと要素をスプレッドさせ、それを配列にしたものを代入する。
const b = [...a];

// 方法2:.slice() メソッドでコピーを作って渡す。
const b = a.slice();

また、その配列の内容が JSON 文字列にシリアライズ可能なデータ構造であると分かっている場合に限り、次の方法で「深い」コピーを作ることも可能です。

Dateundefined、関数、NaNInfinityMap / Set などは正しくコピーされないため注意。

// 「深い」コピー
const b = JSON.parse(JSON.stringify(a));

.reduce() で初期値を与えるのを忘れる

👉 初期値がないと、計算結果がめちゃくちゃになったり、エラーが出たりする

.reduce() メソッドは、配列の要素を先頭から順番に計算し、最終的に一つの値にまで「切り詰める(reduce)」働きをします。ここでいう「初期値」とは、.reduce((累積値, 要素) => 計算結果, 初期値) の、最後の引数のことです。

これを忘れてしまったら、どうなるでしょう?


よくある事故1:累積値と計算結果との不整合

ゲームで、複数のプレイヤーのスコアを合算するために、.reduce() を使うことを考えてみます。以下は、ここで初期値を省いたコードです。

// プレイヤーの名前と、獲得スコアの配列です。
const players = [
  { name: 'Alice', score: 1200 },
  { name: 'Bob', score: 10 },
  { name: '太郎', score: 5420 },
];

// 全プレイヤーのスコアを集計します(初期値を省いた)。
const sum = players.reduce(
  (acc, cur) => acc + (cur?.score ?? 0)
);

この時の sum の値は、6630 ではありません。"[object Object]105420" です。……これでは合算になりませんね。

なぜ起きる?

.reduce() に初期値を指定しない場合、最初の要素 がそのまま 累積値 として使われます。

ここでは、最初の要素はオブジェクトです。これに対し + 演算子が置かれると、オブジェクトは文字列に変換され、+ 演算子は算術演算の加算演算子ではなく、文字列連結演算子として働きます。その後に数値が続いたとしても、

"[object Object]" + 10

となり、文字列として連結されてしまうのです。

解決策

const sum = players.reduce(
  (acc, cur) => acc + (cur?.score ?? 0),
  0 // 初期値
);

初期値を与えることで、常に数値として計算されます。


よくある事故2:空配列でエラー

サーバ通信などで [] (空配列)を得てしまい、それに対して初期値を与えずに .reduce() する例です。

// サーバから全プレイヤーのデータを受け取ったが、空配列だった…という想定。
const players = [];
const sum = players.reduce(
  (acc, cur) => acc + (cur?.score ?? 0)
);

これが実行されてしまうと、

TypeError: Reduce of empty array with no initial value

というエラーになります。

なぜ起きる?

.reduce() に初期値を指定しない場合、最初の要素 がそのまま 累積値 として使われます。

空配列には「最初の要素」なるものは存在しないので、エラーとなります。

解決策

初期値を与えてください。

const players = [];
// `0` を与えると、それが最初の累積値になるので、足し算が成立する。
const sum = players.reduce(
  (acc, cur) => acc + (cur?.score ?? 0),
  0 // 初期値
);

まとめ

これらの落とし穴はすべて「JavaScript の仕様通り」の挙動です。

つまり、 使う側が、「仕様を誤解している」 ことが原因です。

配列を安全に扱うためには、

  • 同期か非同期か
  • 値か参照か
  • 要素が存在するか

といったポイントを意識することが重要です。

こんかいのお話は、以上です。それでは、よきコーディングライフを!