如何进行深度合并而非浅层合并?

618

Object.assign对象扩展运算符都只进行浅复制。

问题示例:

// No object nesting
const x = { a: 1 }
const y = { b: 1 }
const z = { ...x, ...y } // { a: 1, b: 1 }

输出结果与您期望的相同。但是,如果我尝试这样做:
// Object nesting
const x = { a: { a: 1 } }
const y = { a: { b: 1 } }
const z = { ...x, ...y } // { a: { b: 1 } }

与其

{ a: { a: 1, b: 1 } }

你能得到:

{ a: { b: 1 } }

由于展开语法只能深入一层,因此x被完全覆盖。Object.assign()也是如此。
有没有办法解决这个问题?

2
不,因为对象属性不应该被覆盖,相反,如果目标对象已经存在,则每个子对象应该合并到同一个子对象中。 - Mike
1
@Oriol 需要 jQuery... - m0meni
3
const merge = (p, c) => Object.keys(p).forEach(k => !!p[k] && p[k].constructor === Object ? merge(p[k], c[k]) : c[k] = p[k]) - Xaqron
2
你可以查看以下 GitHub 链接,获取简短代码解决方案:https://gist.github.com/ahtcx/0cd94e62691f539160b32ecda18af3d6 - Nwawel A Iroume
显示剩余3条评论
50个回答

296

我知道这个问题有点旧了,但是在ES2015/ES6中我能想到的最简单的解决方案其实非常简单,使用Object.assign()。

希望这能够帮助:

/**
 * Simple object check.
 * @param item
 * @returns {boolean}
 */
export function isObject(item) {
  return (item && typeof item === 'object' && !Array.isArray(item));
}

/**
 * Deep merge two objects.
 * @param target
 * @param ...sources
 */
export function mergeDeep(target, ...sources) {
  if (!sources.length) return target;
  const source = sources.shift();

  if (isObject(target) && isObject(source)) {
    for (const key in source) {
      if (isObject(source[key])) {
        if (!target[key]) Object.assign(target, { [key]: {} });
        mergeDeep(target[key], source[key]);
      } else {
        Object.assign(target, { [key]: source[key] });
      }
    }
  }

  return mergeDeep(target, ...sources);
}

示例用法:

mergeDeep(this, { a: { b: { c: 123 } } });
// or
const merged = mergeDeep({a: 1}, { b : { c: { d: { e: 12345}}}});  
console.dir(merged); // { a: 1, b: { c: { d: [Object] } } }

在下面的答案中,您会找到一个不可变版本。

请注意,这将导致循环引用的无限递归。如果您认为可能会遇到此问题,这里有一些很好的答案可以帮助您检测循环引用。


2
如果您的对象图包含循环引用,将导致无限递归。 - the8472
14
为什么要写 Object.assign(target, { [key]: {} }),而不是直接写成 target[key] = {} - Jürg Lehni
7
使用target[key] = source[key]代替Object.assign(target, { [key]: source[key] }); - Jürg Lehni
12
该函数不支持 target 参数中的非普通对象。例如,执行 mergeDeep({a: 3}, {a: {b: 4}}) 将导致增加一个扩展过的 Number 对象,这显然是不期望的。此外,isObject 不接受数组,但接受任何其他本地对象类型,如 Date,这些对象类型不应进行深度复制。 - riv
2
这个例子似乎来自于这里 https://blog.devgenius.io/how-to-deep-merge-javascript-objects-12a7235f5573,其中包含了代码的完整解释。 - macasas
显示剩余10条评论

236
你可以使用 Lodash merge:

var object = {
  'a': [{ 'b': 2 }, { 'd': 4 }]
};

var other = {
  'a': [{ 'c': 3 }, { 'e': 5 }]
};

console.log(_.merge(object, other));
// => { 'a': [{ 'b': 2, 'c': 3 }, { 'd': 4, 'e': 5 }] }
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>


