拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaScript对象合并全指南:从浅拷贝到深合并的边界与实战

JavaScript对象合并全指南:从浅拷贝到深合并的边界与实战

1. 对象合并的核心场景与本质挑战

1.1 先看三个最常见的合并场景

先说结论:JavaScript 对象合并大概是前端日常开发里出现频率最高、同时也最容易被低估的操作之一。很多朋友写代码时随手Object.assign({}, a, b)或者{...a, ...b},初看没毛病,结果某天线上出了诡异 bug,排查半天发现是对象合并的坑。

我总结了一下,工作里最常碰到的合并需求基本逃不出这三类:

第一类是配置合并。典型场景是组件库、SDK、工具函数提供的“默认配置 + 用户配置”。比如一个弹窗组件,内置了width: 400, title: '', animation: true这种默认参数,用户传进来一个{ width: 600, visible: false },你不可能让用户把全部配置都补齐,于是要做合并。

const defaultOptions = { width: 400, title: '', animation: true }; const userOptions = { width: 600, visible: false }; const finalOptions = { ...defaultOptions, ...userOptions }; // { width: 600, title: '', animation: true, visible: false }

第二类是数据处理。后端接口返回的数据结构往往和前端组件需要的结构不完全一致,或者两个接口的数据需要拼在一起,例如把分页信息和列表数据合并成一个统一的 state。

const pageState = { page: 1, pageSize: 10, total: 128 }; const listState = { list: [], loading: false }; const mergedState = { ...pageState, ...listState };

第三类是状态更新。尤其是 React 里写 reducer、或者用 Vue 的响应式对象做局部更新时,经常要“保留旧值、局部覆盖新值”。

// reducer 里常见的写法 case 'SET_FILTER': return { ...state, filter: { ...state.filter, ...action.payload } };

这三类场景表面上都是“把两个对象合在一起”,但实际要求差别很大。配置合并通常是静态的,数据合并可能要处理嵌套结构,状态更新则往往要求不可变性(不能直接改原 state)。所以不能一招鲜吃遍天,后面每一种方案我都会提到“它适合哪种场景、不适合哪种场景”。

说句实在话,我见过不少团队的项目里,对象合并的代码是“能用就行”的状态,没有统一约定,甚至同一个项目里_.merge、Object.assign、展开运算符、手写 for 循环混着来。要踩坑的时候一个都跑不掉。这篇文章就把各种合并手段的边界、原理和使用禁忌摊开来聊一遍。

1.2 合并前必须搞懂:浅拷贝与深拷贝的分界线

这是整个对象合并知识体系里最重要的一层地基。

先看一个非常反直觉的例子:

const defaults = { theme: 'dark', layout: { sidebar: true, header: true } }; const user = { theme: 'light' }; const merged = { ...defaults, ...user }; console.log(merged.layout === defaults.layout); // true merged.layout.sidebar = false; console.log(defaults.layout.sidebar); // false,原对象的 layout 也被改了

这个例子解释了一个核心事实:{...a, ...b}这类浅合并,把对象a和b的第一层属性拷进了新对象,但属性值如果是引用类型(对象、数组),拷贝的是内存地址,不是数据本身。

可以这么理解:两个对象共用了一个“储物柜”,你用merged.layout改了储物柜里的东西,defaults.layout自然是同一只储物柜,里面的内容当然也跟着变。

这就是浅合并和深合并的分界线:

  • 浅合并:只拷贝第一层属性,嵌套对象保持同一引用。
  • 深合并:递归遍历所有层级,每一层都生成全新的对象,合并结果与原对象彻底断开引用关系。

项目中很多诡异问题都是这条分界线没搞清楚导致的。比如你封装了一个组件,外部某段代码拿到合并后的配置,顺手改了finalConfig.layout.sidebar,结果下一次组件渲染时发现默认布局也变了——就是这种共享引用在作祟。

另外要留意一点:JavaScript 里的“深拷贝”和“深合并”是两个概念。深拷贝是把一个对象完整复制一份,和原对象无关联;深合并则是把多个对象递归合并到一个新对象中,同名字段后者覆盖前者,没有交集的部分各自保留。JSON.parse(JSON.stringify())是深拷贝手段,不是深合并手段。很多人以为用它能完成“深合并”,这是后续第 3 节要专门强调的误区。

看过了这些基础,就可以开始讨论具体工具了。

