资讯动态

深入理解 deepMerge:配置合并、原型污染与循环引用防护

发布时间:2026/10/27 6:53:55 来源:尧图企业网站定制
组件库的默认主题在某次升级后突然串色主色对了圆角对了偏偏阴影层级全掉回默认值。排查了半天最后发现问题出在那行看着人畜无害的Object.assign(defaults, userOptions)上——用户的配置里有个shadow: { color: #000 }浅合并把整个shadow对象替换掉了offsetY、blur、spread这些没被用户提到的字段当场蒸发。这类问题在配置驱动的项目里出现频率高得离谱而deepMerge深合并就是专门治它的药它能让默认配置和自定义配置逐层对齐用户改哪一层就动哪一层没提到的字段继续沿用默认值。这篇内容写给所有要处理默认值 用户覆盖场景的人不管你做的是图表配置、主题系统、构建参数还是请求实例的兜底配置只要你的配置对象有嵌套就绕不开这一关。下面我会把浅合并在什么情况下失效、深合并的类型判定怎么写、数组和 null 这类语义决策怎么定、原型污染和循环引用怎么防以及怎么把它接进真实项目一层层拆开讲。1. 默认配置被吃掉的那一半浅合并留下的空缺1.1 一次主题配置错乱的完整复盘先把这个场景说透。假设有一个图表组件的默认配置长这样const defaultOptions { theme: { color: #2b6cb0, font: { size: 12, family: sans-serif, weight: 400 }, shadow: { color: rgba(0,0,0,0.1), offsetY: 2, blur: 8, spread: 0 } }, grid: { top: 20, right: 20, bottom: 30, left: 40 }, series: [] };调用方只想改主色和阴影颜色const userOptions { theme: { color: #e53e3e, shadow: { color: rgba(229,62,62,0.2) } } }; const merged Object.assign({}, defaultOptions, userOptions);结果merged.theme.shadow只剩{ color: rgba(229,62,62,0.2) }offsetY、blur、spread全部丢失。更麻烦的是merged.theme.font反而还在——因为用户压根没提font这一层整个theme.font是从默认配置原样带过来的。于是表现出的现象是有的子配置还在有的没了非常容易让人误判成组件内部某个地方写死了值。我在项目里遇到这种 bug 时第一反应永远是去翻配置合并那段代码而不是去翻渲染逻辑。因为浅合并造成的丢失有个很典型的特征丢失的字段总是集中在用户部分覆盖的那一个对象里而用户完全没碰的分支是完好的。按这个特征去定位基本五分钟内能锁定。1.2 Object.assign 与展开运算符的能力边界Object.assign和对象展开{...a, ...b}在行为上是同一套逻辑遍历源对象的自有可枚举属性逐个赋给目标对象。这个逐个赋值的颗粒度就是第一层键它不会往下走。值类型浅合并的行为是否符合配置合并的预期字符串/数字/布尔直接替换符合嵌套普通对象整体替换丢失未提及的子字段不符合数组整体替换引用视场景而定多数情况下需要额外策略null直接替换覆盖掉默认值需要明确定义语义undefined也会覆盖Object.assign会赋值undefined通常不符合容易造成空值穿透函数整体替换引用一般符合Date/RegExp/ 类实例整体替换引用多数情况下符合注意表格里undefined那一行这是浅合并里另一个高频坑。很多配置系统允许用户传{ title: undefined }本意是这个字段我不设但Object.assign会把undefined老老实实赋上去把默认的title冲掉。深合并通常会把值为undefined视为未提供作为默认契约这一点后面单独讲。还有一点容易忽略Object.assign只拷贝可枚举的自有属性Symbol键在白皮书里是支持的Object.assign会处理 Symbol 键但展开运算符同样支持。而for...in会把原型链上的属性也带进来JSON.parse(JSON.stringify())会把函数、undefined、Date全部搞坏。这些都不是深合并该用的路数。1.3 深合并要解决的真实问题清单把需求收敛一下一个靠谱的deepMerge至少要满足这几条递归下钻两边都是普通对象时逐键合并而不是整体替换。右侧优先同一路径上用户配置优先于默认配置这是所有配置合并的默认语义。不污染输入默认配置对象必须保持原样否则下一次调用就拿到了被改过的默认值bug 会跨实例传染。非普通对象整体替换Date、RegExp、类实例、Map、Set这类东西没有部分覆盖的概念直接替换才是对的。可控的数组策略数组是覆盖、拼接还是按索引合并必须由调用方决定。安全边界不能让用户传进来的键名污染原型链。这六条里前三条是能不能用的门槛后三条是敢不敢上生产的门槛。2. 手写 deepMerge 的骨架先定类型判定再谈递归2.1 isPlainObject 为什么不能用 typeof写深合并的第一行代码几乎所有人都会写错错在类型判断上。最常见的写法是if (typeof target object typeof source object) { // 递归 }这段代码有三个致命问题。第一typeof null object遇到null直接进递归分支然后炸掉。第二数组也会进递归分支两个数组递归合并出来的东西是个带数字键的普通对象Array.isArray返回false后续所有数组操作全废。第三Date、RegExp、Map、Set、类实例都会进递归分支Date实例递归合并后变成一个空对象——因为它的内部槽位不是自有属性。正确的判定应该盯住原型是不是Object.prototypefunction isPlainObject(val) { if (val null || typeof val ! object) return false; const proto Object.getPrototypeOf(val); return proto Object.prototype || proto null; }第二行的proto null不能省。用Object.create(null)创建的对象没有原型在做配置字典、缓存表的时候很常见它完全应该被当作普通对象参与递归合并。还有一种更严格的写法会额外检查constructor但那会误伤跨执行环境比如iframe传过来的对象——它们的Object.prototype不是同一个引用。如果你的配置可能来自跨窗口通信或者被structuredClone处理过就别加这层检查toString判定的老写法反而更稳const tag Object.prototype.toString.call(val); return tag [object Object];这个写法能正确处理null、数组、Date也能跨执行环境。代价是它会把某些自定义了Symbol.toStringTag的类实例误判成普通对象所以我更倾向getPrototypeOf方案然后在文档里明确说只有原型是Object.prototype或null的对象才会被递归。2.2 递归分支的四种走向类型判完之后递归的逻辑其实是四选一把它摊开写反而不容易出错左值默认右值用户处理方式普通对象普通对象递归合并普通对象非对象值右值替换非对象值普通对象右值替换多数场景或视为配置错误非对象值非对象值右值替换第三行值得单独说一下。默认值是null用户传了个{ a: 1 }该怎么办有两种合理选择一是直接替换成用户的对象二是把默认值当作类型声明报错。做通用工具库的话选第一种更宽容做内部配置系统的话我倾向选第二种——类型不匹配往往是调用方写错了字段名早点报出来比后面渲染出错强。2.3 三十行核心实现与逐行说明下面是能直接用的最小可用版本我把它拆成三段看function deepMerge(target, source) { // 1. 任一侧不是普通对象走替换语义 if (!isPlainObject(target) || !isPlainObject(source)) { return source undefined ? target : source; } // 2. 以 target 为基底创建新对象保证不修改入参 const result Object.create(Object.getPrototypeOf(target)); for (const key of Reflect.ownKeys(target)) { result[key] target[key]; } // 3. 逐键合并 source for (const key of Reflect.ownKeys(source)) { const sVal source[key]; const tVal result[key]; if (sVal undefined) continue; // undefined 视为未提供 if (isPlainObject(tVal) isPlainObject(sVal)) { result[key] deepMerge(tVal, sVal); // 双对象递归 } else { result[key] sVal; // 其余情况右侧优先 } } return result; }几个细节值得多说两句。第一段里source undefined ? target : source这个三元判断是为了让用户传undefined保持默认值。如果直接return sourceundefined会顺着返回值一路上抛把整个对象变成undefined。第二段用了Reflect.ownKeys而不是Object.keys原因是Object.keys只返回字符串形式的可枚举自有属性Symbol键会漏掉。有些配置系统用Symbol做内部标记比如注入令牌漏掉之后合并出来的对象就缺了这些标记排查起来极其费劲。Reflect.ownKeys包含 Symbol 键和不可枚举键覆盖面最全。不过用Reflect.ownKeys有个副作用不可枚举属性也会被复制过来而result[key] ...赋值出来的属性总是可枚举的。如果原始配置里有不可枚举属性需要保持不可枚举就得换成Object.getOwnPropertyDescriptors配合Object.defineProperties。绝大多数配置场景不需要这么讲究但如果你在合并的是某个类库返回的复杂对象就得留意这个差异。第三段的sVal undefined跳过逻辑是整个实现里契约感最强的一行。它决定了{ title: undefined }是我要把 title 设成 undefined还是我没提 title。我选后者的理由是JSON 序列化会把undefined丢掉跨进程传输后语义会变而null已经能表达显式置空了没必要让undefined再承担一次。这个约定一定要写进注释或者文档不然半年后接手的人会以为这是个 bug。3. 数组、null 与不可变三个最容易翻车的语义决策3.1 数组到底该覆盖还是拼接数组是深合并里争议最大的部分因为它没有唯一正确答案完全取决于业务语义。const defaults { plugins: [a, b] }; const user { plugins: [c] };用户的意图可能是我要一套全新的插件列表只有c覆盖也可能是在默认插件基础上我再加一个c拼接还可能是我要把第 0 个插件换成c按索引合并。三种意图对应三种实现选错了就是线上事故。我一般的处理方式是默认覆盖同时把策略做成可配置参数。理由是覆盖语义最符合直觉、最不容易产生越删越多的幽灵配置而且用户在配置里写数组的时候心里想的通常就是这就是我要的完整列表。拼接和按索引合并作为可选策略放开谁需要谁显式打开。按索引合并的实现要小心长度不一致的情况function mergeByIndex(target, source, mergeFn) { const len Math.max(target.length, source.length); const out new Array(len); for (let i 0; i len; i) { if (i source.length) { out[i] target[i]; // 用户没给的保留默认 } else if (i target.length) { out[i] source[i]; // 用户多出来的直接追加 } else { out[i] mergeFn(target[i], source[i]); } } return out; }这里mergeFn传的就是deepMerge本身。按索引合并特别适合那些数组元素是对象、且数组位置有固定含义的配置比如grid的行列定义、columns表格列的宽度设置。但如果是数组元素是列表项这种位置无意义的场景按索引合并在用户删掉中间一项时会产生错位非常危险。还有一个细节合并后的数组要不要保持原数组的原型如果原来是个普通数组当然没问题但如果原始数组是Array的子类实例有些状态管理库会这么干new Array(len)就丢了子类。要严谨的话用target.constructor.from或者直接target.slice(0, len)起手情况会好一些。3.2 null 与 undefined 的差别必须写进契约在配置系统里null和undefined承担的是两个不同的职责undefined 没提供用默认值。null 显式清空把默认值也去掉。按这个契约deepMerge(defaults, { panel: null })的结果应该是panel: null而不是panel: defaults.panel。上面的实现里null会走到else分支被直接赋值语义是对的。但有个例外必须特殊处理当默认值是普通对象、用户传null时。有些框架用null表示这个配置块我用内置逻辑处理这时候把null赋进去可能导致下游读不到任何字段而报错。我的做法是在合并函数里加一个nullAsDelete选项if (sVal null opts.nullAsDelete) { delete result[key]; continue; }这个选项默认关闭需要的人在初始化合并器的时候显式打开。开与不开都要写文档不能让调用方猜。顺便说一句delete result[key]在某些引擎上会让对象退化成字典模式如果配置对象会被频繁读取比如每帧渲染都读能不用delete就别用改成赋undefined然后让下游统一处理。这是个很微妙的性能点配置项几十个以内其实无所谓但如果是成千上万个数据点级别的对象就值得注意。3.3 别让默认配置被顺手改掉我在真实项目里见过最隐蔽的一类 bug某个模块在合并之后又对结果做了后处理比如往merged.series里push了个默认系列结果因为merged.series和defaultOptions.series是同一个数组引用默认配置被永久改脏了。第二次调用的时候默认配置里已经存在那个系列于是出现了重复系列。解决方案有两个层次。第一层是合并函数本身不修改入参这一点上面的实现已经做到了——它用Object.create新建了对象所有递归都返回新对象。但数组的替换如果是直接赋值result[key] sVal那么结果和源数组共享引用。如果下游会改这个数组就得在合并时浅拷贝一份if (Array.isArray(sVal)) { result[key] sVal.slice(); }第二层是给默认配置加冻结const defaultOptions Object.freeze({ theme: Object.freeze({ color: #2b6cb0, font: Object.freeze({ size: 12 }) }) });注意Object.freeze是浅冻结嵌套对象要逐层冻结或者写个递归冻结工具。冻结之后任何试图修改默认配置的代码在严格模式下会直接抛错问题会立刻暴露在开发阶段而不是几个月后的线上。代价是冻结后的对象在 V8 里性能会略差一点点但对配置对象这种读多写少、体量很小的东西完全可以忽略。这里有个坑冻结要在模块加载时做一次而不是每次合并时做。有人会在deepMerge内部对结果调Object.freeze结果下游想给合并结果打个补丁就报错了。冻结默认值不冻结结果。4. 原型污染与循环引用安全与健壮性的两道闸4.1proto这条隐蔽的注入通道如果合并的源对象来自外部输入接口返回、URL 参数解析、用户提交的 JSON那么deepMerge就可能变成原型污染的入口。构造这样一份数据const malicious JSON.parse({__proto__: {polluted: true}}); deepMerge({}, malicious);如果实现里用的是朴素的result[key] value并且对__proto__这个键不设防赋值操作会走原型链的 setter把polluted挂到Object.prototype上之后整个运行时里所有对象都能读到这个属性。这个后果比配置丢失严重得多。防的方式有三道第一道合并时过滤危险键名const UNSAFE_KEYS new Set([__proto__, constructor, prototype]); function safeAssign(obj, key, value) { if (UNSAFE_KEYS.has(key)) return; Object.defineProperty(obj, key, { value, writable: true, enumerable: true, configurable: true }); }用Object.defineProperty而不是直接赋值是因为defineProperty走的是定义自有属性的路径不会触发原型链上的 setter。即便有人绕过了键名过滤也不会污染原型。第二道结果对象用Object.create(null)创建。没有原型自然没有__proto__这个访问器也就无从污染。但这个方案的副作用是合并结果没有hasOwnProperty、toString这些方法如果下游代码对结果调用obj.hasOwnProperty(...)就会报错。所以它适合用在结果只被当作纯数据读取的场景不适合用在要往外暴露成公共 API 的场景。第三道在入口处限制源头。如果配置来自 JSON直接用JSON.parse而不是eval如果来自 URL 参数先用白名单校验一遍字段名。深合并的防御是最后一道保险不是第一道。我个人的组合是内部配置合并用Object.create(Object.getPrototypeOf(target))保留原型以提高兼容性同时加上危险键名过滤和defineProperty赋值。三道闸里至少上两道别只靠一道。4.2 WeakMap 处理循环引用正常情况下配置对象不会有循环引用但只要你的deepMerge被拿去合并任意对象比如有人拿它做状态树的浅比较合并循环引用就一定会出现表现是栈溢出。处理办法是拿一张WeakMap记录源对象 → 合并结果的映射function deepMerge(target, source, seen new WeakMap()) { if (!isPlainObject(target) || !isPlainObject(source)) { return source undefined ? target : source; } const cached seen.get(source); if (cached) return cached; const result Object.create(Object.getPrototypeOf(target)); seen.set(source, result); for (const key of Reflect.ownKeys(target)) { if (!(key in source) || source[key] undefined) { result[key] target[key]; } } for (const key of Reflect.ownKeys(source)) { const sVal source[key]; if (sVal undefined) continue; const tVal target[key]; if (isPlainObject(tVal) isPlainObject(sVal)) { result[key] deepMerge(tVal, sVal, seen); } else { result[key] sVal; } } return result; }用WeakMap而不是Map的理由很实际WeakMap的键是弱引用合并结束后这些临时对象能被正常回收不会因为一次合并就在内存里留下一堆永远不释放的引用。虽然一次合并产生的对象不多但如果这是在热路径上比如每次请求都合并一次差别就很明显了。这个缓存有一个必须说明的局限它只按源对象缓存不区分目标对象。如果同一个源对象被合并进两个不同的目标对象第二次会直接拿到第一次的结果导致默认值丢失。在单次合并调用里这没问题因为每个源对象只会被访问一次但如果有人把这个seen提到函数外部做跨调用缓存就会出问题。所以seen必须是每次调用新建的默认参数不能提到外面。4.3 冻结配置与冻结时机前面提到过冻结默认配置这里补充一下冻结和深合并的配合关系。deepMerge在遍历target时会执行result[key] target[key]这个操作只读不写所以目标对象被冻结完全不影响。但如果实现里图省事直接改target很多手写版本就是这么写的遇到冻结对象在严格模式下会直接抛TypeError。这其实是好事——它能把合并函数偷偷修改入参这个设计缺陷当场暴露出来。有一个场景要注意Object.freeze之后的对象Object.isFrozen返回true但它的嵌套对象如果不冻结依然可以改。所以冻结工具要递归function deepFreeze(obj) { if (!isPlainObject(obj)) return obj; for (const key of Reflect.ownKeys(obj)) { deepFreeze(obj[key]); } return Object.freeze(obj); }这个函数对循环引用会死循环如果有可能遇到循环引用加个WeakSet记录已访问对象就行。5. 把 deepMerge 接进真实配置系统策略化与性能5.1 合并策略参数化一个工具函数写死所有策略用起来一定别扭。我习惯把它包成一个带选项的工厂function createMerger(options {}) { const { arrayStrategy replace, nullAsDelete false, skipUnsafeKeys true } options; function merge(target, source) { // ... } return merge; }数组策略做成枚举用一张表把策略名映射到具体实现const arrayHandlers { replace: (t, s) s.slice(), concat: (t, s) t.concat(s), union: (t, s) Array.from(new Set([...t, ...s])), byIndex: (t, s) mergeByIndex(t, s, merge) };这里union用的是Set注意它对对象元素的去重是按引用比的两个内容相同但引用不同的对象不会被认为重复。如果你的数组元素是对象union的效果可能不符合预期得自己写基于某个键的去重逻辑。这个坑我在做插件列表合并的时候踩过用户重复传了同一个插件配置Set判不出来最后加载了两次。把策略收拢到一个对象里还有个好处策略名可以暴露给调用方。有些配置系统允许用户在配置里声明mergeStrategy: concat这时候合并器读到这个字段就切换策略灵活性会高很多。不过要小心递归读取策略字段本身会不会造成互相依赖简单的做法是只允许顶层声明策略不允许嵌套每个层级单独指定。5.2 合并次数与性能取舍深合并的开销在于递归遍历配置对象通常不大几十到几百个键单次合并基本是微秒级。真正的问题在于合并次数。典型反例是在渲染函数里合并function render(userOptions) { const options deepMerge(defaultOptions, userOptions); // 每次渲染都合并 // ... }如果render每秒被调用几十次而defaultOptions和userOptions都没变这些合并全是浪费。更糟的是每次都产生新对象下游的 memo 或 diff 判断会因为引用变化而全部失效引发连锁重渲染。处理办法是按引用缓存let lastUser null; let lastResult null; function getOptions(userOptions) { if (userOptions lastUser) return lastResult; lastUser userOptions; lastResult deepMerge(defaultOptions, userOptions); return lastResult; }这种单槽缓存对配置只会在初始化时变一次的场景已经够用。如果配置可能有多套并存就换成WeakMap以userOptions为键缓存结果对象被回收时缓存自动释放。另一个性能点是合并顺序。如果有多层配置默认 → 主题 → 用户不要写成deepMerge(deepMerge(defaults, theme), user)这种嵌套调用虽然语义对但会产生中间对象。更好的做法是让deepMerge支持多个源function mergeAll(...sources) { return sources.reduce((acc, src) deepMerge(acc, src), {}); }这样语义清晰调用点也干净。至于中间对象的开销说实话在实际项目里完全不是瓶颈我提这一点主要是为了代码可读性——三层嵌套的deepMerge调用看起来实在难受。5.3 一套能复用的测试用例清单深合并这种边界比主干长的工具测试用例比实现代码更值得投入。我通常按这个清单铺用例输入期望嵌套对象合并{a:{b:1,c:2}}{a:{b:9}}{a:{b:9,c:2}}数组默认覆盖{l:[1,2]}{l:[3]}{l:[3]}数组拼接策略同上开启 concat{l:[1,2,3]}undefined不覆盖{a:1}{a:undefined}{a:1}null覆盖{a:1}{a:null}{a:null}null删除策略同上开启 nullAsDelete{}日期替换{d:new Date(0)}{d:new Date(1)}引用等于第二个日期不修改入参任意合并后两个入参深比较不变原型污染防护{__proto__:{x:1}}合并结果的Object.prototype未被污染循环引用自引用对象不抛栈溢出Symbol 键带Symbol键的对象Symbol 键被保留不修改入参这条要用深比较验证不能只比引用。写断言的时候我一般会把默认配置在合并前JSON.parse(JSON.stringify())存一份快照合并后再比一次。原型污染那条的验证方式有点讲究不能直接读({}).x因为如果你的运行环境已经被别的代码污染了这个断言会误报。正确做法是在合并后读Object.getPrototypeOf({})检查或者干脆在测试开始前断言一次基线。最后分享一个小技巧这套实现我一般会单独放在一个merge.js里导出deepMerge、createMerger、isPlainObject三个东西。isPlainObject单独导出很关键——项目里到处都有人需要判断这是不是个纯对象如果每个人各写一份判定标准迟早会分叉到时候合并行为在不同模块间不一致排查成本会翻好几倍。统一导出让所有人都用同一个判定这是我在几个项目里吃过亏之后固定下来的习惯。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