扩展运算符与Object.assign:JS对象拷贝合并的底层差异与最佳实践
做前端久了你会发现处理对象拷贝和合并这件事几乎天天都在写但大部分人只是习惯性地敲出{ ...obj }或者Object.assign({}, obj)从来没仔细想过这俩到底有什么区别。我之所以专门研究这个问题是因为某次在一个状态管理的重构项目里同事把Object.assign直接当深拷贝用结果嵌套对象改一处、所有引用全跟着变线上排查了大半天才定位到原因。从那次之后我花了不少功夫把扩展运算符和Object.assign的规范语义、实际行为、性能细节从头到尾捋了一遍今天干脆把这些内容完整地分享出来。这篇内容适合三类人刚入行不久、总是分不清扩展运算符和 assign 的新人写了几年业务、想在状态管理和组件封装里减少隐性 bug 的前端还有想系统梳理一遍 JS 对象合并知识点的中高级开发者。看完之后你至少能掌握两者各自的适用边界、底层执行语义差异、常见业务坑位以及一份可以直接抄走的速查表。先别急着写代码我们从最初的设计意图说起。1. 先搞清楚这两个东西到底解决什么问题1.1 扩展运算符的诞生背景与设计初衷很多人以为扩展运算符天生就是用来合并对象的其实它最早的目标是函数调用和数组操作。ES5 时代想把一个数组的元素传给函数只能写Math.max.apply(null, arr)想拼接数组只能写arr1.concat(arr2)。这些写法没问题但读起来总隔了一层。扩展运算符的初衷就是把把数组展开成参数这件事变成语法本身于是就有了Math.max(...arr)、[...arr1, ...arr2]。这里有个关键点在数组和函数调用场景里扩展运算符依赖的是迭代协议。也就是说只有实现了Symbol.iterator的东西才能被展开。数组、字符串、Set、Map 都实现了迭代器所以[...new Set([1,2,3])]、[...hello]都能正常跑起来。后来规范把扩展运算符扩展到了对象字面量里比如{ ...obj }。对象展开走的路径跟数组展开完全不同——它不依赖迭代协议而是直接枚举源对象的可枚举自有属性。这一点极其重要因为很多人拿[...{a:1}]试试发现报错就以为对象展开和数组展开是一回事其实只是规范里重用了同一个语法符号底层机制并不相同。1.2 Object.assign 的设计初衷Object.assign是 ES6 同期加入的静态方法定位非常明确把多个源对象的可枚举自有属性复制到目标对象上。它的核心行为可以拆成四步把目标对象转成对象类型null和undefined在这里会直接抛错。依次遍历每个源对象。对源对象里的每个可枚举自有属性键读取其值然后写到目标对象上。返回目标对象本身。这个方法的出现本质上是为了替代早期各种第三方库里的extend工具方法。以前用 jQuery 有$.extend用 Underscore 有_.extend用 Lodash 有_.assign干的事情基本都一样——把一个对象的属性拷贝到另一个对象上。Object.assign进了标准库之后这种操作不用再依赖外部依赖同时也给后来前端状态管理的不可变更新风格打了一个基础。有一点很多人不知道Object.assign的提案时间和扩展运算符非常接近两者在设计语义上是互相影响的。所以你会看到它们处理对象拷贝时的行为高度相似甚至规范里对象展开使用的抽象操作CopyDataProperties也和Object.assign的赋值逻辑同源。但它们最大的分岔点恰恰藏在要不要修改目标对象这一层。1.3 一句话记住两者差异我的经验总结是这样扩展运算符默认是我要一个新对象Object.assign 默认是我要往现有对象身上填东西。这个差异决定了 90% 的场景选谁。比如在函数式状态更新里你永远不希望原状态被顺手改掉那扩展运算符就是更安全的默认项而在某些缓存合并、多源数据落地的场景里你可能确实希望在一个已存在的对象上不断追加属性那Object.assign反而更顺手。2. 展开语法与 Object.assign 的正面交锋2.1 对象合并写法差异与行为差异先说最基本的对象合并。看这段代码const obj1 { a: 1, b: 2 }; const obj2 { b: 3, c: 4 }; const spreadResult { ...obj1, ...obj2 }; const assignResult Object.assign({}, obj1, obj2); console.log(spreadResult); // { a: 1, b: 3, c: 4 } console.log(assignResult); // { a: 1, b: 3, c: 4 }结果一样都是后者覆盖前者。但如果你把Object.assign的目标对象从{}换成obj1行为立刻不同const mutated Object.assign(obj1, obj2); console.log(mutated obj1); // true console.log(obj1); // { a: 1, b: 3, c: 4 }obj1被原地改掉了。扩展运算符则没有这种副作用{ ...obj1, ...obj2 }永远产生新对象源对象不受影响。这个差异在业务代码里经常被忽略。我见过不少 reducer 或状态管理代码里写Object.assign(state, { loading: true })一旦 state 对象被组件引用就容易触发各种我明明没改它它怎么变了的灵异现象。改成{ ...state, loading: true }之后整个更新流程立刻清晰起来。另外要注意Object.assign对多个源对象的处理是顺序覆盖后面的源对象优先级更高。扩展运算符的顺序由对象字面量本身决定而且可以和普通键值对混着写const merged { ...sourceA, ownKey: 1, ...sourceB };这里的执行顺序一目了然先展开 sourceA再定义 ownKey最后展开 sourceB。如果 sourceB 里有 ownKey 同名属性会覆盖前面的值。2.2 数组拼接与复制扩展运算符的独门绝技数组处理是Object.assign基本帮不上忙的地方也是扩展运算符最大的优势区。数组拼接const arr1 [1, 2, 3]; const arr2 [4, 5, 6]; const combined [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]数组复制const original [1, 2, 3]; const copyViaSpread [...original]; const copyViaSlice original.slice();两种复制都是浅拷贝元素是基本类型时完全等价。但注意一个细节扩展运算符复制的是迭代过程遇到稀疏数组的空位时会把它当作undefined来访问也就是说稀疏数组展开之后会变成密集数组。slice则会把空位原样保留。这个差异平时不太明显但如果你的数据里存在大量空位比如从 CSV 或某些外部接口拿到的稀疏数组copyViaSpread和copyViaSlice的结果就不同了。配合 Set 做去重或者配合 Map 做键值对数组转换也是扩展运算符的常用姿势const unique [...new Set([1, 2, 2, 3])]; // [1, 2, 3] const pairs [...new Map([[a, 1], [b, 2]])]; // [[a, 1], [b, 2]]因为 Set 和 Map 都实现了迭代器这套写法几乎成了现代前端处理集合类的默认风格。2.3 字符串展开的隐藏细节字符串也是可迭代对象所以[...hello]会得到[h, e, l, l, o]。很多人觉得这只是个花哨技巧但它在处理 Unicode 字符时有一个实打实的优势。JavaScript 字符串的length是按 UTF-16 码元计算的一个 emoji 比如的长度是 2因为它占两个码元。如果你用str.split()去拆一个 emoji 字符串会得到两个乱码字符。而扩展运算符走的是字符串迭代器迭代器按码点产出所以const text ab; console.log(text.length); // 4 console.log([...text]); // [a, , b]这是处理多语言、emoji、生僻字场景时非常实用的一招。Object.assign处理字符串时表现就差很多了因为它把字符串当成类数组对象来拷贝索引属性console.log(Object.assign({}, ab)); // { 0: a, 1: b }这种结果在大多数业务场景里没有意义所以遇到把字符串拆成字符数组这类需求优先想到扩展运算符就对了。2.4 函数调用与 rest 参数中的逆用扩展运算符不只是字面量语法它在函数调用里也一样能做到展开参数的效果。最经典的例子是求最大值const numbers [5, 42, 17, 8]; const max Math.max(...numbers); // 42以前需要Math.max.apply(null, numbers)现在一行搞定。另一个经常被关联起来讲的是 rest 参数它和扩展运算符共用三个点符号一个负责收起一个负责展开const [first, ...rest] [1, 2, 3, 4]; console.log(first); // 1 console.log(rest); // [2, 3, 4]注意 rest 参数必须是最后一位而且只能用于解构赋值或函数参数声明不能单独出现在表达式里。它跟扩展运算符的方向相反本质上是把剩下的所有东西打包成一个数组。在写函数参数数量不确定的工具函数时rest 参数配合扩展运算符几乎是标配方案。3. 底层原理与执行语义的深入拆解3.1 迭代协议与可枚举性扩展运算符在不同的场景下依赖不同的底层机制这是理解它行为边界的关键。在数组字面量和函数调用场景扩展运算符会尝试调用对象的Symbol.iterator方法。没有这个方法的对象比如普通对象{ a: 1 }在数组里展开会直接报错try { const arr [...{ a: 1 }]; } catch (error) { console.log(error.message); // object is not iterable (cannot read property Symbol(Symbol.iterator)) }相反在对象字面量里展开数组是完全可以的因为数组的索引属性本身是可枚举的自有属性const obj { ...[1, 2, 3] }; console.log(obj); // { 0: 1, 1: 2, 2: 3 }这里走的是属性枚举跟迭代器没有任何关系。所以不要试图用它能不能被展开来推断对象实现了什么协议要分清楚你是在数组场景、函数调用场景还是对象字面量场景里使用扩展运算符。可枚举性也是很关键的一环。无论是对象展开还是Object.assign都只管可枚举的自有属性。什么意思就是你手动赋值给对象的属性默认可枚举但通过Object.defineProperty创建的属性可以设置为enumerable: false这两种方式都不会拷贝它const source { visible: 1 }; Object.defineProperty(source, hidden, { value: 2, enumerable: false }); console.log({ ...source }); // { visible: 1 } console.log(Object.assign({}, source)); // { visible: 1 }原型链上的属性同样不会被拷贝因为它们不是自有属性。这个约束保证了浅拷贝的语义边界清晰但如果你确实想拷贝继承来的属性扩展运算符和Object.assign都做不到需要自己遍历原型链或用别的手段。3.2 赋值语义写入过程完全不同扩展运算符和Object.assign最核心的原理差异出在写入这一步。Object.assign内部对目标对象的赋值走的是[[Set]]语义。这意味着如果目标对象上已经存在一个同名属性而这个属性有 setter那么Object.assign会触发这个 setter可能会执行额外的逻辑。举一个例子const target { set count(value) { this._count value * 2; } }; Object.assign(target, { count: 5 }); console.log(target._count); // 10这里可以看到Object.assign并没有直接把count写为 5而是通过 setter 间接赋值为 10。这在某些需要校验或转换的场景里算是个特性但也意味着行为不透明容易出bug。对象字面量展开走的则是定义属性的语义内部类似CreateDataProperty或DefineProperty它不会触发目标对象上的 setter而是直接创建或重新定义这个属性const spreadTarget { set count(value) { this._count value * 2; } }; const next { ...spreadTarget, count: 5 }; console.log(next); // { count: 5 }没有触发 setter也没有 _count差异的根源是Object.assign是通过赋值操作写入扩展运算符是通过属性定义写入。你可以把前者理解为给已有的抽屉换东西可能会触发抽屉自带的机关后者是直接往新抽屉里塞东西不碰任何机关。这个差异在业务里比较隐蔽但遇到一些通过 setter 做数据驱动的组件时尤其值得警惕。我甚至见过有人把Object.assign写在一个带 setter 的对象上结果每次合并都会触发this._xxx的值被改写导致渲染异常。3.3 浅拷贝的边界与引用共享无论是展开运算符还是Object.assign拷完之后得到的都是浅拷贝。也就是说对象的第一层属性是新的但属性值如果是引用类型那拷贝出来的对象和原对象会共享同一个内层引用。const source { name: demo, config: { theme: dark } }; const viaSpread { ...source }; const viaAssign Object.assign({}, source); viaSpread.config.theme light; console.log(source.config.theme); // light console.log(viaAssign.config.theme); // light这里viaSpread.config和source.config指向同一个对象所以改一个全部跟着变。很多人把这个误当成深拷贝来用这是我在各种项目里最常见的错误。要真正实现深拷贝至少有三种常见思路使用结构化克隆算法浏览器环境下structuredClone就是标准方案。使用 JSON 序列化JSON.parse(JSON.stringify(obj))但会丢失undefined、函数、symbol键以及循环引用。自己写递归拷贝函数注意处理数组、日期、正则、Map、Set 等特殊类型。这三种方案各有取舍按业务需求来。但有一点要记住如果数据里有循环引用JSON 方案直接报错如果数据量巨大递归方案可能栈溢出。structuredClone是最稳妥的默认选择只不过兼容性要自己评估。3.4 属性描述符与特殊键的处理差异讲到属性层面还有一个容易被忽略的坑拷贝之后属性描述符会丢失。源对象的某个属性如果是通过Object.defineProperty定义的并且是只读的、不可枚举的那么拷贝后这个属性的描述符不会原样保留。因为无论是扩展运算符还是Object.assign拷贝的都是属性的值而不是属性的完整描述。举个例子const source {}; Object.defineProperty(source, locked, { value: 1, writable: false }); const next { ...source }; next.locked 2; console.log(next.locked); // 2拷贝出来的属性是可写的这符合大多数业务预期因为你要的就是数据快照而不是描述符快照。但如果你的场景对属性描述符有要求比如要拷贝一个带 getter 的配置对象那这两种方式都不会保留原始的 getter 函数而是把 getter 求值后的结果作为一个普通数据属性写进去。另一个更隐蔽的坑是特殊键__proto__。Object.assign在赋值时走[[Set]]如果目标对象对__proto__有特殊处理可能引发原型链修改const parsed JSON.parse({__proto__: {polluted: true}}); // 这里 Object.assign 在某些环境下可能污染原型 Object.assign({}, parsed);对象字面量展开则更安全因为它是通过定义属性的方式写入的不会触发__proto__的 setter因此不会直接修改原型链。这里我不展开讲原型污染攻击的具体细节但你可以记住一个结论在合并外部不可信 JSON 数据时使用对象展开比使用Object.assign更稳一点。4. 常用场景的代码实践与性能考量4.1 不可变状态更新的标准写法在前端状态管理场景不可变更新几乎是基本规范。最典型的就是 reducer 模式每次更新都返回一个新对象而不是修改旧 state。使用扩展运算符的写法非常自然const state { user: { name: A, age: 18 }, loading: false }; // Action: 更新 age const nextState { ...state, user: { ...state.user, age: 19 } };这种写法保证了state对象本身不被修改而nextState和state会共享user.name之外的结构。如果要更新嵌套几层的属性每一层都要展开一次写起来确实繁琐但胜在规则简单、不容易出意外。Object.assign在这里也可以实现不可变更新但不能直接拿原 state 当目标对象需要配合空对象使用const nextState Object.assign({}, state, { user: Object.assign({}, state.user, { age: 19 }) });对比之下扩展运算符的嵌套写法更直观。所以我的建议是在状态管理、函数式组件、纯函数更新这些场景里默认选择扩展运算符。4.2 数据快照与缓存合并也有适合Object.assign的场景。比如你想在一个已有对象上持续追加多个来源的数据而且希望最终稳定持有同一个引用。典型的是缓存对象或配置对象const cache { baseUrl: /api }; function updateCache(patch) { Object.assign(cache, patch); }这里cache引用保持不变其他模块持有这个引用也能同步看到变化。如果用扩展运算符就得不断重新赋值给cache变量但持有旧引用的模块依然看不到更新。所以在保持引用不变的前提下Object.assign是比扩展运算符更合适的工具。另一个适用场景是批量合并多个配置源。Object.assign({}, defaultConfig, userConfig, envConfig)这种三段式配置合并在工具函数里非常常见比嵌套多层展开简洁不少。4.3 关于性能到底谁更快这个问题被问过很多次。在 V8 和主流 JavaScript 引擎里扩展运算符和Object.assign的性能差距其实没有想象中大。如果你只合并两个小对象性能差异完全可以忽略。我确实在某个维护项目里做过简单的规模测试数据量在几万条对象的情况下两者的耗时差距基本在个位数百分比内。真正影响性能的往往是后面的浅拷贝深度、源对象属性数量、引擎优化状态等。所以选型时优先考虑语义是否合适而不是纠结那点微耗时。不过有一个性能相关的方向要注意Object.assign可以复用目标对象减少一次对象分配。在极端高频的写路径里比如每帧处理一组数据合并复用一个 pre-allocated 对象可能比每次{ ... }创建新对象更友好。但现代引擎对短期对象分配优化得相当好这个优化空间的优先级并不高。4.4 与解构赋值的组合用法先看 rest 和解构配合对象场景const { a, ...others } { a: 1, b: 2, c: 3 }; console.log(a); // 1 console.log(others); // { b: 2, c: 3 }这是排除某个字段的标准姿势。比如接口返回的数据里有很多冗余字段你想剔除id再传给子组件直接解构取出不需要的字段剩下的结构就是干净的 payload。再看函数参数中的 rest 和解构配合function formatUser({ name, ...rest }) { return { displayName: name.toUpperCase(), ...rest }; }这种写法在组件封装、DTO 转换里非常好用。既保留你关心的字段又让其他字段原样透传。相比手动删除某个键这种方式可读性高得多。5. 踩坑实录与常见问题速查5.1 浅拷贝共享引用引发的连锁修改这是使用这两种方式最经典的坑。我在某个项目里遇到过一个 bug表格组件收到一批过滤后的数据组件内用{ ...props.rowData }复制了当前行数据然后修改了复制对象的某个字段。结果原表数据也跟着变了。原因就是该字段是一个对象引用浅拷贝只复制了引用本身没有复制内层对象。这个问题的排查思路很简单先检查被修改的属性是不是引用类型如果是就得考虑内层也展开一次或者直接使用深拷贝。写扩展运算符时要有意识地问自己这个属性值里有引用类型吗如果有浅拷贝够不够5.2 null 和 undefined 作为源对象的行为扩展运算符处理null和undefined非常宽容在对象展开时会直接忽略const safeSpread { ...null, ...undefined }; console.log(safeSpread); // {}而Object.assign遇到null或undefined作为源对象时会直接抛出 TypeErrortry { Object.assign({}, null, undefined); } catch (error) { console.log(error.message); // Cannot convert undefined or null to object }如果你写了一个通用的合并函数参数可能是可选的那么Object.assign的这个行为需要额外防御。可以用{}给默认值或者在调用前过滤掉 null/undefined。相比之下扩展运算符在对象展开场景没有这个烦恼。5.3 数组空位与稀疏数组的拷贝差异前面提到过扩展运算符迭代数组时会把空位访问一遍得到的数组是密集的slice保持空位不变。举个例子const sparse [1, , 3]; const spreadCopy [...sparse]; const sliceCopy sparse.slice(); console.log(spreadCopy); // [1, undefined, 3] console.log(sliceCopy); // [1, empty, 3] console.log(0 in spreadCopy); // true console.log(0 in sliceCopy); // true console.log(1 in spreadCopy); // true console.log(1 in sliceCopy); // false这个差异在处理稀疏数组时会造成一些微妙影响比如map在空位上会跳过元素而[...sparse]之后map就不会跳过了。如果你从某些外部数据源拿到的是稀疏数组建议先用 Array 的flatten标准方法或Array.from显式处理不要依赖展开运算符的隐式行为。5.4 symbol 键与不可枚举属性的拷贝黑区Object.assign和对象展开都会拷贝可枚举的 symbol 键属性。这听起来很全面但有个前提属性必须可枚举、必须是自有属性。如果你的源对象里有不可枚举属性或者属性在原型链上那么这两种方式都会漏掉。这在处理一些内置对象时会很明显比如你复制一个Set或Map得到的不是可用的集合类型而是一个空对象因为它们的内部槽位根本不会通过属性枚举暴露出来。所以时刻记住对象的展开和合并都是数据搬运不是类型转换。你要拷贝什么类型的对象最好用专门的方法而不是{ ...obj }一把梭。5.5 常见问题速查表对比维度扩展运算符Object.assign目标对象处理创建新对象不改源对象修改第一个参数并返回它数组拼接/复制原生支持不适合字符串转字符数组支持按码点展开只能转成索引属性对象null/undefined 源对象对象展开时忽略抛 TypeError写入语义定义属性不触发 setter赋值语义触发 setter拷贝 symbol 键拷贝可枚举 symbol 键拷贝可枚举 symbol 键浅拷贝引用共享是是性能表现略优或相近复用目标对象时更省分配6. 几个值得单独说说的边界场景6.1 用扩展运算符与 Map、Set 的组合对象合并场景里Map 和 Set 也是常见的可迭代对象。在数组字面量里[...map]会把 Map 展开成一个个[key, value]数组在对象字面量里展开 Map 则不会得到键值对对象因为 Map 默认没有可枚举的自有属性{ ...map }得到的是一个空对象。这算是一个容易出错的点。如果你想把 Map 转成普通对象不能直接写{ ...map }而要先Object.fromEntries(map)const map new Map([[a, 1], [b, 2]]); console.log({ ...map }); // {}不对 console.log(Object.fromEntries(map)); // { a: 1, b: 2 }正确Object.fromEntries是 ES2019 的标准方法专门做 Map 转对象的事。它和Object.entries正好是逆操作。记住这个组合能避开不少奇怪的坑。6.2 对象展开的嵌套改写与不可变性嵌套对象更新时很多人会写出这样的代码const state { nested: { count: 0 } }; const next { ...state, nested: { count: state.nested.count 1 } };这里next.nested是一个新对象state.nested没有被修改因此不可变性是维护住的。但如果某个人图省事直接state.nested.count 1那所有的引用就都乱了。所以团队的代码规范里可以明确写一条在状态更新相关的代码中嵌套对象必须逐层展开后再赋值禁止直接改引用内部属性。6.3 展开运算符处理多层级配置的取舍多层配置合并时展开运算符的铺展深度其实是有限的。比如你要合并一个多层配置对象直接用{ ...defaults, ...user }只会覆盖第一层第二层的对象会被整个替换而不是逐字段合并。如果需求是深合并比如保留默认配置的 B 字段同时只覆盖用户传进来的 A 字段那就不能靠展开运算符了得写递归合并或者引入工具库。这也是很多人在配置合并场景踩坑的点看到第一层合并成功就以为整个配置都按需合了结果某个子配置整体被覆盖。7. 我的选型心得与后续建议说实话在我接触的绝大多数业务代码里扩展运算符都是更合适的默认选择。它产生的副作用更少语义更贴近现代函数式风格写法也更简洁。但有三个场景我会主动选择Object.assign一是需要复用同一个目标对象并保持引用稳定比如全局配置对象的持续追加二是希望利用它触发目标对象 setter 的特性虽然这种场景非常少三是团队里某些旧代码还没用上展开运算符需要保持代码风格一致。还有一个使用习惯想分享。无论选哪种写完之后多看一眼你是在拷贝数据还是在迁移引用如果答案是前者且数据里有嵌套结构尽快确认是否需要深拷贝。这个习惯救了我很多次也希望能帮你在项目里少加点班。如果你正在做一个数据流比较复杂的项目我建议花半小时专门把代码里所有的Object.assign和{ ... }都搜出来逐个确认目标对象是否会被意外修改、嵌套引用是否共享、特殊键是否有风险。这个过程排查出来的问题往往比新加功能时遇到的 bug 更有价值。