尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

JavaScript数组concat为何昂贵?性能优化与替代方案全解析

发布时间:2026/9/24 21:12:01

资讯中心
01
ARTICLE

JavaScript数组concat为何昂贵?性能优化与替代方案全解析

JavaScript数组concat为何昂贵?性能优化与替代方案全解析
工作里做数据清洗和前端可视化时我经常要处理上万条记录。早期代码里到处是list.concat(nextChunk)直到有一次在浏览器里预处理一个几万行的数据集肉眼可见地卡了半秒。当时我愣了一下不就是拼两个数组吗怎么这么慢查了一圈才意识到concat这个我们天天用的 API背后藏着一整套关于内存分配、线性遍历和垃圾回收的代价。这篇文章我想把这个问题彻底讲透concat到底贵在哪、引擎做了什么优化、各种替代写法各自有什么坑以及真实项目里应该怎么选型。1. 先说结论concat的昂贵来自三个层面问题为什么concat两个数组是昂贵的严格来说不是错误认知它背后的依据非常真实。要真正理解我建议从三个层面分开看时间复杂度、空间开销、GC压力。只讨论执行时间会漏掉一半重点而大多数人讨论这个问题时只盯着执行时间。1.1 为什么一个普通API会背上昂贵的标签数组的很多操作在开发者直觉里都接近O(1)。比如 push 在数组末尾追加一项在大多数引擎里确实是均摊O(1)按下标访问元素也是O(1)。但 concat 不是这样。它要合并两个数组结果是全新的数组因此工作量和两个数组的长度总和成正比。用一句话概括concat本质上是把两个数组的元素读取出来再组装成一个新数组。这个过程无论引擎怎么优化都至少要遍历一遍所有元素。所谓昂贵大部分时候是指这个线性遍历被放进了高频路径。比如你做一个数据处理流程每来一批数据就把它们 concat 进结果数组数据量一上来这个操作就从无感变成卡顿。在大数据分析与挖掘里这种流式接入数据块再合并的模式非常常见也是我实际遇到最多的问题场景。1.2 三个成本维度缺一不可我习惯把 concat 的成本拆成三块维度来源量级时间遍历源数组元素逐槽位写入结果O(nm)空间结果数组需要新的存储区域O(nm)GC旧数组在失去引用后必须被回收与创建速率正相关三块加起来才是完整的昂贵。如果你只优化其中一块另外两块依然可能成为瓶颈。这也能解释为什么有人用展开运算符[...a, ...b]替代 concat 后性能感觉没太大变化——因为两者的底层逻辑在引擎看来非常接近都要分配新数组、都要逐个写入、都要产生垃圾。换API并不是解药减少分配才是解药。2. concat到底干了什么从语义到运行时行为2.1 不可变操作意味着一个新数组concat 最大的特性是它不会修改原数组。这个特性看起来完全是个优点但运行时要付出代价。一个不能修改原数组的合并操作只能新建一个数组。在这个取舍下concat 和 push 走的是两条完全相反的技术路线push 是就地操作修改原数组不额外分配结果数组concat 是返回新数组原数组可以继续复用但新数组必须存在。如果你的业务场景完全接受把B追加到A后面之后A单独存在没有意义那 push 显然更省内存。如果你需要保留 A 和 B还要拿到合并后的新数组那 concat 或展开运算符绕不过去。2.2 每个槽位都要处理浅拷贝不是零拷贝很多人把 concat 理解成把两个数组连接起来好像它只是改变一个头尾指针。真正的实现是逐槽位拷贝function simpleConcat(a, b) { const result []; let index 0; for (let i 0; i a.length; i) { result[index] a[i]; } for (let i 0; i b.length; i) { result[index] b[i]; } return result; }这里有一个关键概念concat 是浅拷贝。它对元素中的对象复制的是引用不是对象内部的数据。验证方法很简单const source [{ name: tom }]; const merged source.concat([{ name: jerry }]); merged[0].name changed; console.log(source[0].name); // changed修改合并后数组里的对象会同步影响原数组。这个特性在性能上是好事因为避免了深拷贝整个对象图的巨大开销但对使用者来说它意味着结果数组并不完全独立。理解这一点之后会发现昂贵没有冤枉 concat它既不能做到零拷贝也不能在栈上完成必须处理 nm 个槽位。3. 容易被忽略的性能放大器分配与垃圾回收3.1 一次concat制造了多少垃圾举个例子A 数组有 5000 个元素B 数组有 2000 个元素执行const C A.concat(B)。此时内存里同时存在 A、B、C 三份数据其中 A 和 B 如果之后不再被使用就变成待回收对象。听上去总共 7000 个元素不多。但如果这个操作在循环里重复 100 次每次都把结果赋给同一个变量let list []; for (let i 0; i 100; i) { list list.concat(getChunk(i)); }第一次 concat 产生一个新数组第二次 concat 要把第一次得到的数据和新的数据合并又产生一个更大的数组。每次循环上一次的结果都会变成垃圾。100 次下来GC 要回收的数据总量不是最终 list 的长度而是所有历史中间数组的长度之和。这种写法看起来无害实际上就是数组版本的隐性内存放大——虽然对象最终不再被引用但它们的分配和回收本身会拖慢程序。3.2 为什么GC压力在大数组下格外明显GC 处理的内存越多单次整理耗时就越长。一个包含大量元素的数组往往是一段连续分配的大块内存。concat 产生新数组又需要另一段大块内存。如果旧数组的回收和新数组的分配交替进行堆里会产生更多碎片和工作量。从我的经验看最容易出问题的不是某一次大数组 concat而是反复 concat的累积效应。数据可视化、批量导入、实时日志聚合这些场景特别容易中招它们会不停接收新的数据分块如果每次都把新块 concat 进一个大结果数组用户就会感受到越来越明显的卡顿。另一个隐藏问题是老生代晋升。V8 的堆分年轻代和老生代新对象先分配在年轻代活过几轮 GC 后晋升到老生代。concat 产生的新对象如果活得很久就会被晋升如果反复创建又反复丢弃年轻代的清理负担就很大。这个机制听起来复杂但结论很简单大量临时数组的产生对 GC 很不友好。4. V8的优化现状它没有偷懒但也没法零成本4.1 elements kind与类型检查有读者会说V8 不是已经对 concat 做了非常激进的优化吗确实做了但没到零成本的程度。V8 会把普通数组分成若干 elements kind常见的有PACKED_SMI_ELEMENTS连续小整数PACKED_DOUBLE_ELEMENTS连续浮点数PACKED_ELEMENTS常规对象元素以及对应的 HOLEY_* 系列表示数组里有空洞concat 时V8 需要判断输入数组的 elements kind尽量让结果数组沿用紧凑的存储格式。如果两个数组都是 PACKED_SMI_ELEMENTS结果也可能保持为 PACKED_SMI_ELEMENTS如果里面混入浮点数或对象V8 就要做类型升级比如从 SMI 转为 DOUBLE或者转为通用对象存储。这种类型检查和处理不可能完全免费。当数组特别大时类型升级甚至会造成额外的一次重写。好在大多数场景下摊到每个元素的成本比较小一般感知不到——除非在极端循环里反复执行。4.2 为什么大数组依然没有零拷贝路径从技术上看一个可能的优化方向是确认所有源数组的存储布局一致后直接做一次底层内存复制把源数组的数据整体搬到新数组。但即便这样结果数组仍然是一块新分配的内存。concat 的语义就是返回一个新数组所以创建新存储区这个行为无法被优化掉。如果数组元素是对象引用引擎还需要维护写屏障等机制保证 GC 在标记阶段能正确追踪引用关系。这些机制在幕后增加开销但在业务代码层面我们是看不见的。换个角度说concat 在性能和可读性之间已经取得了不错的平衡。问题不在于这个 API 有多笨而在于任何线性扫描线性分配GC负担的操作在数据规模放大之后都会暴露成本。5. 替代方案横评concat、展开运算符、push、手写循环5.1 四种写法的核心差异我把常见写法列出来先看它们的语义差异写法是否创建新数组是否修改原数组是否会触及参数上限a.concat(b)是否否[...a, ...b]是否有风险a.push(...b)否是有风险Array.prototype.push.apply(a, b)否是有风险for循环内 push否是否如果要保留不可变语义只能在 concat 和展开运算符之间选。两者都创建新数组底层行为很接近。如果业务允许修改原数组push 系列可以避免新数组分配把新数据追加到数组尾部内存压力小得多。5.2 参数上限是个潜藏的地雷展开运算符写起来方便但很多人没意识到它本质上是把数组元素转换成函数调用参数而函数参数是有数量上限的。const big new Array(200000).fill(0); const target []; target.push(...big); // 大概率 RangeError不同引擎和不同环境的限制有差异但通常几万个参数以上就开始出问题。也就是说数十万级数组的展开操作不只是慢一点的问题而是可能直接抛异常。如果想继续用展开写法需要分段处理const big new Array(200000).fill(0); const target []; const chunkSize 50000; for (let i 0; i big.length; i chunkSize) { target.push(...big.slice(i, i chunkSize)); }这里的 slice 会临时产生一个分段副本但相比整个展开直接失败这是可接受的代价。5.3 不同数据规模下的选择框架我自己的选择逻辑大致如下千级以下优先可读性concat 或展开运算符都行。万级到十万级偶尔合并一次concat 问题不大如果在循环里反复合并改用 push 就地追加。百万级及以上别在循环里用 concat 制造新数组。考虑预分配固定长度结果数组后分批写入或者改用更紧凑的 TypedArray 存储数值。最后提醒一句不要迷信某某写法一定比某某快。JS 引擎迭代很快今天的 benchmark 到下个版本可能就变了。稳定可靠的判断依据永远是谁分配更少、谁拷贝更少、谁更容易触发类型转换而不是某篇文章里的某个数字。6. 工程实践什么场景该用什么场景要避开6.1 可以放心用concat的场景一是数据量小的时候。比如配置项合并、接口返回的少量数据预处理、表单字段拼接。几千个元素以内的 concat开销通常不到一毫秒没必要为了省这一点点性能去牺牲代码可读性。二是必须保证原数组不变的场景。如果你要把一个合并结果传给下游同时希望调用方不会意外修改源数据concat 是自然选择。相比展开运算符符号化的写法concat 的可读性更直白也更不容易被人误改。6.2 坚决避免concat的场景最典型的就是循环累加let result []; for (const record of largeRecords) { result result.concat(record.children); }records 很多、每个 children 又不短时这个循环会产生大量中间数组和垃圾。更稳的做法是先遍历一遍把每个 children 的长度加总算出总长度再一次性填充或者直接用 push 把 children 追加到初始数组里。另一个应该避免的场景是超大数组的多次复制。二百万元素的数组如果内存里同时存在原数组、副本、各种分片内存占用会翻好几倍。与其不断 concat不如想想能不能把合并延迟到真正需要读取时。6.3 我实际验证过的三个优化思路第一个思路是分片而不合并。如果业务只是要按顺序访问全部数据可以保留一个数组的数组每个元素都是一个数据块再用一个长度索引来定位某个下标在哪一块。这样就没有复制也没有 GC 压力。第二个思路是预分配后写入。在知道总长度的情况下先创建长度确定的结果数组再写循环。这在数值型的大数组场景比较稳function mergeNumeric(a, b) { const total a.length b.length; const result new Array(total); let i 0; for (let idx 0; idx a.length; idx) result[i] a[idx]; for (let idx 0; idx b.length; idx) result[i] b[idx]; return result; }第三个思路更贴近底层如果数据本来就是数值数组用 TypedArray 承载分配更紧凑整体复制也更高效。const a new Float64Array([1, 2, 3]); const b new Float64Array([4, 5]); const merged new Float64Array(a.length b.length); merged.set(a); merged.set(b, a.length);typedArray.set更接近底层内存复制比逐元素 JS 赋值更高效。缺点是它只能放数字不能直接放对象。7. 最后聊几个容易踩到的细节7.1 稀疏数组会保留empty很多人会忽略稀疏数组这种特殊情况。concat 遇到带空洞的数组时不会把空洞转成 undefined而是保留 emptyconst sparse [1, , 3]; console.log(sparse.concat([4])); // [1, empty, 3, 4]在 forEach、map 里empty 位置会被跳过但用 for 循环按下标访问读到的却是 undefined。这种不一致很容易让依赖数组没有空洞的逻辑出 bug。做去重、排序、区间求和之前最好先用 Array.from 或 fill 方式把空洞补上再做合并。7.2 concat支持非数组参数但不会递归扁平化concat 可以直接接收普通值也可以同时接收多个数组和一个普通值[1, 2].concat(3, [4, 5]); // [1, 2, 3, 4, 5]但当元素本身是数组时concat 不会递归展开const nested [[1], [2]]; console.log(nested.concat(3)); // [[1], [2], 3]如果你要合并的是二维数组的某个子层级需要先 map 一层或使用 flat再去 concat。否则得到的结果形状不符合预期。7.3 空数组concat的隐藏分配不要小看arr.concat([])这行代码。它看起来人畜无害但因为 concat 永远返回新数组即使参数为空数组它也会完整复制整个 arr。等于你白白触发了一次 O(n) 遍历和一次新数组分配。如果这个写法出现在高频函数里并且 arr 本身是个不断变大的数组那它就是一条隐蔽的性能消耗路径。改成arr.slice()虽然也是复制但至少语义上更清晰如果根本不需要复制直接返回 arr 就行。8. 回到最初的问题何时才需要把concat当成一个昂贵操作综合来看concat 不是绝对意义上的慢而是它在语义上承担了不可变合并这个职责所以必须付出线性遍历、新内存分配和随之而来的 GC 代价。对一个很短的数组来说这根本不重要对一个很大的数组来说如果你还在循环里执行它就要重新审视数据流设计。真正的解决方案通常是把合并这个动作从高频路径中移除或者改成更低成本的就地追加/分段访问而不是在 concat 和展开运算符之间纠结谁快。我在项目里受益最多的一条经验是先搞清楚数据接下来要经历哪些操作、能不能接受它被修改、生命周期终点在哪里再决定用哪个 API。技术选型一旦基于真实约束就不会再被xxx 是不是更贵这种问题困扰。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。