🚗

JavaScriptの配列周りの処理ってどうするのが一番速いの?

2024/02/22に公開

JavaScriptは結構優秀な言語なので一般的なWebアプリケーションを開発する上では処理速度を気にする必要はあまりないと思います。
とはいえ、エンジニアとしてパフォーマンスの高い実装を心がけたいので、よく扱う配列周りの処理について速度を調べてみることにしました。

配列内の全要素に対するループ処理

まずはシンプルによく使う配列の全件ループについて検証してみます。
個人的には可読性を重視して for-of を使うことが多いですが、純粋な for 文で添え字参照した方が速そう。

実行した処理

1000万個の要素を持つ配列に対してループ処理を実行したときの経過時間を計測します。
平等に計測するため、いずれの場合においてもループ処理内で配列内の各要素を参照するようにしました。

ループの回し方は

  1. Array.prototype.forEach メソッド
  2. for を用いたインデックス参照
  3. for-of
  4. for-in

の4パターンを比較します。

const LOOP_LENGTH = 10**7

const data = Array.from({ length: LOOP_LENGTH }, (_, i) => ({
  id: i + 1
}))

const forEachStart = Date.now()

data.forEach((el) => {
  el
})

const forEachDuration = Date.now() - forEachStart

console.log('forEach', forEachDuration)

const forStart = Date.now()

for (let i = 0; i < data.length; i++) {
  const el = data[i]
}

const forDuration = Date.now() - forStart

console.log('for', forDuration)

const forOfStart = Date.now()

for (const el of data) {
  el
}

const forOfDuration = Date.now() - forOfStart

console.log('for of', forOfDuration)

const forInStart = Date.now()

for (const el in data) {
  el
}

const forInDuration = Date.now() - forInStart

console.log('for in', forInDuration)

結果

forEach for for-of for-in
1回目 59ms 5ms 104ms 1717ms
2回目 56ms 8ms 106ms 1756ms
3回目 58ms 8ms 97ms 2480ms

予想通り、シンプルな for 文が一番速く、次いで forEachfor-of 、 ぶっちぎりで遅かったのは for-in という結果。
forEachfor-of に倍近い差が出たのは意外でした。
インデックス番号が取得できることを踏まえても forEach の方が利便性は高いですが、可読性を考慮すると for-of を使った方がチーム開発には向いていそうなのでどちらを使うかケースバイケース。
for-in については enumerable の考慮が必要で管理が複雑になりやすいですし、チーム開発には向いていなさそうです。個人開発で enumerable を使いこなせるのであれば一考してもいいかもしれませんが、かなり遅いことも踏まえて他のパターンを採用した方が無難な気がします。

いろんなリストの全要素に対するループ処理

リストを扱う場合、必要に応じて配列以外の手法を採用するケースがあります。
それぞれ用途は異なりますが、ループ処理を行う場合はどれが優れているのでしょうか?

実行した処理

1000万個の要素を持つリストに対してそれぞれ全件ループを実行した場合の速度を比較します。
公平を期すため、ループの手法はすべて for-of で統一しておきましょう。

比較対象のリストは

  1. 配列
  2. Map
  3. Set
  4. 連想配列(オブジェクト)

の4種を採用しました。

const LOOP_LENGTH = 10**7

const data = Array.from({ length: LOOP_LENGTH }, (_, i) => ({
  id: i + 1
}))

let v = null

const arr = [...data]

const arrStartTime = Date.now()

for (const el of arr) {
  el
}

const arrDuration = Date.now() - arrStartTime

console.log('arr', arrDuration)

const map = new Map(data.map((el) => [el.id, el]))

const mapStartTime = Date.now()

for (const el of map.values()) {
  el
}

const mapDuration = Date.now() - mapStartTime

console.log('map', mapDuration)

const _set = new Set(data)

const setStartTime = Date.now()

for (const el of _set.values()) {
  el
}

const setDuration = Date.now() - setStartTime

console.log('set', setDuration)

const associativeArr = Object.fromEntries(data.map((el) => [el.id, el]))

const associativeArrStartTime = Date.now()

for (const el of Object.values(associativeArr)) {
  el
}

const associativeArrDuration = Date.now() - associativeArrStartTime

console.log('associativeArr', associativeArrDuration)

結果

配列 Map Set 連想配列
1回目 106ms 129ms 109ms 146ms
2回目 128ms 125ms 108ms 140ms
3回目 96ms 146ms 119ms 144ms

配列とSetが若干速い気もしますが、有意な差とも言い難い結果になりました……。
ただ、この後も何回か実行したところ、配列は大体同じ結果になりましたが、他の3つは2~5倍ほどの時間がかかったり逆に数十msで完了したりと速度にばらつきがありました。
詳しい原因はわかりませんが、Map、Set、Objectの values メソッドは実行状況によってパフォーマンスの差が大きくなるのかもしれません。
とはいえ、平均するとどのリスト形式も速度に大きな差はなさそうなのでループ処理の観点からはどれを選んでもよさそうです。
正直、 values メソッドを使用するともっと遅くなると思っていたので意外な結果でした。

いろいろなリストからIDに紐づく要素を検索する処理

