フューチャー技術ブログ

TypeScript/JavaScript Array完全攻略2024

TypeScriptアドベントカレンダーの12/5のエントリーです。昨日は@nanasi-1さんの【TypeScript】ジェネレーターによる遅延評価でフィボナッチ数列を生成するでした。

イマドキのJavaScriptの書き方2018というのを以前書いたのだけど、配列周りはかなり変わっているな、というのを思ったので、そこの部分だけアップデートするつもりで書いてみました。

実環境で使えるECMAScriptバージョン

今時のブラウザは常に最新に更新されるはずなのでECMAScript 2024の機能もフルに使えるはずですが、おそらくNode.jsのLTSが一番古いJavaScriptエンジンということになるのかな、と思います。本記事執筆時点でサポート中のバージョンは以下の4つです。軽くメソッドを調べたりした感じ、こんな感じかと。202x年の11月ぐらいになると、ES202xがLTSバージョンで使えるようになって・・・というサイクルのようですね。DenoとBunはよくわかりません。

Nodeバージョン ESバージョン アクティブ メンテナンス
18.20.5(Maintenance) 2022相当 終了 2025/4/30まで
20.18.1(Maintenance) 2023相当 終了 2026/4/30まで
22.11.0(LTS) 2024相当 2025/10/21まで 2027/4/30まで
23.3.0(Current) 2024相当 2025/3/31まで 2025/6/1まで

VSCodeでコードを修正する場合、ES2023とか2024のメソッドを使おうとすると lib を上げろと言ってきますが、実際に使えるECMAScriptバージョンにあわせて、tsconfig.jsonのtargetを上げてしまう方が良いかと思います。

npx -p typescript tsc --init
tsconfig.json
{
"compilerOptions": {
"target": "es2023"
}
}

MDNを見ると、Widely Availableは2.5年前のバージョン基準ということで、今だとECMAScript 2022以前、という感じですかね。

配列の作成

新規で作る

Array.of() というクラスメソッドはありますが、指定した要素を持つ配列はリテラルで作れば良いかなと思います。

// リテラルで配列を作成
const array1 = [1, 2, 3];

// 空の場合は型指定必須
const array2: string[] = []; // なるべくこちら推奨
const array3 = [] as string[]; // 式として書かないといけない場合は右辺に

既存のデータ構造からコピーして作る

旧来の書き方だとslice()を使って行っていたと思いますが、旧来のメソッドはインプレースの変更なのか新規作成なのかが分かりにくいため、見た目で新しいオブジェクトを作成していることが明確なスプレッド構文が良いかと思います。

古い書き方
// 旧: slice利用
var copied1 = original.slice(0);
新しい書き方
// 新: 浅いコピーはスプレッド構文
const copeid2 = [...original];

// 新: Map, Setなどのイテレータ対応オブジェクトはfrom利用
const copied3 = Array.from(map);

ECMAScriptではないのですが、Node.jsでも使える structuredClone() 関数を使うと、深いコピーができます。サポートが遅れていたSafariでも2022/10には使えるようになったので、現時点で全ての環境で使えると判断しても良いでしょう。

新しい書き方
// 新: 深いコピーはstructuredClone
const copied3 = structuredClone(original);

プログラマブルに配列を作る

プログラマブルに「全部0で初期化された配列を作る」ということを行いたい、ということがあるかと思います。

長さ指定で配列を作る場合は Array(n) で作れますが、これだと配列の中身が undefined になります。その後初期化が必要なため、疎な配列を作りたい場合以外は使わない方が良いでしょう。

古い書き方
// 旧: Array(n)を利用。作成後にループで初期化、mapで初期化
var array1 = Array(20);
for (var i = 0; i < array.length; i++) {
array1[i] = 10;
}

ECMAScript 2015のArray.from()はイテレータ対応のオブジェクト以外にlengthを持つオブジェクトが使えて、2つ目にmap()と同じ変換関数を持てるので、これを使って任意の数を設定できます。map()と同じなので2つめにインデックスが渡されるのでインデックスごとに値を変えたい場合も対応可能です。

新しい書き方
// 新: Array(n)を利用。作成後にループで初期化、mapで初期化
const array2 = Array.from({ length: 20 }, () => 10);