2. 浅合并两大主力对比:Object.assign 和展开运算符到底该选谁

2.1 Object.assign 的几个关键行为:返回值会变、不拷贝原型链

Object.assign(target, ...sources)是老牌的官方方案,语法很直白:把 source 对象的属性复制到 target 对象上,并返回 target 对象。

const target = { a: 1 }; const source = { b: 2 }; const result = Object.assign(target, source); console.log(result === target); // true,返回的是被修改后的 target

这里有一个新手特别容易忽略的点:Object.assign的第一个参数是“目标对象”,它会被直接修改。如果不想改到原对象,必须传一个空对象作为 target:

const a = { x: 1 }; const b = { y: 2 }; // 污染了原对象 const r1 = Object.assign(a, b); // a 变成 { x:1, y:2 } // 不污染原对象 const r2 = Object.assign({}, a, b); // 新对象 { x:1, y:2 }

除了“改原对象”这个坑,Object.assign还有几个非常明确的行为边界:

  • 只拷贝自身可枚举属性,不会拷贝原型链上的属性,也不拷贝不可枚举属性。
  • 源对象为null或undefined时会被直接忽略,不会抛错。
  • 目标对象为null或undefined时会抛TypeError。
Object.assign({}, null, undefined); // {},不报错 Object.assign(null, { a: 1 }); // TypeError: Cannot convert undefined or null to object

而函数本身也是对象。如果合并的目标里包含函数属性,Object.assign会对函数引用原样复制,不会调用它、也不会特殊处理。

结合真实项目经验,我的建议是:尽量用Object.assign({}, ...)的标准写法,也就是始终传一个空对象作为第一个参数,避免无意间改动现有对象。如果团队代码规范允许 ES2018 之后的语法,我更推荐直接使用展开运算符,因为它的语义更贴近“我想要一个全新对象”的直觉。

2.2 展开运算符:更符合直觉,但多了一个隐藏细节

对象展开运算符{...a, ...b}是 ES2018 引入的语法,现在已经是普通水平,几乎不存在兼容性限制了。

const a = { name: 'app', config: { theme: 'dark' } }; const b = { version: '1.0.0', config: { theme: 'light' } }; const merged = { ...a, ...b }; // { name: 'app', version: '1.0.0', config: { theme: 'light' } }

它和Object.assign最大的使用差异是:展开运算符永远生成一个新对象,不存在修改目标对象的问题。它从右往左合并,后面的键覆盖前面的键。

下面这个表格是我在项目里总结出来的对比,几个关键差异值得背下来:

对比维度Object.assign(a, b){ ...a, ...b }
返回值返回修改后的 target(通常就是 a)返回全新对象
修改原对象会修改第一个参数对象不会修改任何参与合并的对象
触发 target 上的 setter会触发不会触发
源对象为 null/undefined忽略,不报错忽略,不报错
拷贝可枚举 Symbol 属性会会
属性写入方式使用Set语义使用CreateDataProperty(定义属性)语义

先解释最关键的一点。Object.assign在写入属性时走的是赋值逻辑,如果在目标对象上有同名的 setter 访问器,这个 setter 会被执行。而对象展开运算符在对象字面量里定义属性时,走的是类似Object.defineProperty的路径,不会触发 setter。

const target = { get count() { return this._count || 0; }, set count(value) { console.log('setter 被调用了:', value); this._count = value; } }; const source = { count: 100 }; Object.assign(target, source); // 控制台输出:setter 被调用了:100 const spread = { ...target, ...source }; // 不会触发 setter console.log(spread.count); // 100

实际场景里最容易被这个差异坑到的地方是:当你把对象合并进一个 Vue 或 MobX 的响应式实例、或者其他重写了setter的对象时,Object.assign可能触发各种副作用,而展开运算符则不会。如果只是在两个普通对象之间做合并,两者行为基本一致,选哪个更多是团队风格问题。

不过展开运算符也有它自己的“隐藏细节”。比如展开字符串:

console.log({ ...'abc' }); // { 0: 'a', 1: 'b', 2: 'c' }

字符串会被展开成下标索引属性。这个在日常合并对象时基本不会主动用它,但了解它会避免看见控制台输出时满头问号。

还有一点是企业级项目里经常踩的:展开运算符只能做一层合并。嵌套对象照样是引用共享。

