前端常需要把多批 API 結果、分組選項或巢狀清單合成一個陣列。reduce((acc, chunk) => [...acc, ...chunk], []) 看起來簡潔,卻會在每輪建立新陣列,並重新搬運先前已累積的元素。資料批次增加後,輸入延遲、長任務與記憶體配置可能先於畫面渲染成為瓶頸。

本文聚焦 JavaScript flatMap 陣列攤平 的選擇與驗證:何時用 flatMap,何時用可控的 push 累加,如何保留順序、空值政策與不可變邊界,以及如何不用不穩定的計時 benchmark,也能以固定資料證明 spread-reduce 的重複複製量。評估重構時,核心 KPI 是資料準備階段的長任務、輸入反應延遲、配置量與結果正確性,而不是單看語法行數。

reduce 搭配 spread 為何會放大陣列攤平成本?

每次回傳 [...acc, ...chunk] 都會建立新陣列,先重新展開累加器,再加入本輪資料;若有 n 個單元素批次,舊元素重複搬運次數為 0 到 n-1 的總和,因此結構上呈二次方成長。

問題不在 reduce 本身,而在 reducer 每次都用 spread 重建累加器。第一輪累加器是空的;第二輪要搬一次舊元素;第三輪要搬兩次;第 n 輪要搬 n-1 次。只計算「已累積元素被再次複製」就得到 n × (n - 1) / 2。這是由程式結構直接推導的搬運次數,不是特定瀏覽器的速度保證,也不需要用毫秒數包裝成普遍結論。

若每個 chunk 不只一個元素,總輸出本來就必須至少走訪一次;額外成本來自舊累加器在每輪被重新走訪。資料量小時差異可能不值得優化,但自動完成選項、圖表序列、匯入預覽或大量字串清單常在互動路徑反覆執行。這時應先看資料轉換是否重建大陣列,再調整畫面渲染筆數。

MDN 將陣列 spread 說明為把可迭代值展開到陣列常值中。它適合建立淺層副本或合併少量既有陣列;但「語意正確」不代表適合放在成長中的迴圈累加器。程式碼審查要問的是:這個 spread 會執行幾次,每次左側陣列已經多大,而不是只看單行是否優雅。

flatMap、flat 與 push 該如何選擇?

回呼需要同時轉換與攤平一層時用 flatMap;資料已是二維陣列時可用 flat(1);若需要單次迴圈、條件過濾或嚴格控制配置,則在函式內建立新累加器並以 push 加入元素。

flatMap 的優勢是意圖明確:對每個來源項目執行映射,並把回呼回傳的陣列攤平一層。以下為實作片段,假設 groups 已定義、每個 group 都是有效物件,且 values 是無空槽的普通陣列或缺值。在這個契約下,兩種寫法產生相同順序與值:

const values = groups.flatMap((group) => group.values ?? []);

const valuesWithPush = groups.reduce((result, group) => {
  result.push(...(group.values ?? []));
  return result;
}, []);

兩者都建立新的最外層結果,不會把資料塞回來源 groups。push 版本的 mutation 被限制在函式內新建立、尚未對外共享的 result,因此與修改 Redux state、React props 或快取陣列是不同風險。若團隊規則禁止任何可變寫法,可優先用 flatMap;若單一 chunk 可能非常大,還要評估 push(...chunk) 的引數展開上限,必要時改成內層 for...of 逐項 push。

flatMap 只攤平一層。來源若是 [[1, [2]], [3]],結果是 [1, [2], 3],不會遞迴變成 [1, 2, 3]。若需求是未知深度攤平,必須先定義資料契約;直接使用 flat(Infinity) 可能掩蓋上游形狀錯誤,並讓型別與錯誤位置更難追查。

正確性邊界:順序、空值、稀疏陣列與物件參考

替換攤平寫法前,必須先鎖定輸出順序、缺值政策、攤平深度與物件身分;只驗證元素數量相同不夠,因為錯序、靜默丟值或共享物件仍可能讓 UI 顯示錯誤資料。

第一個契約是順序。flatMap 依來源陣列的迭代順序處理項目,並依回呼輸出的順序加入結果。若原本 reducer 會排序、去重或插入額外項目,就不能只把語法替換成 flatMap。先寫固定輸入與完整期望輸出,再重構實作。