ループ

ループの書き方は3種類あります。一番使うことになるのが for ... of ループです。3つ紹介するなかでは最後発で、2015年のECMAScript6で導入されました。この構文はArraySetMapStringなどの繰り返し可能(iterable)オブジェクトに対してループします。配列の場合で、インデックス値が欲しい場合は、entries()メソッドを使います。

まず最初に使うべきループ
const iterable = ["小金井", "小淵沢", "小矢部"];

// for ofループ
// 要素のみ欲しいときは for (const value of iterable)
for (const value of iterable) {
console.log(value);
}

// for ofループで配列のインデックスが欲しい
for (const [i, value] of iterable.entries()) {
console.log(i, value);
}

こちらは関数呼び出しを伴わないフラットなコードなので、async/awaitとも一緒に使えます。配列の要素を引数にして、1つずつawaitしたい場合などです。

asyncと新しいループ
const iterable = [10, 20, 30];

for (const value of iterable) {
await doSomething(value);
}

2つ目に紹介するのはループ変数が必要となる、最初期からあるC言語由来のループです。順番に配列の最後までループする用途であれば、(2重ループとかで)変数名を間違えて意図せずループが終わったり、無限ループになるリスクがあるため、前述の for ... of がおすすめです。

このループが活躍するのは、アルゴリズムの実装で、ループ変数の開始位置や終了位置が先頭や末尾以外にする必要があるケースなどです。次のコードは、重複を効率よく探すために、 ij の組が重複しないようにループ範囲を調整しています。

ループ変数を使うループ
const iterable = ["中野駅", "中村屋", "中尊寺", "中央線", "中野駅"];

// C言語由来のループ
for (let i = 0; i < iterable.length; i++) {
var value = iterable[i];
console.log(value);
}

// スタート、終了位置が特殊なケース
for (let i = 0; i < iterable.length - 1; i++) {
for (let j = i + 1; j < iterable.length; j++) {
if (iterable[i] === iterable[j]) {
console.log(`重複: ${iterable[i]} (${i}, ${j})`);
}
}
}

2009年のECMAScript5で追加されたforEach()もあります。関数型主義的なスタイルで統一するために、forを禁止してforEach()のみを使うというコーディング標準を規定している会社(Airbnb)も10年ぐらい前に話題になりましたが、積極的に使う理由はないでしょう。

ES5の書き方
const iterable = ["大判焼", "大納言", "大所帯"];

// forEach()ループ
iterable.forEach(value => {
console.log(value);
});

速度の面で言えば、旧来の for ループが最速です。 for ... offorEach() は、ループ1周ごとに関数呼び出しが挟まるため、実行コストが多少上乗せされますが、ゲームの座標計算で1フレームごとに数万要素のループを回さなければならない、といったケース以外ではほぼ気にする必要はないでしょうし、特に for ... of の場合は処理系が最適化を行ってくれているため、パフォーマンス低下は少ないです。

軽く検証したところ、最速のforと比べてChromeでfor...ofの処理時間が20%増し、forEach()が135%増しでした。Safariだと、Chromeでfor...ofが110%増し、forEach()が460%増しぐらい。forEach()はもう忘れましょう。

配列のデータ取得

1要素の取得

データの取得方法も選択肢がいくつか増えています。前からn番目の要素取得に関しては変わりませんが、末尾からの取得については、ECMAScriptで2022で追加された at() メソッドが便利です。正の値を使う場合は動作はインデックスアクセスと変わりませんが、負の数を与えると末尾からのインデックスで値が取得できます。

今でも有効な書き方
// 共: 1要素の取得(前からのインデックス)
const v = array[10];
古い書き方
// 旧: 末尾の要素の取得
var last = array[array.length - 1];
新しい書き方
// 新: 末尾の要素の取得
const last = array.at(-1);

複数要素の取得

複数の要素を取り出す場合は今までは1つずつ取り出していたと思います。また、残りの部分をまとめて取り出すときはslice()で取り出します。
もし配列が短い場合にデフォルト値を与えたい場合はORの演算子 || を使うことが多いでしょう。ただし、0とかfalsyな値に真面目に対応しようとするともうちょっとがんばらないといけないのですが。