ループについては各リストで大きな速度差は見られませんでしたが、要素を検索する処理ではどうでしょうか?
検索した要素を取得するという目的からずれるSetを除いて、その他のリスト形式での速度を調べてみます。

実行した処理

1~1000までの自然数でIDが振られた1000個の要素を持つリストに対して、IDをキーとして要素を検索する処理を1000万回繰り返したときの結果を比較します。

比較対象は下記の4パターンです。

  1. IDとインデックス番号を合わせた配列(添え字による参照)
  2. IDとインデックス番号を合わせていない配列( find メソッドによる検索)
  3. IDをキーとしたMap
  4. IDをキーとした連想配列
const LOOP_LENGTH = 10**7

const data = Array.from({ length: 1000 }, (_, i) => ({
  id: i + 1
}))

const indexArr = [null, ...data]

const indexArrStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  const id = data[i % data.length].id
  indexArr[id]
}

const indexArrDuration = Date.now() - indexArrStartTime

console.log('indexArr', indexArrDuration)

const findArr = [...data]

const findArrStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  const id = data[i % data.length].id
  findArr.find(({ id: _id }) => _id === id)
}

const findArrDuration = Date.now() - findArrStartTime

console.log('findArr', findArrDuration)

const map = new Map(data.map((el) => [el.id, el]))

const mapStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  const id = data[i % data.length].id
  map.get(id)
}

const mapDuration = Date.now() - mapStartTime

console.log('map', mapDuration)

const associativeArr = Object.fromEntries(data.map((el) => [el.id, el]))

const associativeArrStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  const id = data[i % data.length].id
  associativeArr[id]
}

const associativeArrDuration = Date.now() - associativeArrStartTime

console.log('associativeArr', associativeArrDuration)

結果

配列から添え字で検索 配列からfindで検索 Mapから検索 連想配列から検索
1回目 42ms 7260ms 176ms 39ms
2回目 45ms 7334ms 173ms 40ms
3回目 45ms 7359ms 176ms 43ms

添え字による参照を行う配列と連想配列が最も早く、次いでMap、断トツで遅いのが find による配列の検索でした。
ちなみに、配列に対して at メソッドを使用した場合も検証しましたが、添え字で参照した場合と速度差はないようです。
IDが自然数ではない場合や抜け番あるような場合では配列のインデックスとIDを一致させる手法は使えないので、ID検索という点で言うと連想配列が最も使い勝手の良いリスト形式といえそうです。
とはいえ、連想配列の場合、キーを削除するには delete 演算子が必要だったり、要素数の計算が1つのメソッドで完結しないなどのデメリットもあるので、Mapの方が使い勝手の良いケースは多いように思います。
少なくとも自然数でないIDによって要素を検索することが多いのであれば配列は速度面で他の手法よりも劣ることがわかりました。(find はもっと早いと思っていた……)

配列を空にする処理

配列の初期化をする際、配列のインスタンスを残す必要があるか否かで空配列を作る手法は変わりますが、単純にどちらが速度的に優れているのかが気になったので調べてみます。

空配列に対する処理

まずは、元々空の配列に対して空にする処理を実行した場合の純粋な処理速度を比較してみました。

実行した処理

const LOOP_LENGTH = 10**7

const data = Array.from({ length: 100 }, (_, i) => i)

let arr = []

const spliceStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  arr.splice(0)
}

const spliceDuration = Date.now() - spliceStartTime

console.log('splice', spliceDuration)

const lengthStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  arr.length = 0
}

const lengthDuration = Date.now() - lengthStartTime

console.log('length', lengthDuration)

const newStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  arr = []
}

const newDuration = Date.now() - newStartTime

console.log('new', newDuration)

結果

spliceを使用 lengthに0を代入 新規の空配列を再代入
1回目 209ms 268ms 63ms
2回目 199ms 277ms 67ms
3回目 192ms 272ms 61ms

要素数100個の配列に対する処理

実行した処理

100個の要素をもつ配列を空にする処理を500万回繰り返したときの結果を比較します。
(1000万回実行するとメモリ不足でクラッシュしたため実行回数を減らしました)

const LOOP_LENGTH = 10**6 * 5

const data = Array.from({ length: 100 }, (_, i) => i)

function createArrays() {
  return Array.from({ length: LOOP_LENGTH }, () => Array.from(data))
}

const spliceArrays = createArrays()

const spliceStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  spliceArrays[i].splice(0)
}

const spliceDuration = Date.now() - spliceStartTime

console.log('splice', spliceDuration)

const lengthArrays = createArrays()

const lengthStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  lengthArrays[i].length = 0
}

const lengthDuration = Date.now() - lengthStartTime

console.log('length', lengthDuration)

const newArrays = createArrays()

const newStartTime = Date.now()

for (let i = 0; i < LOOP_LENGTH; i++) {
  newArrays[i] = []
}

const newDuration = Date.now() - newStartTime

console.log('new', newDuration)

結果

spliceを使用 lengthに0を代入 新規の空配列を再代入
1回目 735ms 306ms 53ms
2回目 659ms 320ms 64ms
3回目 692ms 329ms 46ms

条件を変えて検証したところ、要素数が5つを超えたところで splice(0) よりも length = 0 の方が速くなりました。

Discussion