11
大家好,这是最简单、最美丽的解决方案。 Lodash非常出色,他们应该将其包含为核心js对象。 - Nurbol Alpysbayev
24
结果不应该是 { 'a': [{ 'b': 2 }, { 'c': 3 }, { 'd': 4 }, { 'e': 5 }] } 吗? - J. Hesters
18
这个结果 { 'a': [{ 'b': 2, 'c': 3 }, { 'd': 4, 'e': 5 }] } 是正确的,因为我们正在合并数组的元素。 object.a 的第一个元素是 {b: 2},而 other.a 的第一个元素是 {c: 3}。当它们因为相同的数组索引而被合并在一起时,结果是 { 'b': 2, 'c': 3 },这是新对象中的第一个元素。 - Alexandru Furculita
6
要实现您所描述的功能,还有一种方法可以使用 Lodash 中的 mergeWith 方法。 - godness
14
添加依赖项不美观。 - stackers
显示剩余3条评论

129
问题在于主机对象或任何比值袋更复杂的对象时变得非常棘手。
你是调用getter来获取值还是复制属性描述符?
如果合并目标具有setter(自有属性或其原型链中的setter)怎么办?您将该值视为已存在还是调用setter以更新当前值?
您是否调用自有属性函数或复制它们?如果它们是绑定函数或箭头函数,取决于它们在定义时的作用域链中的某些内容?
如果是像DOM节点这样的东西怎么办?您肯定不想将其视为简单对象,并将其所有属性深度合并到其中。
如何处理数组、映射或集合等"简单"结构?将它们视为已存在还是合并它们?
如何处理不可枚举的自有属性?
新子树怎么办?只需按引用分配还是深层克隆?
如何处理已冻结/密封/不可扩展的对象?
还要记住一件事:包含循环的对象图。通常不难处理-只需保留已访问源对象的Set即可-但经常被遗忘。
你可能需要编写一个深度合并函数,该函数仅预期基本值和简单对象——最多那些结构克隆算法可以处理的类型——作为合并源。如果遇到无法处理的内容,请抛出异常或直接通过引用赋值而非深度合并。
换句话说,没有一种通用的算法,你需要自己编写或寻找一个库方法来满足你的使用情况。

4
V8开发人员不愿实现安全的“文档状态”转移的借口 - neaumusic
3
你提出了许多好的问题,我希望看到你建议的实施。因此,我试图在以下制定一个方案。请您查看并评论?https://dev59.com/p14c5IYBdhLWcg3wa52M#48579540 - RaphaMex
还有一件事需要记住:此页面上的解决方案容易受到原型污染的影响:https://learn.snyk.io/lessons/prototype-pollution/javascript/ 许多现有的库都受到保护。 - Cyral

109

这里是@Salakar答案的不可变版本(不修改输入)。如果你正在进行函数式编程类型的工作,这将非常有用。

export function isObject(item) {
  return (item && typeof item === 'object' && !Array.isArray(item));
}

export default function mergeDeep(target, source) {
  let 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] = mergeDeep(target[key], source[key]);
      } else {
        Object.assign(output, { [key]: source[key] });
      }
    });
  }
  return output;
}

1
@torazaburo,请查看我之前发布的isObject函数的帖子。 - Salakar
3
这是一个“计算属性名”,第一个将使用key的值作为属性名,而第二个则将“key”作为属性名。请参见:http://es6-features.org/#ComputedPropertyNames - CpILL
2
isObject 中,你不需要在结尾处检查 && item !== null,因为该行以 item && 开头,对吧? - ephemer
7
如果源对象的嵌套子对象比目标对象更深,那么这些对象在 mergedDeep 的输出中仍将引用相同的值(我认为)。例如:const target = { a: 1 }; const source = { b: { c: 2 } }; const merged = mergeDeep(target, source); merged.b.c; // 2 source.b.c = 3; merged.b.c; // 3这是一个问题吗?它并没有改变输入,但是任何未来对输入的修改都可能改变输出,反之亦然。不过,值得一提的是,Ramda 的 R.merge() 也有相同的行为。 - James Conkling
1
是的,这个合并键而不是值,除非它是一个字典。随意更新答案。 - CpILL
显示剩余6条评论