古い書き方
// 旧: 末尾の要素の取得
var a = array[0];
var b = array[1];
var c = array[2] || 10; // デフォルト値

// 旧: 残りの部分をまとめて配列にするにはslice()
var rest = array.slice(3);

ECMAScript 2015から導入された分割代入では複数要素をまとめて取り出せます。ECMAScript 2018から導入された残りプロパティ(...)を使うと、残りを全てまとめて取得、というのもできます。

新しい書き方
// 新: 要素をまとめて取得。デフォルト値も指定可能。
const [a, b, c = 10] = array;

// 新: 末尾の要素と残りをまとめて取得
const [a, b, c, ...rest] = array;

// 新: いくつか読み飛ばしつつ抜き出し
const [, , , ...rest] = array;

ただし、まとめて取得する場合のスタート位置が状況によって変わる場合は以前の通り、slice()を使うことになるでしょう。

配列の変更

ウェブフロントエンドの状態管理だと、配列の変更を行う場合には、インプレースで値を変更するのではなく、新しいオブジェクトを作って返す、ということが行われます。Reactが差分検知でそれを期待しており、Reduxとかもそれに従っていました。Redux Toolkitの裏で使われているimmer.jsみたいに、インプレースの変更をもとにイミュータブルに新しいオブジェクトを作ってくれるライブラリもあるにはありますが、本体にもイミュータブルスタイルのメソッド追加が頻繁に行われています。

どちらが新か旧かというのはなく、用途次第だと思うので、破壊的な方法と、イミュータブルの方法を並べて紹介します。

先頭・末尾の要素の追加

末尾に追加するのはpush()メソッドが昔からある方法です。

破壊的な方法
const a = [1, 2, 3, 4, 5];

a.push(6); // 末尾に追加
a.push(7, 8); // あまり知られてないけど2つ同時に追加もできる。
a.unshift(0); // 先頭に追加

スプレッド構文を使うと、メソッド名と役割を覚えずに、リテラル上の位置のまま挿入できて、見た目もわかりやすいでしょう。

イミュータブルな方法
const a = [1, 2, 3, 4, 5];

const newA = [...a, 6, 7]; // こちらも任意の数を追加できる
const newA2 = [0, ...a]; // 先頭にも追加できる

先頭・末尾の要素の削除

先頭・末尾の要素の削除はpop()shift()を使います。

破壊的な方法
const a = [1, 2, 3, 4, 5];

a.pop(); // 末尾の要素の削除
a.length--; // これでも可能
a.shift(); // 先頭の要素の削除

先頭の要素の削除はスプレッド構文を使えばできますが、末尾の要素の削除はスプレッド構文ではできません。slice()を使って、現在よりも1小さい長さを指定してコピーする方法しかないでしょう。

イミュータブルな方法
const a = [1, 2, 3, 4, 5];

// 末尾の要素の削除
const newA = a.slice(0, a.length - 1);
// 先頭の要素の削除
const [, ...newA2] = a; // 読み飛ばしたい数だけカンマを前におく

要素の変更

これは一番シンプルな最初に覚える書き方ですね。

破壊的な方法
const a = [1, 2, 3, 4, 5];
a[1] = 4;

こちらはECMAScript 2023の新顔のwith()メソッドです。

イミュータブルな方法
const newA = a.with(1, 4);

配列の結合

push()は複数要素対応できるため、スプレッド構文を使えば破壊的な変更は難しくないでしょう。一昔前はb.push.apply(b, c)みたいなコードを書いていた気がしますが、それは忘れましょう。

破壊的な方法
const a = [1, 2, 3];
const b = [4, 5, 6];

a.push(...b);

昔からあるconcat()メソッドは新しい配列も作りますが、スプレッド構文1つで配列結合もできるのでこちらだけ覚えておけば良いでしょう。昔のメソッドは破壊的なのかそうじゃないのかが名前だけでは分からない欠点があります。

イミュータブルな方法
const a = [1, 2, 3];
const b = [4, 5, 6];

const newA1 = [...a, ...b]; // スプレッド構文で結合も可能
const newA2 = a.concat(b); // 昔からあるこちらもイミュータブル

