最近在做某个内部工具的重构时遇到一件有意思的事表格组件里有一列数据需要做排序前快照我当时顺手把原数组直接传进了处理函数结果一排序原数据也跟着变了。排查了半天才发现是引用传递的锅。这种“数组不小心被修改”的体验相信不少前端同行都有过。今天就把我在实战里对“复制数组”这件事的全部理解包括各种方法、原理、适用场景和踩坑经验完整整理出来。先从结论说起JavaScript 里根本没有一条命令能叫“复制数组”但实现复制的手段非常多展开运算符、slice、concat、Array.from、map、filter、structuredClone、JSON序列化……选哪个取决于你到底需要“多新的数组”以及数组里装的是什么。下面我把这些方法逐一拆开讲清楚。1. 为什么“复制数组”看起来简单坑却这么多1.1 一次事故现场赋值到底复制了什么我最初踩坑的代码长这样const original [3, 1, 2]; const copy original; copy.sort((a, b) a - b); console.log(original); // [1, 2, 3] console.log(copy); // [1, 2, 3]原本我只想排副本结果原数组也被悄悄改了。原因在于const copy original这行不是“复制”它只是把同一个数组对象的引用又存到了另一个变量里。就像你把家里的钥匙多配了一把交给别人对方开门进来把客厅重新装修了你这边看到的自然也是装修后的样子。理解这个点要先从 JavaScript 的数据类型说起。基础类型数字、字符串、布尔值存的是真实的值复制变量时是真的拷贝了一份。而数组属于对象变量里存的是指向堆内存中那个数组对象的地址。用赋值复制的是地址不是数据本身。这是所有数组复制问题的根源。如果不理解后面的浅拷贝、深拷贝都会绕晕。1.2 复制需求的不同深度浅拷贝和深拷贝很多人以为“复制数组”就是“newArr 旧数组”实际需求分两个层级浅拷贝复制第一层元素。如果元素本身是基础类型那新的数组里就是全新的值如果元素是对象或嵌套数组新数组里存的是这些对象/数组的引用。深拷贝递归复制所有层级的元素最终得到的数组和原始数组完全独立任何一层嵌套的数据都不共享引用。举个具体例子const team [{ name: A, score: 80 }, { name: B, score: 90 }]; const shallow [...team]; const deep JSON.parse(JSON.stringify(team)); shallow[0].score 100; console.log(team[0].score); // 100被影响了 deep[0].score 60; console.log(team[0].score); // 100deep 不影响原数组实际开发中判断用浅拷贝还是深拷贝的通用标准是你需要复制的数组里的元素是否是纯基础类型。如果全是数字、字符串、布尔值数组之间不共享引用一层浅拷贝完全够用只要有对象、数组混在里面浅拷贝后的数组在嵌套层级上依然藕断丝连。1.3 决定选型的关键场景结合我在不同项目里的实际经验常见的复制需求大概有三种场景第一种是函数入参保护。写工具函数时不希望函数内部对数组的修改影响到外部调用方比如排序、反转、splice 操作这时一般做一层浅拷贝就够了因为入参数组的元素通常是基础类型或者你可以约定函数只改第一层结构。第二种是状态管理中的不可变数据更新。在 Vue 或 React 这类框架里要触发列表更新需要生成一个新的数组引用而不是改动原数组。比如给列表项设置选中状态、新增一条记录、删除一条记录都需要复制后修改再替换。这里大多数情况下浅拷贝就满足因为你会把每一项当成不可变对象处理。第三种是对数组做“快照”。比如搜索前保留原始顺序、表单初始值和当前值对比、导出的数据副本等。这时候如果数组里嵌套了对象就需要深拷贝。选型一定要先想清楚场景再决定用哪个方法。否则就是拿锤子找钉子看着能用后面必出幺蛾子。2. 浅拷贝方法全家桶怎么选有什么区别2.1 展开运算符现代项目的主力方案const original [1, 2, 3]; const copy [...original];展开运算符是 ES6 引入的语法目前是现代前端项目里最常用的浅拷贝方案。它把所有可迭代元素展开到一个新的数组字面量里天然生成一个全新数组。它最大的优点是写在左边能直观表达“我要重新组装一个新数组”的意图一行代码、可读性强还能顺带追加新元素const withExtra [...original, 4]; const merged [...arr1, ...arr2];我自己用下来的感觉是90% 的浅拷贝需求直接上展开运算符就够了。它速度快、写法简洁、不依赖任何库是当之无愧的首选。但要留意展开运算符只浅拷贝一层。如果数组元素是对象复制后的数组里这些对象引用不变。这点在下一章展开讲。还有一个很容易混淆的用法展开运算符和 rest 参数长得一模一样。在一个对象或数组的“收集”场景...rest是把剩余元素放进一个新数组在赋值场景它才是展开。别搞混。2.2 slice()老牌方法为什么还没退休const copy original.slice(); const copyFromIndex original.slice(1, 3);slice()返回一个新数组包含从 start 到 end不含 end的原数组元素。不传参或传 0就返回整个数组的浅拷贝。这是 ES3 时代就存在的老方法在展开运算符普及前它是浅拷贝的标准答案。即使现在它依然有不可替代的地方。最典型的是在需要对子数组做截取后继续操作时slice(1, 3)兼顾了截取和复制。还有在旧代码库或特殊构建环境里如果不允许用 ES6 语法slice 是唯一稳的选择。性能上slice()和展开运算符差不多因为底层都是遍历元素放入新数组。在百万级数据量的场景两者差距可以忽略。但 slice 的语义比展开运算符更明确——它就是“取一段”天生为复制而设计。如果团队里有新人写[...arr]可能会被误解为“拼接”但arr.slice()一看就知道是新数组。2.3 concat、Array.from和map同样能复制但场景各不同先看concat()const copy1 original.concat(); const copy2 [].concat(original);concat()用于合并多个数组不传参数时返回原数组浅拷贝。它是 ES3 老牌 API兼容性极佳。但语义上默认是“合并”用来复制有点绕。团队里如果代码规范严格而且追求可读性最好避免这种用法。再看Array.from()const copy Array.from(original);Array.from()可以把类数组对象、可迭代对象转换成数组。它复制数组本身就有独特优势——它能同时做映射const doubled Array.from(original, (item) item * 2);更重要的是它能处理类数组对象这个后面专门讲。如果只是想复制数组用它有点杀鸡用牛刀但遇到类数组需求时它是不可替代的。最后是map()const copy original.map((item) item);很多人拿它来复制数组但它的本职是“映射变换”。虽然结果确实是个新数组但性能比展开运算符和 slice 略差一丢丢本质上多了一次回调调用而且写法意图不明确团队里看到map(x x)会下意识想“这不是啥也没干吗”。我个人不建议用 map 复制数组除非复制的同时要做元素变换。这三种方法的适用场景我整理成一个简单的对照方法适用场景不推荐场景展开运算符日常浅拷贝、拼接合并ES5 环境slice()截取子数组、复制全量语义要求“合并”时concat()合并多个数组纯复制时语义别扭Array.from()类数组转数组、复制同时映射简单浅拷贝杀鸡用牛刀map()复制同时做变换无变换的纯复制3. 深拷贝真正“新”且可靠的方案3.1 JSON方案为什么经常不够用在 structuredClone 出现之前前端做深拷贝最通用的方法就是const deepCopy JSON.parse(JSON.stringify(original));用 JSON 序列化再反序列化获得一个全新的对象。优点是简单、一行搞定、基本性能尚可。但它的坑非常多我一个个列undefined、函数、Symbol 类型的属性会被丢弃Date 对象会被序列化为字符串反序列化后变成普通的 ISO 字符串不再是 DateMap、Set、RegExp 会丢失类型变成空对象或字符串无法处理循环引用遇到对象间接引用自身会直接抛错NaN和Infinity会被变成null对象里的__proto__、不可枚举属性会被忽略。举个真实例子某项目里用户配置数据包含一个正则表达式用于表单校验用 JSON 方案深拷贝之后正则对象变成空对象运行时直接崩了。从那以后我对包含特殊类型的对象一律不用 JSON 深拷贝。所以 JSON 方案只适合“纯数据对象”即全部由普通对象、数组、字符串、数字、布尔值组成的结构。用于接口返回的 JSON 数据深拷贝问题不大除此之外要谨慎。3.2 structuredClone现代运行时自带的新方法structuredClone()是 2022 年前后逐步被主流浏览器和 Node.js 17 支持的原生深拷贝方法它的地位就相当于“官方深拷贝 API”。用法简单得离谱const deepCopy structuredClone(original);一次调用搞定底层实现了真正的结构化克隆算法和浏览器内部做 postMessage 时使用的序列化机制是同一套。它能正确复制 Date、Map、Set、RegExp、ArrayBuffer、TypedArray 等等大多数内置类型也能处理循环引用。这让深拷贝从“靠团队手写维护的工具函数”变成了“运行时标准能力”。我现在的项目里只要运行环境支持现代浏览器、Electron、Node 17一律优先用它。使用时有几个限制需要注意函数不能被克隆尝试克隆函数会抛出DataCloneErrorDOM 节点不能被克隆会抛错原型链上的自定义属性不会保留某些特殊对象比如Error对象会被复制但堆栈信息不同旧浏览器不支持需要用 polyfill 或者降级方案。还有性能问题structuredClone对复杂嵌套对象的大数组确实比手写递归快但如果对象特别庞大仍然会占用不少内存。大数据量场景我还是建议做分片或尽量浅拷贝不要无脑深拷贝。3.3 自研递归方案与库的选择在遇到既不能用 structuredClone兼容性不满足、又不能上 JSON 方案的情况时有时候得自己写递归深拷贝或者借助成熟库。手写一个支持常见类型的深拷贝核心大致如下function deepClone(value, seen new Map()) { if (value null || typeof value ! object) { return value; } if (seen.has(value)) { return seen.get(value); } if (value instanceof Date) { return new Date(value.getTime()); } if (value instanceof RegExp) { return new RegExp(value.source, value.flags); } if (value instanceof Map) { const cloneMap new Map(); seen.set(value, cloneMap); value.forEach((v, k) { cloneMap.set(deepClone(k, seen), deepClone(v, seen)); }); return cloneMap; } if (value instanceof Set) { const cloneSet new Set(); seen.set(value, cloneSet); value.forEach((v) { cloneSet.add(deepClone(v, seen)); }); return cloneSet; } const clone Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value)); seen.set(value, clone); Reflect.ownKeys(value).forEach((key) { clone[key] deepClone(value[key], seen); }); return clone; }这里最关键的是seen这个 Map用来记录已访问过的对象遇到循环引用时直接返回之前克隆的副本避免无限递归。如果团队里已经引入了成熟工具库用第三方的cloneDeep也是很好的选择。它的优势在于跨环境兼容性好、处理各种边界类型非常全面。缺点是引入了额外的包体积适合项目本身已经在用该库的情况。我自己总结的选型策略是现代环境优先 structuredClone老浏览器或 Node 版本偏旧且有该库依赖时用第三方方案临时脚本或轻量场景且数据全是纯 JSON 的时候可以用 JSON 方案但要心里清楚它的限制。自己手写深拷贝只在学习或特殊场景下值得生产项目里除非有极强的定制需求不然没必要重复造轮子。4. 特殊类型和数据结构的复制姿势4.1 类数组对象的数组化实际开发中经常遇到的不是复制一个数组而是把一个“长得像数组但又不是数组”的东西变成真数组。最典型的是函数的arguments对象和NodeList。ES5 时代的标准解法是用Array.prototype.slice.callfunction demo() { const args Array.prototype.slice.call(arguments); return args.map((item) item * 2); }arrayLike对象有length而且能按下标访问元素所以能被slice当成数组处理。这个写法曾经是前端必背的面试题。ES6 之后有两个更直接的选择Array.from()const args Array.from(arguments); const nodeList Array.from(document.querySelectorAll(div));展开运算符const args [...arguments]; const nodeList [...document.querySelectorAll(div)];两者差别很小。Array.from对类数组和可迭代对象都有效而且可以传映射函数展开运算符只对可迭代对象有效。在实际项目里如果目标对象是真正的可迭代对象比如 NodeList、字符串、Set用展开运算符更方便如果是严格意义上的“类数组”而非可迭代对象用Array.from才可靠。一个容易被忽略的点是arguments在箭头函数里不存在所以数组化的时候要确保用的是普通函数。NodeList 在现代浏览器里是可迭代的但在某些老环境需要用Array.from才能保证兼容。4.2 对象数组和嵌套数组的复制复制一个对象数组最经典的坑我已经在 1.2 提过。这里再说细一些。比如const userList [{ id: 1, profile: { age: 20 } }, { id: 2, profile: { age: 30 } }]; const copy userList.map((user) ({ ...user })); copy[0].profile.age 99; console.log(userList[0].profile.age); // 99{ ...user }只复制了对象的第一层属性profile这个对象依然是共享引用的。要彻底隔离必须对每个元素再做一次深拷贝。于是就有了一个问题到底是“把数组复制一遍里面元素保持引用”还是“连同元素一起递归复制”。前者通常叫 shallow copy后者叫 deep copy。大部分业务场景其实只需要前者因为你会对元素执行“整体替换”而不是“修改内部属性”。但在表格编辑场景中常常需要直接修改某个单元格字段的值而不是替换整个行对象这时就需要深拷贝。在框架里处理对象数组时我还有一个建议尽量修改整套状态时走不可变更新的范式不使用深拷贝依靠版本号或引用变化驱动视图更新。这比纠结深拷贝更贴合现代前端的性能要求。4.3 Map、Set 和 TypedArray 的复制数组的复制方法对 Map、Set 这些数据结构的适用性是很多人容易忽略的地方。展开运算符对Set是有效的const set new Set([1, 2, 3]); const copy new Set(set);Set 直接传入构造函数就会复制。Map 也类似const map new Map([[a, 1]]); const copy new Map(map);但这种构造方式复制的是 Map 的条目如果值是对象依然是引用类型不会深拷贝。结构化克隆对 Map、Set 的处理则能完成深层次的拷贝前面的 deepClone 示例也覆盖了这种情况。TypedArray比如Uint8Array、Float64Array的复制就不能用展开运算符而是const uint8 new Uint8Array([1, 2, 3]); const copy new Uint8Array(uint8);或者用slice()const copySlice uint8.slice();Buffer 也是类似Buffer.from(arrayBuffer)会复制底层内存。这些细节在数据量大或者二进制数据场景里非常关键。5. 常见问题与排查技巧实录5.1 修改副本后原数组也变了症状调用了一个工具函数处理后原数组的值意外变化。排查思路检查函数内部是否对参数数组直接做了sort()、reverse()、splice()、push()等修改性操作。任何直接用方括号赋值或通过索引改值的操作也会直接作用在原数组上。解决方案是先浅拷贝再操作function orderSort(arr) { const copy [...arr]; return copy.sort((a, b) a - b); }或者让函数接收参数时保证不修改形参function orderSort(arr) { return [...arr].sort((a, b) a - b); }如果数组里元素是对象且函数内部会修改元素自身那浅拷贝也不够需要深拷贝。5.2 数组套对象复制后内部被共享症状复制后的数组看起来没问题但改副本里的某个对象属性原数组也跟着变了。排查思路检查复制方法是否为浅拷贝。展开运算符、slice、concat、Array.from 全都是浅拷贝如果数组元素中有对象这些对象的引用会被共享。解决方案元素结构简单、数据全为基础类型浅拷贝即可元素有嵌套对象且需要完全隔离用structuredClone老环境用 JSON 方案但要先排除函数、Date 等特殊类型有第三方库依赖时可以用cloneDeep。写一个辅助函数作为统一出口也是不错的选择function cloneList(list, deep false) { return deep ? structuredClone(list) : [...list]; }团队里约定统一走这个函数能减少大量“深浅不分”的问题。5.3 方法选型备忘表下面是我个人常用的选型表按需求分层可以直接抄作业需求推荐方法不推荐复制基础类型数组[...arr]或arr.slice()无脑map(x x)截取子数组并保持原数组不变arr.slice(start, end)splice会改原数组类数组转真数组Array.from()展开运算符类数组不可迭代时不适用复制后立即映射Array.from(arr, fn)或arr.map(fn)JSON 方案有类型丢失风险复制对象数组且需要深层隔离structuredClone(arr)JSON 方案特殊类型丢字段老环境深拷贝库的 cloneDeep手写浅拷贝凑合用合并多个数组[...a, ...b]或a.concat(b)用push展开循环修改原数组5.4 性能实测与注意事项实测过程中有一个细节值得单独说一下展开运算符和slice在处理十万级字符串数组时差别基本可以忽略但如果是十万个对象即使只是浅拷贝内存占用也会明显增加。在需要频繁复制的热路径上数据量一大建议使用池化或者只复制需要的字段而不是每次全量拷贝。还有一点很多新手容易忽略的是“复制出来的数组迭代顺序和原数组完全一致”。如果你依赖数组顺序做状态管理那么注意sort是原地排序reverse也是原地排序而slice、map、filter、concat不会改变原数组。比如要生成一个反序数组可以这样const reversed [...arr].reverse();而不要const reversed arr.reverse(); // 原数组也被反转了这个小陷阱我曾经在一次代码评审里反复跟团队强调过原因就是太容易随手写错了。写在最后数组复制这件事在我刚入行的时候也被它折腾过好几回。那时候遇到数组被莫名修改第一反应是去查业务逻辑里谁动过原数据查了半天才发现是自己某个工具函数里的一行sort。后来想明白了这类问题的本质从来不是“哪个复制方法更好”而是“我到底需不需要复制、需要复制到什么深度”。想清楚了赋值、浅拷贝、深拷贝这三者的边界再遇到这类问题基本就能一眼定位。最后再分享一个小技巧如果你们项目的代码规范里能约定一条“所有对外暴露的数组参数默认情况下函数内部不得修改”并配套在团队里推行统一的复制工具函数很多“辈分不清”的引用共享问题会在源头被掐死。别指望每个人都能记住所有方法的语义差异统一出口才是长期维护最稳的办法。