103

2022年更新:

我创建了 mergician ,以解决评论中讨论的各种合并/克隆要求。它基于我原来的回答(如下),但提供了可配置选项:

与本地方法和其他合并/克隆实用程序不同,Mergician 提供了高级选项,用于自定义合并/克隆过程。这些选项使得检查、过滤和修改键和属性变得轻松;合并或跳过唯一的、共同的和通用的键(即交集、并集和差异);并从数组中合并、排序和去除重复项。属性访问器和描述符也被正确处理,确保 getter/setter 函数被保留,并且描述符值被定义在新的合并/克隆对象上。

值得注意的是,与类似的工具库lodash.merge(5.1k min+gzip)相比,mergician 更小(1.5k min+gzip)。


原始回答:

既然这个问题仍然存在,那么这是另一种方法:

  • ES6/2015
  • Immutable (不修改原始对象)
  • 处理数组(将它们连接在一起)

/**
* Performs a deep merge of objects and returns new object. Does not modify
* objects (immutable) and merges arrays via concatenation.
*
* @param {...object} objects - Objects to merge
* @returns {object} New object with merged key/values
*/
function mergeDeep(...objects) {
  const isObject = obj => obj && typeof obj === 'object';
  
  return objects.reduce((prev, obj) => {
    Object.keys(obj).forEach(key => {
      const pVal = prev[key];
      const oVal = obj[key];
      
      if (Array.isArray(pVal) && Array.isArray(oVal)) {
        prev[key] = pVal.concat(...oVal);
      }
      else if (isObject(pVal) && isObject(oVal)) {
        prev[key] = mergeDeep(pVal, oVal);
      }
      else {
        prev[key] = oVal;
      }
    });
    
    return prev;
  }, {});
}

// Test objects
const obj1 = {
  a: 1,
  b: 1, 
  c: { x: 1, y: 1 },
  d: [ 1, 1 ]
}
const obj2 = {
  b: 2, 
  c: { y: 2, z: 2 },
  d: [ 2, 2 ],
  e: 2
}
const obj3 = mergeDeep(obj1, obj2);

// Out
console.log(obj3);


6
为了使数组唯一,您可以将 prev[key] = pVal.concat(...oVal); 更改为 prev[key] = [...pVal, ...oVal].filter((element, index, array) => array.indexOf(element) === index); - Richard Herries
1
辉煌。这个示例还展示了数组的合并,这正是我想要的。 - Tschallacka
isObject检查方法太过简单。请使用以下代码:(obj+"") === "[object Object]" - vsync
5
将“prev[key] = pVal.concat(...oVal);”更改为“prev[key] = [...new Set([...oVal, ...pVal])];”可获得独特数组的es6替代方案。 - Davey
我阅读了所有答案,这个库对我来说最合适,并且有适当的配置项,可以根据实际情况进行配置。唯一的遗憾是没有 TypeScript 支持,希望未来能够改进。谢谢! - Simon
显示剩余4条评论

48
我知道已经有很多答案和评论争论它们不起作用。唯一的共识是“它太复杂了,没有人为此制定标准”。然而,在stackoverflow上大多数被接受的答案都揭示了被广泛使用的“简单技巧”。因此,对于像我这样的非专家,想通过更好地掌握JavaScript的复杂性来编写更安全的代码的人,我将尝试阐明一些问题。
在开始之前,让我澄清两点:
[免责声明] 我下面提出了一个函数,它解决了如何深入循环遍历javascript对象进行复制,并说明了通常太简短的注释。它不是生产就绪的。为了清晰起见,我故意忽略了其他考虑因素,比如循环对象(通过集合或无冲突符号属性跟踪),复制引用值或深度克隆,不可变目标对象(再次深度克隆?),每种类型的对象的逐案例研究,通过访问器获取/设置属性... 此外,我没有测试性能-尽管这很重要-因为这也不是重点。
我将使用复制分配术语代替合并。因为在我看来,合并是保守的,应该在冲突时失败。在这里,当发生冲突时,我们希望源覆盖目标。像Object.assign一样。

