前端常需要把多批 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 只攤平一層,以及來源陣列未被修改;排程環境已實際執行,不連線、不讀取真實資料。
- 在空白測試目錄把下列完整內容存成
array_flatten_contract_20261015.js,本次觀察到的執行版本為 Node.js 26.7.0;其他支援flatMap的現代 Node 版本可由讀者另行重現,但應記錄實際版本。 - 執行
node array_flatten_contract_20261015.js,要求程序結束碼為零,且四個PASS與最後SUMMARY 4/4全部出現。 - 檢查 100 個單元素 chunk 的案例是否輸出 4,950 次舊累加器元素重複複製;若公式、輸出長度或順序不符,測試必須非零結束。
- 修改 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 表達單次累加,再以順序、形狀、參考與搬運量測試驗收,才能同時守住可讀性與可預測性。