const defaults = { options: { cache: true, retry: 3 } }; const incoming = { options: { cache: false } }; const merged = { ...defaults, ...incoming }; // merged.options 是 incoming.options 的引用,defaults.options 的 retry 被丢了 // 结果: { options: { cache: false } }

这其实不算 bug,而是浅合并的正常行为。如果你希望merged.options.retry还能保留默认值 3,就必须用深合并手段,这个留到第 3 节。

2.3 属性描述符问题:getter、setter、只读属性怎么处理

这是绝大多数教程不会展开、但线上 bug 最容易出没的地方。

先补充一个 JavaScript 基础:对象的每个属性背后都有一个“属性描述符”(Property Descriptor),里面包含了value、writable、enumerable、configurable,或者get、set。用Object.getOwnPropertyDescriptor(obj, key)可以查看:

const obj = { get name() { return 'lin' } }; console.log(Object.getOwnPropertyDescriptor(obj, 'name')); // { get: [Function: get name], set: undefined, enumerable: true, configurable: true }

利用Object.assign或展开运算符合并有 getter 的属性时,两者做的事情是读取 getter 的返回值,再把值写入目标对象。也就是说,源对象上“看起来是个 getter”的属性,合并后变成了一个普通的值属性,getter 本身丢失了。

const source = { get fullName() { return `${this.first} ${this.last}`; } }; const copy = Object.assign({}, { first: '张', last: '三' }, source); console.log(copy.fullName); // '张 三',值拿到了 console.log(Object.getOwnPropertyDescriptor(copy, 'fullName')); // { value: '张 三', writable: true, enumerable: true, configurable: true } // 没有 get,说明 getter 编程了普通值

如果你确实需要“保留原属性描述符”的浅拷贝,正确的姿势是Object.getOwnPropertyDescriptors+Object.defineProperties:

const shadowClone = Object.defineProperties( {}, Object.getOwnPropertyDescriptors(source) ); console.log(Object.getOwnPropertyDescriptor(shadowClone, 'fullName')); // { get: [Function: get fullName], ... }

不过要提醒一下,Object.getOwnPropertyDescriptors会包含不可枚举属性。如果你只想拷贝可枚举属性但保留描述符,需要自己过滤一遍:

const descriptors = Object.getOwnPropertyDescriptors(source); const enumerableDescriptors = {}; for (const [key, desc] of Object.entries(descriptors)) { if (desc.enumerable) enumerableDescriptors[key] = desc; } const filteredClone = Object.defineProperties({}, enumerableDescriptors);

对于“只读属性”(writable: false),Object.assign也可能出问题。假如目标对象上已经有一个同名只读属性,Object.assign在非严格模式下会静默失败,在严格模式下直接抛TypeError。展开运算符因为走的是定义属性逻辑,所以只要目标对象可配置,一般不会受到只读限制。

这个细节我单独拿出来讲的原因很简单:它太符合“两年不踩一次、一踩就要加班定位”的特征了。尤其是当你和其他同事写的代码做衔接,对方给某个对象属性加了writable: false或者Object.freeze,你用Object.assign去合并就有可能在线上环境蹦出一个莫名其妙的 TypeError。

3. 深合并实战:手写 deepMerge 之前必须了解三条路线

3.1 JSON 序列化合并:三行代码,坑比想象中多

网上很多文章提到“深拷贝”第一反应就是:

const deepCopy = JSON.parse(JSON.stringify(obj));

把它用在“合并”场景时也常见到类似写法:

const merged = JSON.parse(JSON.stringify({ ...defaults, ...userConfig }));

先用浅合并把单层覆盖做掉,再用 JSON 序列化的方式把嵌套对象“深拷”出来,以此切断引用关系。

这么写对“纯数据”场景(普通对象、数组、字符串、数字、布尔值、null)确实有效,而且代码极短。但代价也很大,我直接列一张我在项目中实测遇到的失真清单:

数据类型JSON.stringify 后的结果典型后果
undefined属性值该属性被丢弃合并后字段缺失
function属性值该属性被丢弃(数组中变null)配置里的回调函数消失
Symbol属性值该属性被丢弃合并后想用符号键报错
Date对象变成 ISO 字符串时间戳字段变成字符串
RegExp对象变成空对象{}正则失效
Map/Set变成空对象{}数据丢失
BigInt属性值直接抛 TypeError程序运行时报错
循环引用抛 TypeError合并直接失败