第二個契約是缺值。group.values ?? [] 只把 null 與 undefined 視為沒有清單,不會把空字串或數字零當成預設值;但若欄位契約要求陣列,這兩種值仍屬型別錯誤,不能直接送進攤平流程。若寫成 group.values || [],在欄位型別不穩定時可能吞掉其他 falsy 值。最好先驗證 Array.isArray(group.values);形狀錯誤應被標記成讀取失敗,而不是悄悄當成空資料。

第三個契約是淺層參考。spread、flat 與 flatMap 都不會深拷貝物件。結果陣列雖然是新的,但其中物件仍可能與來源共用參考。若後續程式會修改項目,應明確建立投影物件,例如 {...item},或維持不可變更新規則。物件 spread 也只複製一層;巢狀物件仍共享參考,必須依真正要隔離的欄位另行投影。不要把「外層陣列是新的」誤解為「所有內容都已隔離」。

第四個契約是稀疏陣列。flatMap 會略過來源空槽,並略過回呼回傳陣列中的空槽;陣列 spread 則會把普通稀疏陣列的空槽展開成 undefined。因此 flatMap 與 spread-push 不一定等價,不能直接套用密集陣列案例的結論。一般 API JSON 不會產生真正的 sparse slot,但手動設定長度或刪除索引可能造成空槽。若資料來源可能稀疏,測試要包含該形狀,不能只用連續索引的樣本推論。

在 React 資料流中如何避免把計算問題誤判成渲染問題?

先分開量測「取得資料、攤平與過濾」和「建立 React 元素、提交 DOM」兩段;如果資料準備已形成長任務,單純限制畫面筆數不會消除前段重複複製。Memo 快取命中時可以跳過計算,但快取未命中時,原實作的複製成本仍然存在。

大型 typeahead 常同時有三個不同成本:合併所有來源值、依輸入文字篩選、渲染可見選項。maxResults 只限制畫面結果數,不會自動減少前面建立完整候選陣列的工作;minLength 可以延後開始篩選,但若每次輸入仍先用 spread-reduce 重建大清單,主執行緒仍會做多餘搬運。

較穩定的流程是:資料收到時先驗證形狀並建立一次規範化清單;只有來源批次改變時才重新攤平;輸入改變時只執行必要的查詢或索引操作;顯示層再限制可見筆數。useMemo 只能在依賴參考穩定時重用結果。若上游每次 render 都建立新陣列,memo 依賴會一直改變,快取不會命中。

效能觀察應使用相同資料集與相同互動路徑,分別記錄資料準備函式的執行時間、瀏覽器 Long Task、輸入事件到畫面更新的延遲,以及記憶體配置趨勢。這些是應追蹤的工程 KPI,但本文不提供跨裝置通用門檻,也不宣稱換成 flatMap 必然改善某個百分比。先修正可證明的重複複製,再在目標瀏覽器量測。

驗證與重現:以 Node 26.7.0 檢查搬運量與語意

此離線測試用固定輸入驗證三種寫法輸出相同、spread 累加器的重複複製公式、flatMap 只攤平一層,以及來源陣列未被修改;排程環境已實際執行,不連線、不讀取真實資料。

  1. 在空白測試目錄把下列完整內容存成 array_flatten_contract_20261015.js,本次觀察到的執行版本為 Node.js 26.7.0;其他支援 flatMap 的現代 Node 版本可由讀者另行重現,但應記錄實際版本。
  2. 執行 node array_flatten_contract_20261015.js,要求程序結束碼為零,且四個 PASS 與最後 SUMMARY 4/4 全部出現。
  3. 檢查 100 個單元素 chunk 的案例是否輸出 4,950 次舊累加器元素重複複製;若公式、輸出長度或順序不符,測試必須非零結束。
  4. 修改 one-level 案例的期望值,刻意把 [2] 寫成 2 後重跑;斷言應失敗,證明測試能抓到「誤以為會遞迴攤平」的錯誤理解。
"use strict";

const assert = require("node:assert/strict");

function flattenWithSpread(chunks) {
  let copiedAccumulatorElements = 0;
  const result = chunks.reduce((accumulator, chunk) => {
    copiedAccumulatorElements += accumulator.length;
    return [...accumulator, ...chunk];
  }, []);
  return { result, copiedAccumulatorElements };
}

function flattenWithPush(chunks) {
  return chunks.reduce((accumulator, chunk) => {
    accumulator.push(...chunk);
    return accumulator;
  }, []);
}