使用for..inObject.keys的答案是误导性的

制作深拷贝似乎是基本和常见的做法,我们期望能够找到一个一行代码或至少通过简单递归快速解决的方法。我们不认为我们需要一个库或编写一个100行的自定义函数。
当我第一次阅读Salakar's answer时,我真诚地认为我可以做得更好、更简单(您可以将其与x={a:1}, y={a:{b:1}}上的Object.assign进行比较)。然后我阅读了the8472's answer,我想……这样很难逃脱,改进已经给出的答案不会让我们走得更远。
让我们暂时把深拷贝和递归放在一边。只考虑人们如何(错误地)解析属性以复制一个非常简单的对象。
const y = Object.create(
    { proto : 1 },
    { a: { enumerable: true, value: 1},
      [Symbol('b')] : { enumerable: true, value: 1} } )

Object.assign({},y)
> { 'a': 1, Symbol(b): 1 } // All (enumerable) properties are copied

((x,y) => Object.keys(y).reduce((acc,k) => Object.assign(acc, { [k]: y[k] }), x))({},y)
> { 'a': 1 } // Missing a property!

((x,y) => {for (let k in y) x[k]=y[k];return x})({},y)
> { 'a': 1, 'proto': 1 } // Missing a property! Prototype's property is copied too!

Object.keys会省略自身的不可枚举属性、自身的符号键属性和所有原型的属性。如果你的对象没有这些属性,那么这可能是可以的。但请注意,Object.assign处理自身的符号键枚举属性。因此,你的自定义复制将失去其效果。

for..in会提供源的属性、其原型的属性以及整个原型链的属性,而你并不想要(或知道)它们。你的目标可能会有太多的属性,混淆原型属性和自身属性。

如果你正在编写一个通用函数,并且没有使用Object.getOwnPropertyDescriptorsObject.getOwnPropertyNamesObject.getOwnPropertySymbolsObject.getPrototypeOf,那么你很可能做错了。

编写函数前需要考虑的事情

首先,请确保你理解Javascript对象是什么。在Javascript中,一个对象由自身的属性和(父级)原型对象组成。原型对象又由自身的属性和原型对象组成。依此类推,定义了一个原型链。

属性是一个键(stringsymbol)和描述符(valueget/set访问器,以及像enumerable这样的属性)的对。

最后,有许多类型的对象。你可能想要以不同的方式处理一个对象Object和一个对象Date或一个对象Function。

因此,在编写深拷贝时,您应该至少回答以下问题:

  1. 我认为什么是深度拷贝(适合递归查找)或平面拷贝?
  2. 我想要复制哪些属性?(可枚举/不可枚举,字符串键入/符号键入,自有属性/原型的自有属性,值/描述符...)

对于我的示例,我认为只有“对象Object”是“深层”的,因为由其他构造函数创建的其他对象可能不适合进行深入查看。定制自这个SO

function toType(a) {
    // Get fine type (object, array, function, null, error, date ...)
    return ({}).toString.call(a).match(/([a-z]+)(:?\])/i)[1];
}

function isDeepObject(obj) {
    return "Object" === toType(obj);
}

我创建了一个options对象来选择要复制的内容(仅供演示目的)。

const options = {nonEnum:true, symbols:true, descriptors: true, proto:true};

提议的功能

您可以在这个plunker中测试它。