途中への要素/配列の挿入・削除

昔のJavaScriptには「これが使いこなせれば初級脱出」というsplice()というメソッドがありました。1つめの引数に処理を開始したい要素のインデックス、2つ目に削除したい要素の数、3つ目以降に挿入したい要素(複数可)というものです。これを使うと、任意の箇所に要素を挿入できます。

2つめの引数を1以上にすると削除します。

破壊的な方法
const a = [1, 2, 3];
const b = [4, 5, 6];

a.splice(2, 0, 3.5); // 2と3の間に3.5を挿入
a.splice(1, 0, ...b); // 1と2の間にbの要素を挿入
a.splice(1, 1); // 2番目の要素を1つ削除

これはスプレッド構文だけではやや難しかったのですが、ECMAScript 2023ではslice()のイミュータブルバージョンが追加されました。

イミュータブルな方法
const a = [1, 2, 3];
const b = [4, 5, 6];

const newA = a.toSpliced(2, 0, 3.5); // 2と3の間に3.5を挿入
const newA2 = newA.toSpliced(1, 0, ...b); // 1と2の間にbの要素を挿入
const newA3 = newA2.toSpliced(1, 1); // 2番目の要素を1つ削除

ソート・反転

ミュータブルなソートや反転はおなじみのsort()reverse()を使います。

破壊的な方法
const arr = [1, 5, 8, 3, 6, 2];
arr.sort((a, b) => b - a); // ソート
arr.reverse(); // 反転

splice()に対するtoSpliced()みたいに、ECMAScript 2023でsort()に対するtoSorted()reverse()に対するtoReversed()が追加されました。

イミュータブルな方法
const arr = [1, 5, 8, 3, 6, 2];
const arr2 = arr.toSroted((a, b) => b - a); // ソート
const arr3 = arr2.toReversed(); // 反転

Pythonは昔からsorted()とかreversed()という関数を提供していましたが、「ソート済みかどうか」の判定関数にも見えてしまう名前なのでこちらの方が良いですね。

ネストを解消

flat()は配列の中に配列がある場合に、それを平坦にならします。デフォルトでは配列の中の配列までが対象ですが、パラメータの数値で、何段まで探索するかが変わります。これはイミュータブルなメソッドです。

const arr = [0, 1, [2, [3, 4, 5]]]; // 3層の配列

console.log(arr.flat()); // デフォルトでは1階層目の配列を展開
// [0, 1, 2, [3, 4, 5]]
console.log(arr.flat(2)); // 数値を大きくすれば1次元配列に
// [0, 1, 2, 3, 4, 5]

リスト処理

フロントエンドで関数型スタイルのコーディング技法として広まっているが関数型由来のリスト処理です。昔からあって一番有名なのがmap/filter/reduceですが、他にもいくつかあります。

1つだけだとfor...ofを使うのとあまり変わりませんが、メソッドチェーンで複数接続すると、小さい関数で期待する結果を得る、関数型スタイルの処理になります。

残念ながら、これらはリストのメソッドであり、DOMのAPIにいくつかある「リストっぽいけどリストじゃない」オブジェクトでは使えなかったりします。今ステージ2のパイプライン演算子が入ることになれば、これらのメソッドも純粋な関数に再定義されて、すべてのイテレーション対応オブジェクトで使えるようになるんじゃないかと期待しています。このリスト処理だと同期処理しか対応できないのですが、それもこのパイプライン演算子が入れば解決の見込みです。

const result = input
|> validation(%) // 他の言語とは違い、%が前の関数の返り値みたい
|> normalize(%)
|> sanitize(%);

リスト→リスト変換

リストからリストを返すメソッド群が以下の3つです。

  • map(変換処理): ループの要素ごとに変換処理の関数を呼び出し、その結果で新しい配列を作って返す
  • filter(判定処理): ループの要素ごとに判定処理の関数を呼び出し、真の値を返すもののみの新しい配列を作って返す
  • flatMap(変換関数): ループの要素ごとに変換処理の関数を呼び出し、その結果を flat() した新しい配列を作って返す

これらを結合すると柔軟な処理が可能ですが、可能であればfilter()で要素を減らす処理を先に行うと、トータルのループの回数が減るため、処理が高速化されます。

