組添加元素6種方法:從push到展開運算符的性能與選型)
做前端這些年被問得最多的一類問題就是往數(shù)組里添加元素有哪幾種寫法很多人脫口而出push想一下再補一個unshift能說出六種以上的其實不多。更別提問一句“為什么unshift慢慢多少什么場景該選哪種”——能講清楚的人更少。這篇文章就把 JS 數(shù)組添加元素的 6 種方法挨個拆開從語法、返回值、底層存儲到性能實測再結(jié)合我自己的項目經(jīng)驗給出選型建議。內(nèi)容適合初中級前端、準備面試的同學(xué)也適合正在做性能優(yōu)化又不想盲目換數(shù)據(jù)結(jié)構(gòu)的同行。1. 先分清兩類操作原地修改與原數(shù)組不變1.1 為什么一定要先搞清楚“改沒改原數(shù)組”數(shù)組添加元素的方法看似零散其實可以一刀切成兩類一類會直接修改原數(shù)組另一類不會。先列個清單原地修改push、unshift、splice、arr.length ...、arr[index] ...返回新數(shù)組concat、展開運算符[...]這個分類不是面試炫技用的它直接決定了你在什么場景下敢用哪個方法。舉個例子在 React 的 state 里管理列表如果你直接push就算數(shù)據(jù)變了組件也可能不更新因為 state 引用沒變在函數(shù)式編程里副作用會引起各種隱蔽 bug。反過來在高頻操作的內(nèi)部邏輯里如果每加一個元素就復(fù)制一次整個數(shù)組內(nèi)存和 GC 開銷也會讓你難受。所以每次寫“加元素”之前先回答自己這個數(shù)組能不能被修改1.2 一個類比的快速理解把數(shù)組想象成一份貼在墻上的名單。push就是在名單末尾追加一個新名字原名單還在返回值是你重新數(shù)了一遍的總?cè)藬?shù)。unshift更像插隊你要把所有人的名字往后挪一格再把新名字貼到最前面。concat和展開運算符就是完全不碰原來那份名單重新抄一份完整的名單新名字按你的要求出現(xiàn)在某個位置原來的名單保存不動。抄名單這種操作在人數(shù)少的時候很輕松人數(shù)一多每次都要把整個名單謄一遍自然就慢。這個類比能解釋大部分性能差異原地操作用的是同一份名單不可變操作每次都造一份新名單頭插和中間插入需要挪動名單上的人名挪動數(shù)量越多越慢。接下來逐個看具體方法。1.3 六種方法的基本行為一覽方法語法示例是否改原數(shù)組返回值插入位置pusharr.push(item)是新 length尾部unshiftarr.unshift(item)是新 length頭部splicearr.splice(i, 0, item)是被刪除元素數(shù)組任意位置concatarr.concat(item)否新數(shù)組尾部展開運算符[...arr, item]否新數(shù)組尾部或任意位置length/索引賦值arr[arr.length] item是元素值尾部這張表先給你一個整體印象原地修改的方法里能避免搬移元素的操作必然更快返回新數(shù)組的方法必然有一次完整復(fù)制。記住這個基本判斷再看后面性能和引擎原理的部分就不會亂。2. 六個方法逐個拆解語法、行為、隱藏細節(jié)2.1push尾部追加push是數(shù)組添加元素最常用的方法。它的語法是arr.push(element1, element2, ..., elementN)可以一次傳入多個參數(shù)所有參數(shù)會按順序追加到數(shù)組末尾。它的返回值很有特點是操作完成后數(shù)組的新長度而不是被添加的元素。這一點經(jīng)常有人記錯。push的性能優(yōu)勢來自它的操作路徑特別短引擎只需要知道當(dāng)前數(shù)組長度是多少然后在那個下標(biāo)位置寫入新值再把長度加一。如果數(shù)組預(yù)留的空間不夠了就觸發(fā)一次擴容把容量擴大。擴容是成倍擴大所以均攤到每次添加也是常數(shù)時間。這也是為什么尾部插入在絕大多數(shù)情況下都是最快的添加方式。需要提醒的是push是原地修改方法它會把原數(shù)組改掉。如果函數(shù)內(nèi)部對入?yún)?shù)組做了push外層拿到的數(shù)組也會變。想避免這個副作用就得用后面的concat或展開運算符。2.2unshift頭部插入unshift的語法和push幾乎對稱arr.unshift(element1, ..., elementN)直接在數(shù)組最前面插入一個或多個元素返回值同樣是新數(shù)組的length。它也是原地修改。但等價的語法不意味著等價的性能。頭部插入意味著原本位于 0 號索引的元素要變成 1 號索引1 號變 2 號所有已有元素都要往后挪一位。數(shù)組本質(zhì)上是連續(xù)內(nèi)存的線性結(jié)構(gòu)這個搬移操作是 O(n)n 是數(shù)組長度。數(shù)組越長unshift越慢。這里藏著一個容易踩的坑當(dāng)一次unshift傳入多個元素時參數(shù)順序沒有被反轉(zhuǎn)。比如arr [1, 2]執(zhí)行arr.unshift(3, 4)之后結(jié)果是[3, 4, 1, 2]不是[4, 3, 1, 2]。因為引擎的邏輯是把參數(shù)按順序放到數(shù)組頭部的連續(xù)區(qū)域里。很多人下意識以為會像“從前往后插”那樣反過來實際不是。2.3splice任意位置插入splice是數(shù)組里最“多功能”的方法刪除、替換、插入一套全包。往數(shù)組添加元素的用法是arr.splice(start, 0, item1, item2, ...)第二個參數(shù)傳 0 表示不刪除任何元素后面的參數(shù)就是要插入的元素。它返回一個新數(shù)組里面是實際被刪除的元素如果沒刪除任何元素返回空數(shù)組。splice也是原地修改。start參數(shù)支持負數(shù)比如arr.splice(-1, 0, x)表示在倒數(shù)第一個元素之前插入等于把數(shù)組長度加一新元素處在倒數(shù)第二位。負數(shù)規(guī)則和slice一致但如果對負數(shù)場景不熟很容易搞混建議先打印測試一遍再寫進代碼。splice在中間位置插入的成本取決于插入點越靠前需要向后搬移的元素越多越靠后搬移越少。最壞情況是在 0 號位置插入復(fù)雜度同樣是 O(n)和unshift一個量級。所以它不是一個“想插哪就隨手插”的方法數(shù)據(jù)量大的時候要慎重。2.4concat無副作用的拼接concat的本意是連接多個數(shù)組或值語法是arr.concat(value1, ..., valueN)。如果參數(shù)是數(shù)組它會把數(shù)組元素打平一層加進去如果參數(shù)是普通值就作為單個元素追加。關(guān)鍵點concat不會修改原數(shù)組而是返回一個新數(shù)組。這是添加元素時最常用的不可變方案之一。比如arr.concat(4, [5, 6])得到一個新數(shù)組[舊元素..., 4, 5, 6]原數(shù)組保持原樣。注意它只展開數(shù)組第一層如果參數(shù)是嵌套數(shù)組比如arr.concat([1, [2]])最終結(jié)果是[..., 1, [2]]內(nèi)層嵌套會被保留。因為concat內(nèi)部要遍歷原數(shù)組的所有元素再拼接上新元素所以整體時間復(fù)雜度是 O(n)。這個 n 包含原數(shù)組長度。也就是說在 10 萬個元素的數(shù)組后面加 1 個元素concat要復(fù)制十萬個元素到一個新數(shù)組里而不是在原數(shù)組上追加一個。性能自然比push差但它換來的是原數(shù)組不被污染在不可變數(shù)據(jù)場景里值這個代價。2.5 展開運算符更現(xiàn)代的不可變寫法ES6 之后展開運算符成了構(gòu)造新數(shù)組最受歡迎的寫法。尾部添加是[...arr, newItem]頭部添加是[newItem, ...arr]中間插入可以配合slice[...arr.slice(0, i), item, ...arr.slice(i)]。它和concat一樣不修改原數(shù)組但可讀性更強而且能直接用在數(shù)組字面量里。不過要注意展開運算符的適用范圍是“可迭代對象”不只是數(shù)組。字符串可以被展開成字符數(shù)組Set、Map 也可以。如果想展開一個類數(shù)組對象比如arguments直接展開是可行的因為arguments是可迭代的但如果是一個純粹的類數(shù)組比如{0: a, length: 1}它其實不能用...直接展開這時往往需要Array.from。展開運算符的底層邏輯也是“復(fù)制整個數(shù)組再添加”復(fù)雜度 O(n)。在 React 的useState更新列表時我用得最多的就是[...prev, newItem]它比concat更直觀也避免了原地修改 state 的坑。2.6 直接利用length和索引最樸素的底層寫法嚴格說這不算一個新 API而是一類底層技巧通常有兩種寫法第一種是arr[arr.length] value。因為數(shù)組索引從 0 開始arr.length始終指向最后一個元素后一格的空位所以這種寫法等同于push但返回值不同push返回新length這種賦值返回value。在微基準測試里它有時候比push更快因為它省了一次函數(shù)調(diào)用不過差距非常小。第二種是直接改arr.length。比如arr.length arr.length 1這會往數(shù)組末尾追加一個空槽empty slot數(shù)組長度變大但值沒有初始化或者arr.length 0可以清空數(shù)組。利用length給數(shù)組“擴容”添加空槽的做法我不推薦在業(yè)務(wù)代碼里用因為空槽會造成非常隱蔽的問題數(shù)組方法在處理空槽時行為不一致map、filter、forEach會跳過空槽但some和find又會把它當(dāng)作undefined去執(zhí)行。后面第 5 章我會寫一個真實踩坑案例。3. 性能實測十萬次添加操作結(jié)果可能顛覆直覺3.1 測試代碼怎么設(shè)計才公平網(wǎng)上有很多數(shù)組性能對比的代碼但不少寫得不太公平。比如測concat和展開運算符時有人直接寫成console.time(concat); for (let i 0; i 100000; i) { arr.concat(i); } console.timeEnd(concat);這里的問題在于每次concat的返回值沒有賦值回去結(jié)果concat雖然復(fù)制了數(shù)組但復(fù)制出來的新數(shù)組被丟掉了原數(shù)組還是最初那一個循環(huán)多少次復(fù)制的都是同一個長度的數(shù)組。但在真實使用場景里你會把新數(shù)組重新賦值回去數(shù)組會不斷變大所以更公平的測試要模擬真實的累積操作。我這里提供一個簡化版但相對公平的基準測試思路const N 100000; const rounds 5; function testPush() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.push(i); return performance.now() - start; } function testIndexWrite() { let arr []; const start performance.now(); for (let i 0; i N; i) arr[arr.length] i; return performance.now() - start; } function testUnshift() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.unshift(i); return performance.now() - start; } function testSpliceEnd() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.splice(arr.length, 0, i); return performance.now() - start; } function testSpliceStart() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.splice(0, 0, i); return performance.now() - start; } function testConcatEnd() { let arr []; const start performance.now(); for (let i 0; i N; i) arr arr.concat(i); return performance.now() - start; } function testSpreadEnd() { let arr []; const start performance.now(); for (let i 0; i N; i) arr [...arr, i]; return performance.now() - start; }跑的時候每個函數(shù)各執(zhí)行rounds次取中位數(shù)更能反映真實情況。注意先把 N 設(shè)小一點比如 10000 跑通再根據(jù)機器調(diào)整否則unshift和concat那一組可能會讓你等到懷疑人生。3.2 實測結(jié)果和解讀在我這邊 Node 20 環(huán)境下N100000、rounds5 的前提下結(jié)果大概是這樣的數(shù)值僅供參考不同機器會有浮動但相對關(guān)系很穩(wěn)定方法操作位置是否修改原數(shù)組平均耗時10萬次復(fù)雜度arr[arr.length] i尾部是約 2msO(1)push(i)尾部是約 3msO(1)splice(arr.length, 0, i)尾部是約 12ms平均 O(1)但有函數(shù)調(diào)用開銷unshift(i)頭部是約 950msO(n)splice(0, 0, i)頭部是約 1500msO(n)concat(i)尾部否約 3800msO(n)[...arr, i]尾部否約 4000msO(n)更直觀一點push和直接索引賦值跑完只要幾毫秒unshift要做元素搬移直接到了秒級。而concat和展開運算符因為每次循環(huán)都復(fù)制整個不斷變大的數(shù)組耗時呈現(xiàn)累加的二次方增長所以比預(yù)期還要慢很多。這個結(jié)果不是特地去調(diào)出來的。背后的規(guī)律就是只要復(fù)制整個數(shù)組n 越大越吃虧只要頭部插入需要搬移所有元素n 越大越吃虧。唯一接近“免罪”的尾部原地操作因為不需要搬移元素才會在長數(shù)組上遙遙領(lǐng)先。3.3 大數(shù)組批量添加時的參數(shù)展開陷阱如果測試里把循環(huán)改成push(...bigArray)這種寫法會掉進另一個坑。JS 引擎對函數(shù)參數(shù)數(shù)量是有限制的當(dāng)展開的數(shù)組元素數(shù)量過大在 V8 中大約超過 65535 個就會引發(fā)RangeError: Maximum call stack size exceeded。unshift(...bigArray)和splice(0, 0, ...bigArray)同理。所以批量往數(shù)組里塞大量數(shù)據(jù)的時候不要圖省事用展開語法當(dāng)參數(shù)。推薦下面這幾種方式原數(shù)組可以被修改直接for循環(huán)push想保持不可變使用arr.concat(bigArray)concat能接收數(shù)組作為參數(shù)不需要把數(shù)組展開成參數(shù)列表如果必須用push批量追加可以arr.push(...bigArray.slice(0, 5000))分段展開再繼續(xù)下一段但這只是繞開參數(shù)上限的方案不是最優(yōu)我記得有一次線上數(shù)據(jù)同步功能就是因為在循環(huán)里用了push(...chunk)chunk 一次一萬條沒事某天業(yè)務(wù)方給了個五萬條的 chunk然后就報錯中斷了。從那以后我對“展開給函數(shù)傳參”都多留了一份心眼。4. 為什么 push 比 unshift 快那么多V8 引擎的數(shù)組存儲機制4.1 快數(shù)組和慢數(shù)組要理解性能差異得看點引擎內(nèi)部的東西。V8 里面數(shù)組最常見的是“快數(shù)組”Fast Elements它的元素存儲在連續(xù)內(nèi)存上索引直接對應(yīng)內(nèi)存偏移訪問復(fù)雜度 O(1)??鞌?shù)組內(nèi)部還會根據(jù)數(shù)組里存的元素類型做更細的分類比如全是整數(shù)、全是 double、全是對象、混合類型不同的 ElementsKind 對應(yīng)不同的存儲和優(yōu)化策略。這也是為什么如果你真的追求性能盡量保持數(shù)組元素類型一致。當(dāng)數(shù)組變得非常稀疏、或者索引跳躍太離譜V8 可能把它降級成“慢數(shù)組”Dictionary Elements本質(zhì)上是哈希表結(jié)構(gòu)索引變成長度很長的 key。這種數(shù)組元素訪問不再是簡單偏移性能會下降很多。所以在業(yè)務(wù)代碼里動不動就arr[100000] 1這樣的寫法可能讓數(shù)組直接變成字典模式后續(xù)所有操作都變慢。4.2 unshift 的底層搬移過程unshift為什么慢本質(zhì)上就是“騰位置”。假設(shè)現(xiàn)在快數(shù)組里有[a, b, c, d]內(nèi)存里是連續(xù)的一塊。要在頭部插入 x引擎不能像鏈表那樣只改指針?biāo)仨毎?a、b、c、d 這四個元素整體往后移動一個槽位再把 x 放到 0 號位置。這個搬移是逐元素 copyn 個元素就要搬 n 次數(shù)組長度越大耗時越長。splice在頭部插入也是同樣的搬移邏輯所以兩個方法在頭部場景下都是 O(n)。有趣的是push和arr[arr.length]之所以快是因為它們在數(shù)組尾部寫入不需要搬移任何已有元素最多只是數(shù)組本身的容量不夠時觸發(fā)擴容。V8 的擴容是成倍數(shù)增長均攤到每次添加是常數(shù)時間整體就是 O(1)。4.3 中間插入其實是“半截搬家”splice在中間插入的執(zhí)行過程類似從插入位置開始把后半段元素往后挪一位再把新元素填進去。涉及的元素數(shù)量不是整個數(shù)組而是插入位置后面的那一截。所以它的平均復(fù)雜度也是 O(n)但常數(shù)系數(shù)比unshift小。數(shù)組長度為 n 時插入在頭部要搬 n 個元素插入在中間平均搬 n/2 個插入在尾部幾乎不用搬。這也解釋了測試中splice(arr.length, 0, i)為什么比splice(0, 0, i)快得多。想驗證這個結(jié)論也很簡單在插入位置附近打印一下下標(biāo)或者用不同位置各跑一遍基準。實際項目中如果必須在中間大量插入元素可以考慮先把數(shù)組拆成前后兩段分別組合或者干脆換數(shù)據(jù)結(jié)構(gòu)。4.4 不可變操作的成本被很多人低估concat和展開運算符表面上只是“方便地返回新數(shù)組”但這個新數(shù)組不是憑空出現(xiàn)的。它需要先根據(jù)原數(shù)組長度申請一塊新內(nèi)存然后把原數(shù)組的元素一個個復(fù)制過去再追加新元素。這意味著每次操作都要遍歷 n 個舊元素。如果你在一個循環(huán)里不斷往同一個數(shù)組尾部添加每次數(shù)組長度都會增加每次都要復(fù)制更大的數(shù)組總耗時接近 O(n2)。所以如果數(shù)據(jù)量大又堅持不可變操作性能通常不會好看。實際工程中我們要做的不是“永遠不用concat”而是知道在什么臨界點開始需要思考優(yōu)化。比如一個列表只有幾百條spread 怎么寫都無所謂一旦上萬條且頻繁更新就應(yīng)該考慮改成增量更新或者用 immer 這類工具來管理狀態(tài)而不是硬扛著每次復(fù)制大數(shù)組。5. 真實項目里的選型建議與避坑指南5.1 不同場景應(yīng)該選哪個方法根據(jù)自己的項目和踩坑經(jīng)歷我做了一個簡單的決策對照表場景推薦寫法原因純粹在尾部追加原數(shù)組可修改arr.push(item)性能最好語義清晰尾部追加但不想引入副作用[...arr, item]或arr.concat(item)不改變原數(shù)組適合 state 更新偶爾在頭部插入數(shù)組不大arr.unshift(item)代碼可讀性最好性能可接受頻繁在頭部插入數(shù)組很大改用尾部push 最后reverse或換數(shù)據(jù)結(jié)構(gòu)避免每次 O(n) 搬移任意位置單次插入arr.splice(index, 0, item)功能完整注意 index 越界不可變地任意位置插入[...arr.slice(0, i), item, ...arr.slice(i)]不污染原數(shù)組可讀性好合并多個大數(shù)組arr.concat(bigArr1, bigArr2)避免參數(shù)展開上限問題決策時我一般只問三個問題第一能不能修改原數(shù)組不能直接進concat/spread 分支第二數(shù)據(jù)量大概多大超過一萬條就要開始警惕 O(n) 操作第三操作位置在哪頭部和中間插入都是 O(n)要盡量規(guī)避。5.2 我踩過的幾個典型坑先說最經(jīng)典的一個用length賦值制造稀疏數(shù)組。let arr [1, 2, 3]; arr.length 5;此時 arr 變成[1, 2, 3, 2 empty items]。如果直接arr.map(x x * 2)結(jié)果會是[2, 4, 6, 2 empty items]右側(cè)空槽依然存在且被跳過很多人在這一步以為map出 bug 了。更詭異的是arr.forEach不會遍歷空槽但arr.find會認為空槽是undefined行為不一致會帶來非常難排查的數(shù)據(jù)問題。所以除非你能確定空槽不影響業(yè)務(wù)否則別用length賦空槽。第二個坑是 React 等框架里的 state 數(shù)組直接push。我見過不止一次這樣的代碼const [list, setList] useState([]); function addItem(item) { list.push(item); setList(list); }list 的引用沒變React 做淺比較的時候會認為 state 沒變組件不重渲染。正確寫法是setList([...list, item])或setList(list.concat(item))。這個坑背后就是第 1 章說的“原地修改 vs 返回新數(shù)組”的分類問題面試官愛考它是有道理的。第三個坑是批量插入用了展開運算符當(dāng)參數(shù)。前面 3.3 節(jié)也說過push(...bigArray)存在參數(shù)上限的問題在大數(shù)據(jù)量時直接拋異常。一旦發(fā)現(xiàn)問題換成concat或者循環(huán)push就穩(wěn)多了。5.3 如果真的要頻繁首尾插數(shù)組不是好選擇當(dāng)你確認業(yè)務(wù)就是高頻地在數(shù)組頭部或尾部插入并且數(shù)據(jù)量很大光靠“用push代替unshift”是不夠的。一個常見做法是維護“雙端隊列”的思維內(nèi)部用兩個數(shù)組一個正向存后半段一個反向存前半段。往頭部插入時插到反向數(shù)組的尾部往尾部插入時插到正向數(shù)組的尾部需要讀取時把反向數(shù)組反轉(zhuǎn)后和正向數(shù)組合并。這樣頭部插入也變成 O(1)。另一個更簡單粗暴的替代方案實在不行用循環(huán)數(shù)組或者單向鏈表。JS 沒有原生鏈表但你完全可以用對象模擬一個節(jié)點結(jié)構(gòu)或者引入現(xiàn)成的數(shù)據(jù)結(jié)構(gòu)庫。不過要注意鏈表犧牲了隨機訪問能力如果業(yè)務(wù)上既要隨機訪問又要頻繁頭插那就要權(quán)衡了。我自己的經(jīng)驗是大多數(shù)業(yè)務(wù)場景其實頭插頻率沒那么高真有那么高的時候重新審視一遍數(shù)據(jù)模型往往能找到一個可以reverse或分段處理的方案。提示別做“為了優(yōu)化而優(yōu)化”的事情。如果你的數(shù)組只有幾十上百個元素unshift和splice隨便用肉眼根本感覺不到區(qū)別。先把代碼可讀性保住等到性能瓶頸真實出現(xiàn)再按本章思路去改。我個人在項目里的習(xí)慣是默認情況下一律用push需要不可變更新時用展開運算符偶爾插中間用splice但會先把數(shù)據(jù)規(guī)模放在心里。遇到需要頻繁頭插的大數(shù)組第一反應(yīng)不是急著換個方法而是先懷疑數(shù)據(jù)模型是否合理。把這三件事想明白數(shù)組添加元素這件事基本就不會再踩坑了。