順番に読み進めながら学べます

reduce — 配列を 1 つの値に畳み込む

配列を 1 つの値にまとめる reduce を扱います。アキュムレータと初期値の受け渡し、合計・最大・カテゴリ別の集計、空の配列で起きる TypeError まで確かめます。

注文の明細から合計金額を出したいとき、map は件数が変わらず、filter は要素が減るだけで、どちらも返るのは配列です。合計や最大値のように配列を 1 つの値にまとめる処理は、この 2 つのメソッドでは書けません。

この記事では、配列を 1 つの値にまとめる reduce と、その起点になる初期値を扱います。

配列を 1 つの値にまとめる — reduce と初期値

買い物カートの合計金額を画面に出したいとします。forEach で書くと、合計を入れる変数がループの外に残り、あとの行からも書き換えられる状態になります。足し込むたびに値が変わるので、その変数は const ではなく let で宣言することになります。

配列のメソッド reduce(要素を 1 件ずつコールバックに渡し、最後の戻り値だけを返すメソッド)のコールバックは、mapfilter と違い、1 つ目の引数が アキュムレータ(前の周の戻り値が入る引数)で、要素は 2 つ目です。reduce 自身の第 2 引数には、初期値(1 周目のアキュムレータに入る値)を書きます。

const cartItems = [
  { name: "ノート PC スタンド", price: 3200 },
  { name: "USB ハブ", price: 2480 },
  { name: "HDMI ケーブル", price: 1180 },
];

// 1 つ目の引数が前の周の戻り値、2 つ目が今の要素
const addPrice = (total, item) => total + item.price;

// 第 2 引数の 0 が 1 周目の total になる
const totalPrice = cartItems.reduce(addPrice, 0);
console.log(totalPrice);          // 6860

// 返るのは配列ではなく 1 つの値
console.log(typeof totalPrice);   // number
1 周ごとに total が置き換わる
1 周目のtotal は 00 + 3200 を返す2 周目のtotal は 32003200 + 2480を返す3 周目のtotal は 56805680 + 1180を返すtotalPriceは 6860最後の戻り値がそのまま入る
1 周目が返した 3200 が、2 周目の total として渡ります。最後の周が返した 6860 が reduce の結果です

途中経過を持つ変数を自分で用意しなくてよいのは、周と周のあいだで値を渡す役目を reduce が持っているからです。次の周へは数値だけでなく、要素やオブジェクトも渡せます。

社内の備品発注リストから、発注全体の数量と金額をまとめます。orderLines は宣言済みで、1 行が 1 品目です。

① 数量の合計を表示してください。

② 単価 × 数量で求めた金額の合計を表示してください。

③ ② の合計を品目数で割った、1 品目あたりの平均金額を表示してください。

(正しく実行できれば解説が表示されます)

JavaScript / TypeScript エディタ

コードを実行してください

大きい方を残す — 数値と要素どちらを持ち回るか

配信した動画のうち、再生数が最も多いものを知りたいとします。合計と違って足し合わせるのではなく、比べて大きい方だけを次の周へ残す形です。ここで決めることになるのが、1 周目のアキュムレータに何を置くかです。

アキュムレータには数値も要素そのものも置けます。第 2 引数に 0 を置けば数値を、videos[0] を置けば要素そのものを次の周へ渡す形になります。初期値には、コールバックが返すものと同じ種類の値を置きます

const videos = [
  { title: "SQL 入門 #1", views: 8200 },
  { title: "Linux 入門 #3", views: 15400 },
  { title: "Git の基本", views: 9700 },
];

// 初期値が数値なので、比べるのも返すのも数値
const maxViews = videos.reduce((max, video) => Math.max(max, video.views), 0);
console.log(maxViews);         // 15400

// 初期値が要素なので、比べるのも返すのも要素
const topVideo = videos.reduce(
  (top, video) => (video.views > top.views ? video : top),
  videos[0],
);
console.log(topVideo.title);   // Linux 入門 #3
初期値に置いたものを最後まで持ち回る
初期値は 0数値max とvideo.views を比べる大きい方の数値を返すmaxViews は15400初期値はvideos[0]top.views とvideo.views を比べる大きい方の要素を返すtopVideo のtitle が読める
上は数値だけを持ち回り、下は要素をそのまま持ち回ります。タイトルまで知りたいときは、初期値に要素を置きます

どちらの行も比べているのは views の値で、違うのは次の周へ渡すものだけです。下の行で初期値を 0 にすると、1 周目の top.viewsundefined なので比較は毎回 false になり、最後まで 0 が返ります。

初期値 0 は負の数を含むと合いません

気温の記録のようにマイナスの値が混ざる配列で初期値に 0 を置くと、実際の最大値が -3 でも 0 のほうが大きいまま残り、配列に無い値が結果になります。要素どうしを比べる形にして初期値を配列の 0 番目にすれば、値の範囲に左右されません。

今月の店舗別の売上と費用から、損益が最も大きい店舗と最も小さい店舗を調べます。stores は宣言済みで、単位は万円です。

① 損益(売上 − 費用)が最も大きい店舗の名前を表示してください。

② 損益が最も小さい店舗の名前を表示してください。

③ ② の店舗の損益を表示してください。

④ ① と ② の店舗の損益の差を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