举例说明。

const config = { name: 'demo', onSuccess: () => console.log('ok'), createdAt: new Date(), rule: /^[a-z]+$/, }; const merged = JSON.parse(JSON.stringify(config)); // 输出结果: // { name: 'demo', createdAt: '2025-...T...' } // onSuccess、rule 全没了

如果你只是合并两个后端返回的纯 JSON 对象,这个方案的简洁性是无可替代的,但项目中一旦配置项里挂了函数(回调)、日期、正则,它就会成为定时炸弹。

所以我在团队里一般给这条路线定一条铁律:只有数据来源是 JSON、且业务明确不包含函数、Date、RegExp 时,才允许使用 JSON 序列化法深合并。其他情况一律往后看。

3.2 手写递归合并:可控性最强,也最容易写错

当合并的数据结构不固定、类型复杂时,手写一个递归合并函数是最踏实的方案。它的核心逻辑其实不复杂:遍历源对象的每个 key,如果目标对象和源对象在该 key 下的值都是普通对象,就递归合并;否则直接用源对象的值覆盖。

下面是我在项目里使用的一个相对完整的基础版本,融合了Reflect.ownKeys处理 Symbol、WeakMap处理循环引用等关键设计:

function isPlainObject(value) { if (Object.prototype.toString.call(value) !== '[object Object]') { return false; } const proto = Object.getPrototypeOf(value); return proto === null || proto === Object.prototype; } function deepMerge(target, source, seen = new WeakMap()) { // 源不是普通对象时直接替换 if (!isPlainObject(source)) { return source; } // 处理循环引用:如果 source 之前已经合并过,直接返回记录的合并结果 if (seen.has(source)) { return seen.get(source); } // 合并结果以 target 为基础,生成新对象 const result = { ...target }; seen.set(source, result); for (const key of Reflect.ownKeys(source)) { const sourceValue = source[key]; if (isPlainObject(sourceValue) && isPlainObject(target[key])) { result[key] = deepMerge(target[key], sourceValue, seen); } else { result[key] = sourceValue; } } return result; }

用起来效果如下:

const defaults = { theme: { primary: '#333', spacing: 8 }, retry: { times: 3, backoff: 1000 }, }; const user = { theme: { primary: '#ff6600' }, }; const merged = deepMerge(defaults, user); console.log(merged); // { // theme: { primary: '#ff6600', spacing: 8 }, // retry: { times: 3, backoff: 1000 }, // }

这次嵌套对象里的spacing: 8被保留了下来,实现了真正意义上的“递归合并”。

这里提示三个手写时容易踩的细节:

第一,Array.isArray要单独处理。上面的代码里,数组不是普通对象,所以走的是“直接替换”。但有些场景希望数组也做合并(比如按下标递归合并),有些场景希望直接替换。我建议把它做成一个配置项,而不是在函数里硬编码,后面第 4 节会专门展开数组策略。

第二,isPlainObject的判断很重要。如果你用typeof value === 'object'做判断,Date、RegExp、Map都会被当成普通对象递归进入,结果变成一个空壳。

第三,循环引用必须处理。如果某个对象直接或间接引用了自身,简单的递归会无限循环最终爆栈。用WeakMap记录“已经处理过的 source 对象”是最常见的解法:

const obj = {}; obj.self = obj; deepMerge({}, obj); // 不会爆栈

手写深合并的主要优点是完全可控:合并哪一层、遇到数组怎么处理、函数怎么处理、Symbol 要不要合并,全部由你说了算。缺点则是需要自己承担逻辑正确性,尤其要写好单元测试。我的习惯是把手写版维护在项目的utils/deepMerge.js里,并配几个基础的测试用例,确保后续同事改动时有兜底。

3.3 引入一个小工具库:Lodash merge 与 mergeWith 的取舍

如果不想自己维护递归逻辑,Lodash 是经典选择。_.merge(target, source)会递归合并可枚举属性,并且从右往左覆盖。

import _ from 'lodash'; const defaults = { options: { cache: true, retry: 3 }, }; const user = { options: { cache: false }, }; _.merge(defaults, user); // defaults 变成 { options: { cache: false, retry: 3 } } // 注意:_.merge 会直接修改第一个参数对象

几个要点:

  • _.merge会修改第一个参数对象。需要保留默认配置时,记得写成_.merge({}, defaults, user)。
  • _.merge对数组是按下标递归合并的,不是整体替换。比如{ a: [1, 2] }和{ a: [3] }合并后得到{ a: [3, 2] }。
  • _.merge在合并过程中会调用目标对象上的 setter(和Object.assign类似的坑)。
  • _.merge对 Symbol 等特殊键的处理在不同 Lodash 版本中并不一致,不建议依赖官方默认行为。

需要自定义合并策略时,用_.mergeWith,它允许传入自定义函数,在返回undefined时走默认逻辑:

import _ from 'lodash'; const result = {}; _.mergeWith( result, { handlers: { click: () => console.log('click') } }, { handlers: { hover: () => console.log('hover') } }, (objValue, srcValue, key, object, source) => { if (typeof srcValue === 'function') { // 所有函数都直接保留,不进入默认合并 return srcValue; } return undefined; // 交给 Lodash 默认处理 } );

什么时候用 Lodash?我的标准很简单:项目里已经引入了 Lodash,那么用_.merge是顺理成章的;为了一个 merge 单独把整个 Lodash 引入项目就太重量级了,这时候手写递归函数更划算。如果用的是现代原生能力也没问题,关注一下 tree-shaking 是否有效即可。

4. 合并中那些容易被忽略的编码细节与边界场景

4.1 Symbol、不可枚举属性与“完整浅拷贝”

很多开发者并不知道,Object.assign和对象展开运算符都会拷贝可枚举的 Symbol 属性:

const s1 = Symbol('s1'); const source = { [s1]: 1, regular: 2 }; const merged = { ...source }; console.log(merged[s1]); // 1

但这里的“可枚举”三个字是前提。合并时如果你用到for...in或者只看Object.keys(),那 Symbol 键、不可枚举键都会被漏掉。为了清晰起见,我整理了一个属性遍历方式的对照表:

方法自身 Symbol 属性自身不可枚举属性原型链上的可枚举属性
for...in不包含不包含包含
Object.keys()不包含不包含不包含
Object.getOwnPropertyNames()不包含包含不包含
Reflect.ownKeys()包含包含不包含

如果项目里存在“既要拷贝这些特殊属性、又要保留属性描述符”的需求,最稳妥的做法是先用Reflect.ownKeys()拿到全部属性,再用Object.getOwnPropertyDescriptor逐项读取描述符并判断枚举性,最后用Object.defineProperties写入新对象。这种代码写起来比较啰嗦,建议封装成函数。

function cloneEnumberableWithDescriptors(source) { const descriptors = {}; for (const key of Reflect.ownKeys(source)) { const descriptor = Object.getOwnPropertyDescriptor(source, key); if (descriptor && descriptor.enumerable) { descriptors[key] = descriptor; } } return Object.defineProperties({}, descriptors); }

我在实际项目中见过一个特别典型的 case:某个用装饰器或枚举模式写的类实例,内部属性用 Symbol 做键,且enumerable: false。业务方想把它合并到另一个对象里,结果用Object.assign怎么都拿不到对应数据,排查很久才发现是“属性根本没被遍历到”。如果早点理解属性遍历的边界,这类问题几乎可以一眼定位。

4.2 数组策略:合并、替换还是拼接?提前定好规则

深合并遇到数组时,最容易产生分歧。不同开发者对“数组怎么合并”的理解完全可能不同,我总结一下项目里真实见过的三类策略:

策略一:直接替换。源对象的数组整体覆盖目标对象的数组。这是手写深合并默认最安全的做法,符合大多数人的直觉。

const target = { tags: ['a', 'b'] }; const source = { tags: ['c'] }; // 替换结果:{ tags: ['c'] }

策略二:按下标合并。Lodash 的_.merge就是这种策略。target和source的同下标元素递归合并,前提是目标数组存在对应下标元素;否则直接使用源数组当前项。

// target.tags = ['a', 'b']; // source.tags = ['c']; // 结果:{ tags: ['c', 'b'] }

策略三:拼接。把源数组看作“追加项”,合并后数组是[...target.tags, ...source.tags],或者做去重。

// 结果:{ tags: ['a', 'b', 'c'] }

这三种策略没有绝对优劣,只看业务语义。比如配置项里定义的是“class 列表”,你可能希望后者完全覆盖前者;如果是“事件处理函数列表”,你可能希望拼接而不丢。所以我在封装手写 deepMerge 时,数组合并逻辑一定是可配置的:

function deepMerge(target, source, options = {}) { // options.arrayMode === 'replace' | 'concat' | 'merge' }

另外要提醒一点:数组本身也是对象,如果你在建递归函数时只用typeof value === 'object'判断,会把数组也递归展开成{ 0: ..., 1: ... },合并结果直接变成带下标的普通对象。所以先判断Array.isArray再决定怎么处理,顺序一定不能乱。

4.3 特殊对象怎么处理:Date、RegExp、Map、Error、函数

JavaScript 里对象不只有普通对象和数组,还有Date、RegExp、Map、Set、Error、函数等。不同合并工具对它们的处理差异非常大。

我用一张表展示它们在各种合并手段下的表现:

类型Object.assign/展开运算符JSON 序列化法简单递归(只判断 typeof object)Lodash merge
Date保留原引用变成字符串变成空对象复用之
RegExp保留原引用变成{}变成空对象复用之
Map保留原引用变成{}变成空对象复用之
Error保留原引用变成{}变成空对象复用之
Function保留原引用丢弃/变 null保留原引用保留原引用

这里最隐形的坑是“简单递归”那一列。如果我手写递归时用typeof value === 'object'判断,Date实例确实满足条件,于是会被当成普通对象递归展开。但Date实例的属性几乎都不在自身对象上,最终递归结果是一个空对象。

function naiveMerge(target) { const result = {}; for (const key of Object.keys(target)) { const value = target[key]; if (typeof value === 'object' && value !== null) { result[key] = naiveMerge(value); // Date 会走进来,然后变空 } else { result[key] = value; } } return result; } console.log(naiveMerge({ time: new Date() })); // { time: {} }

要避免这个问题,必须用“白名单 + 黑名单”的思路来判断哪些类型需要递归、哪些类型直接引用。我在手写 deepMerge 里用的方案是:只有isPlainObject(原型是Object.prototype或null)才递归,Date、RegExp、Map、Set等一律视为“原子值”直接替换,不进入递归逻辑。

if (isPlainObject(sourceValue) && isPlainObject(target[key])) { result[key] = deepMerge(target[key], sourceValue, seen); } else { result[key] = sourceValue; }

这里还有一个生产环境的实战经验:如果你在 Node.js 服务端处理配置合并,Buffer对象也应该被视为原子值。Buffer如果被递归展开,内存和正确性都会出大问题。所以判断普通对象时,Buffer也会被isPlainObject排除在外,这恰好是安全的。

对于函数属性,大部分合并策略都是“直接覆盖引用”。如果你需要保留多个函数并依次调用(比如事件订阅场景),那就不能用普通合并语义了,得自己定义“函数数组”的合并规则,或用mergeWith做定制。这个属于业务设计问题,工具本身解决不了。

5. 高频问题排查速查表与方案选型建议

5.1 合并结果与预期不符的排查清单

这里我整理了一张“踩坑速查表”,基本覆盖了我这几年同事提问和线上问题里的高频问题。遇到问题时先对着表格过一遍,能省下大量调试时间:

现象可能原因解决方案
合并后修改嵌套对象,原对象跟着变浅合并,嵌套对象仍是同一引用改用深合并方案
配置里的回调函数合并后消失用了 JSON 序列化法,函数被丢弃改用手写递归或 Lodash merge
日期字段合并后变成字符串用了 JSON 序列化法避免对含 Date 的对象使用 JSON 法
期望深合并,结果第二层以后被整体覆盖用了Object.assign或展开运算符递归合并或使用_.merge
合并后看得到某个属性,但值是 undefined源对象显式传了undefined,覆盖了旧值合并前过滤掉undefined属性
某些属性怎么拷都不出来属性不可枚举,或键是 Symbol 但遍历方法没选对用Reflect.ownKeys或getOwnPropertyDescriptors
合并不报错但结果完全变了目标对象上有 setter 被触发优先用展开运算符代替 Object.assign
深合并循环引用时爆栈递归没有防循环处理增加 WeakMap 记录已处理对象
Date、RegExp 在简单递归后变成空对象递归判断条件过宽,把特殊对象当成普通对象用isPlainObject限定递归范围
数组合并后不是想要的拼接/覆盖深合并的数组策略不符合业务预期明确策略并配置化处理

这里面有些坑是叠加的。比如“回调函数消失+日期变字符串”通常同源于 JSON 序列化;而“数组合并结果怪”则可能是 Lodash 的默认按下标合并和你的业务语义冲突。定位时不要只盯着表面现象,先想清楚代码里走的是哪种合并逻辑,问题往往自己就浮出来了。

5.2 按业务场景选择合并方式:一张表的事

不同业务场景其实有非常清晰的推荐方案,不需要每次拍脑袋。我建议团队在模块里提前约定好合并工具,避免“想用哪个用哪个”的混乱:

业务场景推荐方案理由
简单配置叠加,嵌套不超过两层{ ...defaults, ...user }写法直观,不修改原对象
复杂嵌套配置,对象层级较深手写 deepMerge 或_.merge递归保留默认值
Redux/Vuex 状态更新展开运算符 + 按层展开保持不可变性,不随意深合并
合并后端返回的纯 JSON 数据JSON 序列化法或structuredClone数据结构简单,快速可靠
配置含函数、Date、RegExp、Map 等类型手写 deepMerge 或_.mergeWith避免类型被错误转换
用户自定义行为很强的合并且_.mergeWith或自定义 deepMerge函数、数组、特殊类型都要定制

特别讲一下 Redux/Vuex 状态更新的场景。很多人一上来就“深合并”,但状态管理往往要求的是“新对象 + 未修改的部分保持原引用”,这样依赖引用做 memo 优化的组件才能正确跳过渲染。如果无脑深合并所有嵌套层级,会导致大面积重渲染,性能反而下降。正确的姿势是浅合并 + 按需展开需要变更的层级:

// 只更新 filter 这一层 return { ...state, filter: { ...state.filter, ...action.payload, }, };

这就是为什么不能只学一种合并方式,而是要理解场景再选方案。

5.3 项目落地时的几条实操建议

最后聊几个实操中的习惯,这些不是理论,全是项目里沉淀下来的约束。

第一,能浅合并就别上来就深合并。深合并有性能成本和语义复杂性。绝大多数配置场景其实只需要一层覆盖,用展开运算符就够了。我见过有些项目把每个对象合并都统一换成_.merge,最后排查问题时反而更难判断“到底哪一层被覆盖了”。合并深度越深,心智负担越重。

第二,手写 deepMerge 一定要配套测试。至少覆盖普通对象、数组、Date、函数、循环引用、Symbol 这六类 case。我第一次写 deepMerge 时自我感觉良好,结果 Date 被递归成空对象的 bug 靠测试才抓出来。测试代码不长,但能给后续使用的人兜底。

// 一个简单测试用例 const merged = deepMerge( { a: { b: 1 }, date: new Date('2024-01-01') }, { a: { c: 2 } } ); console.log(merged.a); // { b: 1, c: 2 } console.log(merged.date instanceof Date); // true

第三,合并操作尽量不要修改原对象。无论用Object.assign还是Lodash merge,都记得传入{}作为目标,或者始终使用会生成新对象的展开运算。修改原对象的代码短期内能跑,长期会成为隐性地雷,尤其是当原对象是某个模块的共享单例时。

第四,留意兼容性边界。项目如果还要跑在旧版浏览器或旧版 Node 上,展开运算符和Object.getOwnPropertyDescriptors可能需要编译配置支持。不过以当前前端工程的普遍环境来说,这类语法基本都能安全使用;“完整浅拷贝”用getOwnPropertyDescriptors前建议看一眼团队的目标浏览器列表。

第五,对象合并和深拷贝是两个概念,别把structuredClone不当回事。现代浏览器和 Node 17+ 内置了structuredClone,它可以深拷贝 Date、RegExp、Map、Set 等类型,但它是“拷贝”,不是“合并”。如果业务需要把整个对象完整替换为新副本,直接用structuredClone比JSON.parse(JSON.stringify())可靠得多;但如果业务希望“默认值和用户值递归合并”,它又不能替代 deepMerge。

我在实际维护项目的过程中,最大的感受是:对象合并看着简单,但错误地使用它会带来非常隐蔽的 bug。每次遇到“数据莫名其妙被改”“函数丢了”“日期变string”这类问题,回来检查合并逻辑,基本都是某个边界条件没考虑到。把文章里这五类问题都过一遍,你排查合并问题的时候会快很多,也希望这些经验能帮你少熬几个加班的夜。

返回列表