function deepAssign(options) {
    return function deepAssignWithOptions (target, ...sources) {
        sources.forEach( (source) => {

            if (!isDeepObject(source) || !isDeepObject(target))
                return;

            // Copy source's own properties into target's own properties
            function copyProperty(property) {
                const descriptor = Object.getOwnPropertyDescriptor(source, property);
                //default: omit non-enumerable properties
                if (descriptor.enumerable || options.nonEnum) {
                    // Copy in-depth first
                    if (isDeepObject(source[property]) && isDeepObject(target[property]))
                        descriptor.value = deepAssign(options)(target[property], source[property]);
                    //default: omit descriptors
                    if (options.descriptors)
                        Object.defineProperty(target, property, descriptor); // shallow copy descriptor
                    else
                        target[property] = descriptor.value; // shallow copy value only
                }
            }

            // Copy string-keyed properties
            Object.getOwnPropertyNames(source).forEach(copyProperty);

            //default: omit symbol-keyed properties
            if (options.symbols)
                Object.getOwnPropertySymbols(source).forEach(copyProperty);

            //default: omit prototype's own properties
            if (options.proto)
                // Copy souce prototype's own properties into target prototype's own properties
                deepAssign(Object.assign({},options,{proto:false})) (// Prevent deeper copy of the prototype chain
                    Object.getPrototypeOf(target),
                    Object.getPrototypeOf(source)
                );

        });
        return target;
    }
}

可以像这样使用:

const x = { a: { a: 1 } },
      y = { a: { b: 1 } };
deepAssign(options)(x,y); // { a: { a: 1, b: 1 } }

谢天谢地!这对我有用!我说“target始终是“较小”的对象”是正确的吗?谢谢。 - The gates of Zion
这可能是我最喜欢的答案,因为它考虑了所有环境因素。然而,代码中存在一个错误,你应该注意一下。基本上,如果目标具有非“深层”对象属性但源具有该属性,则它不会深度克隆源,所以修改源会导致同时修改目标。可以很容易地修复此问题。另外,并不支持选项模型。const target = { foo: 4 }; const source = { foo: { bar: 5 } }; deepAssign({})(target, source); console.log(target); // target.foo.bar === 5 source.foo.bar = 6; console.log(target); // target.foo.bar === 6 - Agendum

38
如果您想要一个不需要像lodash这样的巨大库的一行代码,我建议您使用deepmergenpm install deepmerge)或deepmerge-tsnpm install deepmerge-ts)。 deepmerge还带有TypeScript的类型,并且更加稳定(因为它较旧),但deepmerge-ts也可以Deno中使用,并且是设计更快的, 尽管如其名称所示,它是用TypeScript编写的。
导入后,您可以执行以下操作
deepmerge({ a: 1, b: 2, c: 3 }, { a: 2, d: 3 });

to get

{ a: 2, b: 2, c: 3, d: 3 }

这适用于复杂对象和数组,是一种真正的全能解决方案。


2
找了好几个小时,这个救了我一天,能够合并深层对象,正如你所说的全能型,干杯! - usernotnull
11
你不必要求整个lodash库。你只需要请求你需要的部分:const merge = require('lodash.merge'); - barney765
太棒了,完美无缺!谢谢! - otherguy
查看了他们的源代码,不喜欢他们使用了 as / any 进行类型定义。 - godblessstrawberry
@godblessstrawberry 我同意。由于deepmerge-ts更受欢迎,我决定提出一个问题。使用any的情况非常少,但应该避免。旧版的deepmerge不应受到干扰,因为它只适用于JavaScript环境。 - Martin Braun

20
这里,直截了当;
一个简单的解决方案,就像Object.assign一样深入,并且适用于数组,无需任何修改。

function deepAssign(target, ...sources) {
  for (source of sources) {
    for (let k in source) {
      let vs = source[k], vt = target[k]
      if (Object(vs) == vs && Object(vt) === vt) {
        target[k] = deepAssign(vt, vs)
        continue
      }
      target[k] = source[k]
    }
  }
  return target
}

x = { a: { a: 1 }, b: [1,2] }
y = { a: { b: 1 }, b: [3] }
z = { c: 3, b: [,,,4] }
x = deepAssign(x, y, z)

console.log(JSON.stringify(x) === JSON.stringify({
  "a": {
    "a": 1,
    "b": 1
  },
  "b": [ 1, 2, null, 4 ],
  "c": 3
}))

编辑: 我在其他地方回答了关于深度比较两个对象的新方法。 这个方法也可以用于深度合并。如果你想要实现,请留下评论 https://dev59.com/fmoy5IYBdhLWcg3wa9Qj#71177790

