
1. 项目概述为什么合并操作是前端开发的“基本功”在JavaScript的日常开发中无论是处理从多个API接口聚合而来的用户数据还是管理一个复杂应用的状态树亦或是简单地拼接几个配置项对象和数组的合并操作都像呼吸一样自然却又无处不在。我见过不少新手开发者面对一个看似简单的合并需求会不假思索地使用Object.assign()或者展开运算符...直到在某个深夜被一个由“浅拷贝”引发的诡异Bug折磨得焦头烂额时才恍然大悟。也有经验丰富的同事在处理深层嵌套数据的合并时依然会谨慎地选择自己手写一个递归函数而不是盲目信任某个库。这个标题——“js中对象合并的4种方式数组合并的4种方法”——看似基础但它直指前端开发中最核心的数据操作场景。它不仅仅是记住几个API那么简单其背后是关于数据不可变性、引用与拷贝、性能考量以及代码意图清晰度的深层权衡。理解这些合并方式的细微差别能让你在构建复杂应用、优化性能、编写可维护代码时做出更明智的选择。本文将从一个一线开发者的视角彻底拆解这8种方法不仅告诉你“怎么做”更重点剖析“为什么”以及“什么时候用”并分享那些在官方文档里找不到的实战踩坑经验。2. 对象合并的四种核心方式及其深度解析对象合并的本质是将一个或多个源对象source的可枚举自身属性复制到目标对象target。这个过程中属性冲突时的覆盖策略和拷贝的深度深/浅是两个最关键的决定因素直接影响到程序的正确性。2.1 Object.assign()最经典的浅合并Object.assign()是ES6引入的标准方法它的行为非常明确将后续所有源对象的自身可枚举属性浅拷贝到第一个参数目标对象并返回这个目标对象。const target { a: 1, b: 2 }; const source1 { b: 3, c: 4 }; const source2 { c: 5, d: 6 }; const result Object.assign(target, source1, source2); console.log(result); // { a: 1, b: 3, c: 5, d: 6 } console.log(target result); // truetarget被修改了核心特性与“坑点”修改目标对象第一个参数target会被直接修改。这常常是意料之外的行为。为了避免副作用一个非常普遍的做法是传入一个空对象作为目标Object.assign({}, target, source1, source2)。这样合并结果会是一个全新的对象原对象均保持不变。浅拷贝Shallow Copy这是Object.assign()最需要警惕的地方。它只拷贝属性的引用如果属性值是一个对象包括数组、函数那么拷贝的是这个对象的引用而非创建新对象。const obj { nested: { a: 1 } }; const copy Object.assign({}, obj); copy.nested.a 999; console.log(obj.nested.a); // 999原对象被意外修改了。忽略不可枚举属性和Symbol属性不ES6之后Object.assign()会拷贝源对象的Symbol属性。这是很多人忽略的细节。覆盖顺序后面的源对象属性会覆盖前面同名属性。适用场景适合合并平坦的配置对象、覆盖默认值、快速创建对象的浅副本。当数据结构简单且确定没有嵌套对象时它是高效且清晰的选择。注意在React或Vue等强调不可变数据流的生态中直接使用Object.assign({}, state, newData)来更新状态是一种常见模式但前提是你清楚newData中不包含需要深拷贝的嵌套结构。2.2 展开运算符 (Spread Operator)...更优雅的语法糖ES2018中对象展开运算符被正式加入标准。在合并功能上它与Object.assign()在逻辑上几乎等价但语法更简洁、意图更清晰。const defaultConfig { host: localhost, port: 8080 }; const userConfig { port: 3000, debug: true }; // 使用展开运算符 const finalConfig { ...defaultConfig, ...userConfig }; // 等价于 Object.assign({}, defaultConfig, userConfig) console.log(finalConfig); // { host: localhost, port: 3000, debug: true }与Object.assign()的细微差别与选择语法与可读性展开运算符的语法更符合“合并”的直观感受尤其是在合并多个对象或与字面量混合使用时代码更易读。// 更清晰的意图创建一个新对象包含所有属性 const newObj { ...base, ...overrides, extraProp: value };性能在大多数现代JavaScript引擎中两者性能差异微乎其微可以忽略。选择应基于代码风格和可读性。Polyfill对于需要支持极老环境如IE的项目Object.assign通常有更成熟的Polyfill方案。而展开运算符在转换到ES5时Babel等工具会将其转换为Object.assign调用所以本质上一样。同样也是浅拷贝务必记住展开运算符进行的也是浅拷贝嵌套对象的问题同样存在。适用场景现代前端项目的首选用于组件Props的传递、状态的不可变更新、函数参数的默认值合并等。它已成为编写简洁、现代JavaScript代码的标志之一。2.3 手动遍历赋值最灵活可控的底层方案当标准方法无法满足特定需求时手动遍历并赋值提供了终极的灵活性。你可以控制拷贝哪些属性、如何处理冲突、甚至执行转换逻辑。function customMerge(target, ...sources) { // 为了防止修改原target可以从一个空对象或target的浅拷贝开始 const result Object.assign({}, target); for (const source of sources) { if (!source || typeof source ! object) continue; for (const key in source) { // 通常只拷贝自身属性忽略原型链上的 if (source.hasOwnProperty(key)) { // 这里可以加入你的自定义逻辑 // 例如只合并特定类型的值、递归合并、冲突时取最大值等 if (key specialKey) { // 特殊处理 result[key] transform(source[key]); } else { // 默认覆盖行为 result[key] source[key]; } } } } return result; }为什么需要手动合并选择性合并只合并符合某些条件的属性如非undefined、特定前缀等。自定义冲突解决不是简单的覆盖可能是合并数组、数值相加、调用自定义函数等。兼容性处理在无法使用ES6特性的极端环境下。性能微调对于已知结构的超大规模对象针对性的循环可能比通用方法有极细微优势但绝大多数情况下不需要。实操心得除非有非常明确的、标准方法无法实现的定制需求否则不建议轻易选择手动遍历。它会增加代码量、引入潜在Bug并降低可读性。99%的场景Object.assign或展开运算符足矣。2.4 深度合并Deep Merge处理嵌套结构的终极武器如前所述浅拷贝是前三种方式的共同局限。当我们需要合并嵌套结构时就必须进行深度合并。JavaScript语言本身不提供原生的深度合并方法因此我们需要自己实现或使用第三方库。深度合并的核心原理是递归当遇到属性值是对象或数组时不是直接赋值引用而是递归地合并这两个对象或数组。一个简易的深度合并实现function deepMerge(target, source) { const output Object.assign({}, target); if (isObject(target) isObject(source)) { Object.keys(source).forEach(key { if (isObject(source[key])) { if (!(key in target)) { // 目标没有该属性直接赋值 Object.assign(output, { [key]: source[key] }); } else { // 目标有该属性且都是对象递归合并 output[key] deepMerge(target[key], source[key]); } } else { // 基础类型或函数等直接覆盖 Object.assign(output, { [key]: source[key] }); } }); } return output; } function isObject(item) { return (item typeof item object !Array.isArray(item)); } // 使用示例 const obj1 { a: 1, b: { x: 10, y: 20 } }; const obj2 { b: { y: 30, z: 40 }, c: 3 }; const merged deepMerge(obj1, obj2); console.log(merged); // { a: 1, b: { x: 10, y: 30, z: 40 }, c: 3 } console.log(obj1.b.y); // 20原对象未被修改深度合并的复杂性与第三方库自己实现一个健壮的深度合并函数需要考虑很多边界情况数组的合并策略是覆盖、拼接、还是去重合并特殊类型的处理Date,RegExp,Map,Set,Function等应该如何处理通常是直接替换。循环引用对象内部可能存在循环引用递归函数需要能检测并避免栈溢出。性能深度递归在大对象上可能成为性能瓶颈。因此在生产环境中我强烈推荐使用成熟的第三方库它们经过了充分的测试和优化Lodash 的_.merge或_.mergeWith功能强大可定制合并规则是社区事实标准。deepmerge一个专注于深度合并的轻量级库API简单。Immutable.js如果你在使用不可变数据结构其提供的mergeDeep方法是更优选择。适用场景合并嵌套的配置如Webpack配置、深度更新Redux/Vuex状态树、处理复杂的API响应数据。当你无法确定数据结构是否平坦时使用深度合并是更安全的选择。3. 数组合并的四种方法及其性能考量数组合并通常比对象合并更直观但不同的方法在性能、语义和结果形式上也有显著差异。选择哪种方法取决于你是否需要改变原数组以及合并的数组数量与大小。3.1 Array.prototype.concat()返回新数组的经典方法concat()方法用于合并两个或多个数组它不会改变现有数组而是返回一个新数组。const arr1 [1, 2]; const arr2 [3, 4]; const arr3 [5, 6]; const newArr arr1.concat(arr2, arr3); console.log(newArr); // [1, 2, 3, 4, 5, 6] console.log(arr1); // [1, 2] 原数组不变关键特性非破坏性最重要的特性符合函数式编程思想不会产生副作用。扁平化一层如果参数是数组它会将其元素提取出来加入新数组但不会递归扁平化。const arr [1]; const nested [2, [3, 4]]; console.log(arr.concat(nested)); // [1, 2, [3, 4]]性能对于合并多个数组concat在大多数引擎中表现良好。它是数组合并最通用和安全的起点。适用场景任何需要合并数组且不希望修改原数组的情况。特别是在React/Vue的渲染逻辑中基于不可变数据创建新数组是推荐做法。3.2 展开运算符...用于数组简洁的现代语法与对象类似展开运算符也可以用于数组字面量中将另一个数组的元素“展开”到新数组的位置。const arr1 [a, b]; const arr2 [c, d]; const arr3 [e]; const combined [...arr1, ...arr2, ...arr3, f]; console.log(combined); // [a, b, c, d, e, f]与concat()的对比功能等价在数组合并的基本功能上[...arr1, ...arr2]完全等价于arr1.concat(arr2)。语法优势展开运算符在需要将数组合并到特定位置时语法更灵活直观。// 在中间插入另一个数组 const newArr [0, ...arr1, middle, ...arr2, 100];可读性对于熟悉ES6的开发者展开运算符的意图一目了然。性能与concat性能相近细微差别可忽略。选择建议如果你已经在项目中使用ES6语法那么展开运算符是首选因为它更现代、更简洁。concat则在需要明确的方法调用语义或支持极老环境时使用。3.3 Array.prototype.push.apply()修改原数组的“老派”技巧这是一种利用apply方法将第二个数组的所有元素作为参数一次性push进第一个数组的技巧。它会修改原数组第一个数组。const arr1 [1, 2, 3]; const arr2 [4, 5, 6]; // 传统方式 Array.prototype.push.apply(arr1, arr2); console.log(arr1); // [1, 2, 3, 4, 5, 6] arr1被改变 console.log(arr2); // [4, 5, 6] arr2不变 // ES6 后可以用展开运算符达到同样效果且更安全 arr1.push(...arr2); // 效果同上但更直观为什么现在不推荐了语法晦涩Array.prototype.push.apply(target, sourceArray)这种形式对于新手难以理解。存在限制JavaScript引擎对单个函数调用的参数数量有限制早期约6.5万如果sourceArray非常大此方法会失败。有更好的替代ES6的展开运算符target.push(...sourceArray)完全替代了它意图更清晰且没有参数数量限制实际上受栈空间限制但远大于apply的限制。遗留代码中可能会见到但新代码中应使用arr1.push(...arr2)。3.4 遍历插入如 for 循环 push / splice最底层的手动控制与对象合并一样手动遍历给了你最大的控制权但代码也最冗长。const arr1 [1, 2]; const arr2 [3, 4, 5]; // 方法Afor循环 push (合并到末尾) for (let i 0; i arr2.length; i) { arr1.push(arr2[i]); // 会修改arr1 } // 方法Bfor...of (更现代) for (const item of arr2) { arr1.push(item); } // 方法C使用splice在指定位置插入 arr1.splice(arr1.length, 0, ...arr2); // 在末尾插入不删除元素使用场景极其有限过滤性合并只合并满足特定条件的元素。const source [1, -2, 3, -4]; const target []; for (const num of source) { if (num 0) target.push(num); // 只合并正数 }转换性合并在合并过程中对元素进行转换。极致的性能优化在性能关键的代码段如游戏引擎、高频算法对已知长度和类型的数组进行手动的循环操作有时能比通用方法concat/...榨取最后一点性能。但这属于高级优化需要 profiling 数据支撑绝大多数应用不需要。实操建议除非你有上述非常具体的定制需求否则永远优先选择concat()或展开运算符...。它们的代码更简洁、意图更明确并且引擎已经为它们做了高度优化。4. 实战场景与方案选型指南了解了所有工具关键在于如何根据场景选择最合适的那一个。下面通过几个典型场景来分析。4.1 场景一合并API返回的用户数据与本地缓存假设你从服务器获取了用户的最新数据newData需要与本地缓存localCache合并以更新用户界面。// 假设数据结构 const localCache { id: 1, name: 老张, preferences: { theme: dark, notifications: true }, lastLogin: 2023-10-01 }; const newDataFromAPI { name: 张老师, // 名字更新了 preferences: { notifications: false }, // 只更新了通知设置 email: zhangexample.com // 新增邮箱 };分析preferences是一个嵌套对象。如果使用浅合并newDataFromAPI.preferences会完全覆盖localCache.preferences导致theme设置丢失。这显然不是我们想要的。方案选择深度合并// 使用 Lodash import _ from lodash; const updatedCache _.merge({}, localCache, newDataFromAPI); // 结果{ id:1, name:张老师, preferences:{ theme:dark, notifications:false }, lastLogin:2023-10-01, email:zhangexample.com } // 或使用自定义的deepMerge函数需处理数组等边界 const updatedCache deepMerge(localCache, newDataFromAPI);结论对于嵌套的、需要部分更新的配置或状态数据深度合并是唯一安全的选择。4.2 场景二函数参数的默认值配置这是一个非常常见的模式函数接受一个配置对象需要与内部默认值合并。function initChart(options) { const defaults { width: 800, height: 600, animation: { duration: 1000, easing: linear }, colors: [#ff0000, #00ff00] }; // 错误做法浅合并会破坏 defaults.animation // const config Object.assign(defaults, options); // 正确做法1以空对象为目标进行浅合并前提是确定options里没有需要深合并的嵌套对象 const config Object.assign({}, defaults, options); // 正确做法2更现代使用展开运算符和默认参数 const config { ...defaults, ...options }; // 如果 defaults 中有嵌套对象且 options 可能部分覆盖它则需要深度合并 // const config _.merge({}, defaults, options); console.log(config); } initChart({ height: 400, animation: { easing: ease-in } }); // 浅合并结果{ width:800, height:400, animation:{ easing:ease-in }, colors:[#ff0000,...] } // 注意浅合并导致 animation.duration 丢失了 // 深度合并结果{ width:800, height:400, animation:{ duration:1000, easing:ease-in }, colors:[#ff0000,...] }结论对于配置合并如果默认配置是平坦对象使用{ ...defaults, ...options }即可。如果默认配置包含嵌套对象且调用者可能只想覆盖其中一部分则必须使用深度合并。4.3 场景三Redux/Vuex中的状态更新在现代状态管理中不可变性是核心原则。你绝不能直接修改状态对象而必须返回一个全新的对象。// Redux Reducer 示例 const initialState { loading: false, items: [], filters: { category: all, sortBy: date } }; function dataReducer(state initialState, action) { switch (action.type) { case SET_FILTERS: // 错误直接修改嵌套对象 // state.filters.category action.payload.category; // 正确返回全新状态树 return { ...state, // 浅拷贝第一层 filters: { ...state.filters, // 浅拷贝filters对象 ...action.payload // 用payload覆盖 } }; case ADD_ITEM: return { ...state, items: [...state.items, action.payload] // 使用展开运算符创建新数组 }; default: return state; } }结论在Redux等场景中我们大量使用展开运算符进行浅拷贝来保证不可变性。对于多层嵌套需要每一层都进行展开操作这被称为“展开地狱”。对于非常深的状态树可以考虑使用Immutable.js或Immer这类库来简化不可变更新。Object.assign在这里也完全可用但展开运算符更清晰。4.4 场景四合并多个数组并去重有时合并数组后需要去除重复元素。const arr1 [1, 2, 3]; const arr2 [3, 4, 5]; const arr3 [5, 5, 6]; // 1. 合并使用展开运算符 const merged [...arr1, ...arr2, ...arr3]; // [1, 2, 3, 3, 4, 5, 5, 5, 6] // 2. 去重 // 方法A使用 Set (最简单高效但无法自定义去重规则) const uniqueViaSet [...new Set(merged)]; // [1, 2, 3, 4, 5, 6] // 方法B使用 filter indexOf (兼容性好但性能较差于Set) const uniqueViaFilter merged.filter((item, index) merged.indexOf(item) index); // 方法C使用 reduce const uniqueViaReduce merged.reduce((acc, cur) acc.includes(cur) ? acc : [...acc, cur], []);结论合并后去重是一个组合操作。先使用concat或...合并再使用Set进行去重是现代JavaScript中最简洁高效的方式。5. 性能对比、常见陷阱与排查技巧5.1 浅拷贝 vs 深拷贝内存与性能的权衡这是合并操作中最容易出错的地方必须形成条件反射般的警惕。浅拷贝只复制一层属性。如果属性是引用类型对象、数组复制的是内存地址。速度快内存占用小但共享嵌套数据修改其一可能影响其他。深拷贝递归复制所有层级创建完全独立的对象副本。速度慢内存占用大但数据完全隔离安全。如何选择默认优先浅拷贝在明确知道数据结构平坦或共享嵌套引用正是你期望的行为时如多个组件引用同一个配置对象使用浅拷贝。不确定就深拷贝当数据来源复杂如API响应、结构未知或需要确保绝对的数据隔离时使用深度合并或深拷贝。JSON.parse(JSON.stringify(obj))是一种快速的深拷贝方法但会丢失函数、undefined、Symbol和循环引用。使用不可变库对于复杂应用状态使用Immer或Immutable.js它们通过结构共享等技术在保证不可变性的同时优化性能。5.2 数组合并方法性能浅析对于数组合并在V8等现代引擎中arr1.push(...arr2)和[...arr1, ...arr2]对于合并少量或中等数量数组性能最佳且语法清晰。arr1.concat(arr2, arr3)性能与之相当是标准方法。当需要合并大量小数组时在循环中反复使用concat或...可能会产生大量中间数组影响性能。此时可以预先计算总长度使用一个循环直接赋值给新数组是更优的微优化方案但代码复杂度上升。黄金法则在遇到可测量的性能瓶颈之前永远选择可读性最好、最不易出错的方法concat或...。5.3 常见问题排查表问题现象可能原因解决方案合并后修改新对象原对象也跟着变了使用了浅拷贝Object.assign、展开运算符且合并的属性值是对象/数组。对嵌套结构使用深度合并_.merge或手动深拷贝。使用Object.assign合并后某些属性如Symbol丢失了源对象的属性是不可枚举的或Object.assign在ES6前的Polyfill不完善。确保环境支持ES6的Object.assign或检查属性描述符。对于不可枚举属性需要手动复制。数组合并后顺序不对concat或...会严格按照参数顺序拼接。检查源数组顺序。确认业务逻辑要求的合并顺序。深度合并时数组被整个覆盖而不是合并元素你使用的深度合并函数或库的默认配置对数组的处理策略是覆盖。使用支持自定义合并规则的库如_.mergeWith或修改你的深度合并函数为数组定义合并逻辑如拼接。合并非常大的对象或数组时页面卡顿或内存飙升进行了不必要的深拷贝或合并算法复杂度高。审视是否真的需要深拷贝。考虑使用不可变数据库的结构共享。对于超大数据可能需分块处理。TypeError: Cannot assign to read only property尝试合并属性到一个只读对象如Object.freeze创建的对象或严格模式下的某些特殊对象。确保目标对象是可写的。通常合并时应以一个新空对象{}为目标。5.4 一个关于“覆盖”与“合并”的思维陷阱这是初级和中级开发者常混淆的一点。以对象为例覆盖Override后者的属性值直接替换前者的同名属性值。Object.assign和展开运算符的默认行为就是覆盖。合并Merge通常指深度合并对于嵌套对象是递归地执行“合并”操作而不是简单覆盖。很多业务逻辑中我们嘴上说“合并配置”心里想的其实是“用新配置覆盖默认配置”这时浅拷贝就足够了。只有当我们需要“用新配置深度更新默认配置”时才需要深度合并。在设计和沟通时明确使用哪个词能避免很多误会。6. 高级话题与扩展思考6.1 使用 Proxy 实现响应式合并在一些高级场景比如实现一个响应式状态系统你希望合并操作不仅能产生新数据还能自动触发更新。这时可以结合Proxy。function createReactiveState(initialState) { const handlers { set(target, property, value) { console.log(属性 ${property} 被设置为 ${value}); target[property] value; // 这里可以触发视图更新等副作用 return true; } }; return new Proxy(initialState, handlers); } const state createReactiveState({ count: 0 }); const update { count: 1 }; // 简单的“合并”操作实际上会触发set陷阱 Object.assign(state, update); // 控制台输出: 属性 count 被设置为 1当然一个完整的响应式系统要复杂得多需要处理嵌套对象、数组、defineProperty与Proxy的兼容等Vue 3的reactive和ref就是这方面的典范。理解Proxy可以帮助你洞悉这些框架底层的一部分魔法。6.2 不可变数据流与结构共享在React、Redux或函数式编程中不可变性是关键。但深拷贝性能差。如何平衡答案是结构共享Structural Sharing。库如Immutable.js或Immer在背后使用了这种技术。当你修改一个深层属性时它们不会克隆整个对象树而是只克隆从根节点到修改节点路径上的所有节点未修改的节点则在新旧对象间共享。// 使用 Immer import produce from immer; const baseState { user: { name: Tom, address: { city: Beijing, street: Zhongguancun } } }; const nextState produce(baseState, draftState { draftState.user.address.city Shanghai; // 直接“修改”draft }); console.log(baseState.user.address.city); // Beijing原状态未变 console.log(nextState.user.address.city); // Shanghai console.log(baseState.user nextState.user); // falseuser对象被克隆了 console.log(baseState.user.name nextState.user.name); // truename字符串未变共享引用 console.log(baseState.user.address nextState.user.address); // falseaddress对象被克隆了 // 但baseState中其他未被触及的部分在内存中与nextState是共享的。理解结构共享能让你在面临“不可变性要求”和“性能担忧”时做出更合理的技术选型。对于大型、频繁更新的状态树使用Immer这样的库是明智之举。6.3 自定义合并策略与递归终止条件如果你需要实现自己的深度合并函数除了处理对象和数组还必须考虑递归的终止条件。什么不是“对象”通常null、非对象类型string,number,boolean,undefined,symbol,function应该直接返回不再递归。Array.isArray(item)用于判断数组。特殊对象如何处理像Date,RegExp,Map,Set这类内置对象通常的合并策略是直接使用新的替换旧的因为它们的内部状态很难或不应该被“合并”。循环引用怎么办对象内部可能形成环A引用BB又引用A。递归函数需要用一个WeakMap来记录已访问过的对象如果再次遇到则直接返回已存储的引用避免无限递归。function deepMergeCustom(target, source, visited new WeakMap()) { // 终止条件非对象或null if (!isObject(target) || !isObject(source)) { return source; } // 处理循环引用 if (visited.has(target)) { return visited.get(target); } visited.set(target, target); const output { ...target }; Object.keys(source).forEach(key { if (isObject(source[key]) isObject(target[key])) { output[key] deepMergeCustom(target[key], source[key], visited); } else { output[key] source[key]; } }); return output; }编写一个生产环境可用的深度合并函数是一个很好的练习它能极大地加深你对JavaScript对象模型的理解。但在实际项目中还是那句话相信并利用好成熟的第三方库。