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”这类问题,回来检查合并逻辑,基本都是某个边界条件没考虑到。把文章里这五类问题都过一遍,你排查合并问题的时候会快很多,也希望这些经验能帮你少熬几个加班的夜。