
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的第一个参数是“目标对象”它会被直接修改。如果不想改到原对象必须传一个空对象作为 targetconst 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.getOwnPropertyDescriptorsObject.definePropertiesconst 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: falseObject.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 objectLodash mergeDate保留原引用变成字符串变成空对象复用之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”这类问题回来检查合并逻辑基本都是某个边界条件没考虑到。把文章里这五类问题都过一遍你排查合并问题的时候会快很多也希望这些经验能帮你少熬几个加班的夜。