件数をオブジェクトに畳む — グルーピング

問い合わせがカテゴリごとに何件あるかを知りたいとします。合計のように結果が数値 1 つでは済まず、カテゴリの数だけ値を持つ集計になります。空のオブジェクトを外に置いて forEach で足し込む形でも書けますが、集計用の入れ物が処理の外に残ります。

初期値には {} も置けます。このときのアキュムレータは集計中のオブジェクトで、コールバックはキーを更新したあと そのオブジェクトを返します。まだ登録の無いキーを読むと undefined なので、?? 0 を挟んで 0 から数え始めます。

const tickets = [
  { subject: "返品したい", category: "返品" },
  { subject: "商品が届かない", category: "配送" },
  { subject: "伝票を再発行してほしい", category: "配送" },
];

// カテゴリごとに 1 件ずつ数え上げる
const countByCategory = tickets.reduce((counts, ticket) => {
  counts[ticket.category] = (counts[ticket.category] ?? 0) + 1;
  return counts;          // 更新した counts を次の周へ渡す
}, {});                   // この {} が 1 周目の counts になる

console.log(JSON.stringify(countByCategory));  // {"返品":1,"配送":2}
console.log(countByCategory["配送"]);          // 2
3 周とも同じ counts のキーを更新する
1 周目counts は {}返品を 1 にして返す2 周目返品 1配送を 1 にして返す3 周目返品 1・配送 1配送を 2 にして返す
どの周も受け取った counts のキーを 1 増やし、そのまま返しています。書き込み先は、初期値に渡した 1 つのオブジェクトだけです

初期値の {} は、集計結果だけを入れるための新しいオブジェクトで、tickets の要素には何も書き込みません。下の表は、ここまでの 3 つの使い方で、初期値とコールバックが返すものの組み合わせをまとめたものです。

畳み込みたいもの初期値に置くものコールバックが返すもの
合計0前の周の合計に値を足した数値
最も大きい要素配列の 0 番目比べて大きいほうの要素
分類ごとの件数空のオブジェクトキーを更新したオブジェクト

通販の注文一覧から、支払い方法ごとの売上金額を集計します。orders は宣言済みで、amount は注文 1 件の金額です。

① 支払い方法をキー、金額の合計を値に持つオブジェクト salesByMethod を作ってください。

② salesByMethod を JSON の文字列に直して表示してください。

③ 売上全体に占めるクレジットカードの割合を、% の数値で表示してください。

④ 支払い方法が何種類あったかを表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

要素が 1 つも無いとき — 初期値を省いた reduce

絞り込んだ結果が 0 件になることがあります。当日の出荷が 1 件も無い日に合計を求めるような場面で、reduce は書き方によって 0 を返すこともエラーで止まることもあります。

第 2 引数の初期値は省略できます。省いた場合は配列の 0 番目が最初のアキュムレータになり、コールバックは 1 番目の要素から呼ばれます。空の配列では取り出す 0 番目が無いため、TypeError が投げられます。

const yesterdayAmounts = [3200, 2480, 1180];
const todayAmounts = [];

// 初期値を省くと、0 番目の 3200 が最初の total になる
console.log(yesterdayAmounts.reduce((total, amount) => total + amount));  // 6860

// 空の配列でも、初期値があればそれがそのまま返る
console.log(todayAmounts.reduce((total, amount) => total + amount, 0));   // 0

// 初期値が無いと最初の total を決められない
// todayAmounts.reduce((total, amount) => total + amount);
// TypeError: Reduce of empty array with no initial value
空の配列で初期値があるときと無いとき
空の配列に初期値 0渡す要素が1 件も無いコールバックは呼ばれない初期値の 0 がそのまま返る空の配列で初期値なし0 番目を取り出せない最初の total が決まらないTypeError で止まる
同じ空の配列でも、初期値を書いたかどうかで結果が分かれます。初期値が無いときだけ 0 番目の要素が必要になります

手元のデータで動いていても、条件に合う要素がたまたま 1 件も無い日に初めて止まるため、書いた時点では気づきにくい失敗です。filter で絞った結果を reduce に渡すときは、0 件に備えて初期値を渡します

{} を省くと先頭の要素に書き込まれる

countByCategory の例で最後の {} を省くと、tickets[0] の問い合わせオブジェクトそのものが最初の counts になります。エラーは出ずに、そのオブジェクトへ 配送 のキーが書き足され、返品 の 1 件は数えられません。要素があっても、オブジェクトに畳むときは初期値を渡します。

今月の返金記録から、高額な返金の合計を出します。refunds は宣言済みで、単位は円です。

① 返金額の合計を、初期値を省いた書き方で表示してください。

② 5000 円以上の返金だけを残した配列を作り、件数を表示してください。

③ ② の配列の合計を、初期値を渡した書き方で表示してください。

④ ② の配列の合計を、初期値を省いた書き方で表示してください。

JavaScript / TypeScript エディタ

コードを実行してください
QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1reduce に渡すコールバックの 1 つ目の引数に入るのは何ですか?

Q2要素どうしの views を比べて大きいほうを返す reduce に、初期値 0 を渡すと何が返りますか?

Q3空の配列に対して、初期値を省いた reduce を呼ぶと何が起きますか?