ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JavaScript对象与数组合并全解析:从浅拷贝到深拷贝的实战指南

JavaScript对象与数组合并全解析:从浅拷贝到深拷贝的实战指南

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); // true,target被修改了!

核心特性与“坑点”:

  1. 修改目标对象:第一个参数target会被直接修改。这常常是意料之外的行为。为了避免副作用,一个非常普遍的做法是传入一个空对象作为目标:Object.assign({}, target, source1, source2)。这样合并结果会是一个全新的对象,原对象均保持不变。
  2. 浅拷贝(Shallow Copy):这是Object.assign()最需要警惕的地方。它只拷贝属性的引用,如果属性值是一个对象(包括数组、函数),那么拷贝的是这个对象的引用,而非创建新对象。
    const obj = { nested: { a: 1 } }; const copy = Object.assign({}, obj); copy.nested.a = 999; console.log(obj.nested.a); // 999!原对象被意外修改了。
  3. 忽略不可枚举属性和Symbol属性?:不,ES6之后,Object.assign()会拷贝源对象的Symbol属性。这是很多人忽略的细节。
  4. 覆盖顺序:后面的源对象属性会覆盖前面同名属性。

适用场景:适合合并平坦的配置对象、覆盖默认值、快速创建对象的浅副本。当数据结构简单且确定没有嵌套对象时,它是高效且清晰的选择。

注意:在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()的细微差别与选择:

  1. 语法与可读性:展开运算符的语法更符合“合并”的直观感受,尤其是在合并多个对象或与字面量混合使用时,代码更易读。
    // 更清晰的意图:创建一个新对象,包含所有属性 const newObj = { ...base, ...overrides, extraProp: 'value' };
  2. 性能:在大多数现代JavaScript引擎中,两者性能差异微乎其微,可以忽略。选择应基于代码风格和可读性。
  3. Polyfill:对于需要支持极老环境(如IE)的项目,Object.assign通常有更成熟的Polyfill方案。而展开运算符在转换到ES5时,Babel等工具会将其转换为Object.assign调用,所以本质上一样。
  4. 同样也是浅拷贝:务必记住,展开运算符进行的也是浅拷贝,嵌套对象的问题同样存在。

适用场景:现代前端项目的首选,用于组件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); // 效果同上,但更直观

为什么现在不推荐了?

  1. 语法晦涩Array.prototype.push.apply(target, sourceArray)这种形式对于新手难以理解。
  2. 存在限制:JavaScript引擎对单个函数调用的参数数量有限制(早期约6.5万),如果sourceArray非常大,此方法会失败。
  3. 有更好的替代:ES6的展开运算符target.push(...sourceArray)完全替代了它,意图更清晰,且没有参数数量限制(实际上受栈空间限制,但远大于apply的限制)。

遗留代码中可能会见到,但新代码中应使用arr1.push(...arr2)

3.4 遍历插入(如 for 循环 + push / splice):最底层的手动控制

与对象合并一样,手动遍历给了你最大的控制权,但代码也最冗长。

const arr1 = [1, 2]; const arr2 = [3, 4, 5]; // 方法A:for循环 + push (合并到末尾) for (let i = 0; i < arr2.length; i++) { arr1.push(arr2[i]); // 会修改arr1 } // 方法B:for...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: 'zhang@example.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:'zhang@example.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.jsImmer这类库来简化不可变更新。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 深拷贝:内存与性能的权衡

这是合并操作中最容易出错的地方,必须形成条件反射般的警惕。

  • 浅拷贝:只复制一层属性。如果属性是引用类型(对象、数组),复制的是内存地址。速度快,内存占用小,但共享嵌套数据,修改其一可能影响其他。
  • 深拷贝:递归复制所有层级,创建完全独立的对象副本。速度慢,内存占用大,但数据完全隔离,安全。

如何选择?

  1. 默认优先浅拷贝:在明确知道数据结构平坦,或共享嵌套引用正是你期望的行为时(如多个组件引用同一个配置对象),使用浅拷贝。
  2. 不确定就深拷贝:当数据来源复杂(如API响应)、结构未知或需要确保绝对的数据隔离时,使用深度合并或深拷贝。JSON.parse(JSON.stringify(obj))是一种快速的深拷贝方法,但会丢失函数、undefinedSymbol和循环引用。
  3. 使用不可变库:对于复杂应用状态,使用ImmerImmutable.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

当然,一个完整的响应式系统要复杂得多(需要处理嵌套对象、数组、definePropertyProxy的兼容等),Vue 3的reactiveref就是这方面的典范。理解Proxy可以帮助你洞悉这些框架底层的一部分魔法。

6.2 不可变数据流与结构共享

在React、Redux或函数式编程中,不可变性是关键。但深拷贝性能差。如何平衡?答案是结构共享(Structural Sharing)

库如Immutable.jsImmer在背后使用了这种技术。当你修改一个深层属性时,它们不会克隆整个对象树,而是只克隆从根节点到修改节点路径上的所有节点,未修改的节点则在新旧对象间共享。

// 使用 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); // false,user对象被克隆了 console.log(baseState.user.name === nextState.user.name); // true,name字符串未变,共享引用 console.log(baseState.user.address === nextState.user.address); // false,address对象被克隆了 // 但baseState中其他未被触及的部分,在内存中与nextState是共享的。

理解结构共享,能让你在面临“不可变性要求”和“性能担忧”时,做出更合理的技术选型。对于大型、频繁更新的状态树,使用Immer这样的库是明智之举。

6.3 自定义合并策略与递归终止条件

如果你需要实现自己的深度合并函数,除了处理对象和数组,还必须考虑递归的终止条件

  1. 什么不是“对象”?通常,null、非对象类型(string,number,boolean,undefined,symbol,function)应该直接返回,不再递归。Array.isArray(item)用于判断数组。
  2. 特殊对象如何处理?Date,RegExp,Map,Set这类内置对象,通常的合并策略是直接使用新的替换旧的(因为它们的内部状态很难或不应该被“合并”)。
  3. 循环引用怎么办?对象内部可能形成环(A引用B,B又引用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对象模型的理解。但在实际项目中,还是那句话:相信并利用好成熟的第三方库。

返回列表