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