你应该在测试用例中使用更多的数据类型变量(new Date(), a(){}, null, undefined, 0)。 - vsync
发现重大错误 - https://jsbin.com/javefudife/1/edit?html,js,console - vsync
什么是 Bug?你所使用的示例非常简单。在控制台中检查它会得到正确的结果。JSBin 看起来有点错误。 - pery mimon
1
抛出错误:对象不可迭代,给定输入为:{ "CommandWorkflows": { "businessRules": [{ "arrayParsing": [{ "characterArrayParsing": [{ "Workflow": [{ "$": { "Name": "doesArrayContainCharacter", "Value": "cmdgen bizRul,doesArrayContainCharacter,$,[the|answer|to|life|the|universe|and|everything|is|$42] 4"}}]}]}]}]}} - Seth Eden

17
许多答案使用几十行代码,或者需要向项目中添加一个新库,但如果您使用递归,则只需4行代码。

function merge(current, updates) {
  for (key of Object.keys(updates)) {
    if (!current.hasOwnProperty(key) || typeof updates[key] !== 'object') current[key] = updates[key];
    else merge(current[key], updates[key]);
  }
  return current;
}
console.log(merge({ a: { a: 1 } }, { a: { b: 1 } }));

数组处理:上述版本将旧的数组值与新的值进行覆盖。如果您希望保留旧的数组值并添加新的值,只需在else语句之前添加else if (current[key] instanceof Array && updates[key] instanceof Array) current[key] = current[key].concat(updates[key])代码块即可。


2
我喜欢它,但它需要一个简单的未定义检查来检查'current',否则{foo: undefined}不会合并。只需在for循环之前添加if(current)即可。 - Andreas Pardeike

15
这里是TypeScript的实现:
export const mergeObjects = <T extends object = object>(target: T, ...sources: T[]): T  => {
  if (!sources.length) {
    return target;
  }
  const source = sources.shift();
  if (source === undefined) {
    return target;
  }

  if (isMergebleObject(target) && isMergebleObject(source)) {
    Object.keys(source).forEach(function(key: string) {
      if (isMergebleObject(source[key])) {
        if (!target[key]) {
          target[key] = {};
        }
        mergeObjects(target[key], source[key]);
      } else {
        target[key] = source[key];
      }
    });
  }

  return mergeObjects(target, ...sources);
};

const isObject = (item: any): boolean => {
  return item !== null && typeof item === 'object';
};

const isMergebleObject = (item): boolean => {
  return isObject(item) && !Array.isArray(item);
};

单元测试:

describe('merge', () => {
  it('should merge Objects and all nested Ones', () => {
    const obj1 = { a: { a1: 'A1'}, c: 'C', d: {} };
    const obj2 = { a: { a2: 'A2'}, b: { b1: 'B1'}, d: null };
    const obj3 = { a: { a1: 'A1', a2: 'A2'}, b: { b1: 'B1'}, c: 'C', d: null};
    expect(mergeObjects({}, obj1, obj2)).toEqual(obj3);
  });
  it('should behave like Object.assign on the top level', () => {
    const obj1 = { a: { a1: 'A1'}, c: 'C'};
    const obj2 = { a: undefined, b: { b1: 'B1'}};
    expect(mergeObjects({}, obj1, obj2)).toEqual(Object.assign({}, obj1, obj2));
  });
  it('should not merge array values, just override', () => {
    const obj1 = {a: ['A', 'B']};
    const obj2 = {a: ['C'], b: ['D']};
    expect(mergeObjects({}, obj1, obj2)).toEqual({a: ['C'], b: ['D']});
  });
  it('typed merge', () => {
    expect(mergeObjects<TestPosition>(new TestPosition(0, 0), new TestPosition(1, 1)))
      .toEqual(new TestPosition(1, 1));
  });
});

class TestPosition {
  constructor(public x: number = 0, public y: number = 0) {/*empty*/}
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接