function verifyEquivalentResults() {
  const chunks = [["a", "b", "c"], ["d", "e"], ["f", "g", "h", "i"], ["j"]];
  const spread = flattenWithSpread(chunks);
  const flatMapResult = chunks.flatMap((chunk) => chunk);
  const pushResult = flattenWithPush(chunks);

  assert.deepEqual(spread.result, flatMapResult);
  assert.deepEqual(pushResult, flatMapResult);
  assert.equal(spread.copiedAccumulatorElements, 17);
  console.log("PASS equivalent order and values; spread recopied 17 accumulated elements");
}

function verifyQuadraticCopyGrowth() {
  const chunkCount = 100;
  const chunks = Array.from({ length: chunkCount }, (_, index) => [index]);
  const spread = flattenWithSpread(chunks);
  const expectedRepeatedCopies = (chunkCount * (chunkCount - 1)) / 2;

  assert.equal(spread.copiedAccumulatorElements, expectedRepeatedCopies);
  assert.equal(spread.result.length, chunkCount);
  console.log(`PASS ${chunkCount} singleton chunks require ${expectedRepeatedCopies} repeated accumulator-element copies`);
}

function verifyOneLevelFlattening() {
  const nested = [[1, [2]], [3]];
  const result = nested.flatMap((chunk) => chunk);

  assert.deepEqual(result, [1, [2], 3]);
  console.log("PASS flatMap flattens one level and preserves deeper nested arrays");
}

function verifyInputIsNotMutated() {
  const chunks = [[1, 2], [3]];
  const snapshot = JSON.stringify(chunks);
  const result = chunks.flatMap((chunk) => chunk);

  assert.equal(JSON.stringify(chunks), snapshot);
  assert.notEqual(result, chunks[0]);
  console.log("PASS flatMap returns a separate result without mutating the source chunks");
}

function main() {
  verifyEquivalentResults();
  verifyQuadraticCopyGrowth();
  verifyOneLevelFlattening();
  verifyInputIsNotMutated();
  console.log(`SUMMARY 4/4 checks passed on Node ${process.versions.node}`);
}

try {
  main();
} catch (error) {
  console.error("FAIL", error);
  process.exitCode = 1;
}
PASS equivalent order and values; spread recopied 17 accumulated elements
PASS 100 singleton chunks require 4950 repeated accumulator-element copies
PASS flatMap flattens one level and preserves deeper nested arrays
PASS flatMap returns a separate result without mutating the source chunks
SUMMARY 4/4 checks passed on Node 26.7.0

本次真實執行輸出四個 PASS,包含「100 個單元素批次需要 4,950 次舊累加器元素重複複製」與「flatMap 保留更深層陣列」,最後為 SUMMARY 4/4 checks passed on Node 26.7.0,程序結束碼零。第一個小案例的舊累加器重複複製數為 17,三種實作產生相同順序與值。

這項測試證明固定 JavaScript 模型的語意與搬運計數,不是瀏覽器 benchmark,也不證明任何 React 頁面的實際輸入延遲。讀者可在隔離的前端測試專案加入瀏覽器 Performance trace、相同資料 fixture 與元件互動測試;這些是讀者可重現步驟,排程環境未執行。

重構檢查清單與結論

安全重構不是搜尋並取代所有 reduce,而是找出「每輪重建成長中累加器」的路徑,先用契約測試固定結果,再選擇能表達一層攤平或受控累加的實作。

提交前確認:來源確實是陣列集合;輸出順序有斷言;null、空陣列與錯誤形狀被區分;攤平深度只有一層;結果外層不與來源共用;物件項目是否需要投影已明確決定;React memo 依賴參考穩定;互動路徑以固定 fixture 重測;沒有用單次毫秒數推導跨環境結論。

適合 flatMap 的訊號是「每個輸入項目映射成零到多個輸出,而且只需攤平一層」。適合受控 push 的訊號是「需要在同一迴圈加入條件、驗證或去重,並能保證累加器只在函式內部使用」。仍適合 spread 的情境則是少量、非反覆成長的陣列合併,因為可讀性有時比微小差異更重要。

最終原則是把語法選擇連回資料流契約。reduce 不是錯,spread 也不是錯;錯的是在資料量與呼叫頻率不明時,讓成長中的結果被一再完整複製。用 flatMap 表達一層轉換與攤平,用局部 push 表達單次累加,再以順序、形狀、參考與搬運量測試驗收,才能同時守住可讀性與可預測性。

官方參考文件