Reactの仮想DOM構築では、通常はすべて1つの式の中でDOMを作ります。そうなるとif/forが使えません。ifの代わりに三項演算子、forの代わりにmapがよく使われます。

リスト→リスト処理
const items = [
{ name: "りんご", price: 100, category: "果物" },
{ name: "バナナ", price: 50, category: "果物" },
{ name: "オレンジ", price: 80, category: "果物" },
{ name: "牛乳", price: 150, category: "乳製品" },
];

// 税金を加算したリストを作る
const itemsWithTax = items.map(({name, price, category}) => {
return {name, price: price * 1.1, category};
});
// [
// { name: "りんご", price: 110.00000000000001, category: "果物" },
// { name: "バナナ", price: 55.00000000000001, category: "果物" },
// { name: "オレンジ", price: 88, category: "果物" },
// { name: "牛乳", price: 165, category: "乳製品" }
// ]

// 値段が100以上の商品を抽出する
const cheapItems = items.filter(item => item.price < 100);
// [
// { name: "バナナ", price: 50, category: "果物" },
// { name: "オレンジ", price: 80, category: "果物" }
// ]

flatMap()map()flat()をくっつけたものです。ただし、配列を返すとそれを展開する点が異なります。くっつけたことにより、map()よりも柔軟です。

次のサンプルのように、定数を返すだけならmap()互換ですが、空配列を返すとその要素は消滅します。また、配列を返すことで、1要素から複数の要素を生み出せます。ただし、配列そのものを要素にしたい場合は2重配列にする必要があります。

const a = [1, 2, 3, 4];
const result = a.flatMap(i => {
if (i === 1) return []; // 空配列を返すと削除
if (i === 2) return 2; // 配列以外を返すとmapと同じくそのまま結果に入る
if (i === 3) return [3, 3, 3]; // 配列を返すと展開される
if (i === 4) return [[4, 4, 4, 4]]; // 配列そのものを要素にしたい場合は2重配列にする
});
console.log(result);
// => [2, 3, 3, 3, [4, 4, 4, 4]]

1つの要素を返す

  • reduce(結合処理[, 初期値]): 要素の配列ごとに結合処理を行い、最終的に1つの値にして返す
  • some(判定処理): 要素の1つでも判定処理関数が真の値を返せば true を返す
  • every(判定処理): すべての要素に対して判定処理関数が真の値を返せば true を返す
リスト→1つの要素
const items = [
{ name: "りんご", price: 100, category: "果物" },
{ name: "バナナ", price: 50, category: "果物" },
{ name: "オレンジ", price: 80, category: "果物" },
{ name: "牛乳", price: 150, category: "乳製品" },
];

// 合計値
const totalPrice = items.reduce((acc, {price}) => acc + price, 0);
// => 380

// 果物が含まれる?
items.some(({ category }) => category === "果物");
// => true

// すべての商品が果物?
items.every(({ category }) => category === "果物");
// => false

reduce()の結合処理は前の処理結果が1つめの引数に渡され2つ目に配列の要素が入ります。これを要素数分繰り返します。初期値を省略すると2つ目の要素から処理され、最初の要素は前の処理の結果として渡されます。上記の処理は4回呼ばれます。最後の結果が関数の結果となります。

  • (0, { price: 100 }) => 100
  • (100, { price: 50 }) => 150
  • (150, { price: 80 }) => 230
  • (230, { price: 150 }) => 380

some()は条件似合うものが1つでもあればすぐにループを終えるので、every()はすべての要素を探索するので、not条件を検証したい場合は、some()で代替できないかは検討してみると良いでしょう。

まとめ

以前の配列は破壊的操作と、イミュータブルな操作が混ざっていてわかりにくかったのですが、関数型スタイルブームに合わせてか、イミュータブルな処理が増えました。リファレンスを見ても、その辺りの区別がつきにくいと思われるため整理のためにまとめてみました。

リスト処理、toXXXed()メソッドの追加とwith()メソッド、flat()メソッド、スプレッド構文を中心に使えばイミュータブルなデータ加工はだいぶ思いのままにできそうだな、ということがわかりました。