深入 Defu 的递归合并:算法、安全与可扩展性
前置知识
- ›第 1 篇:Defu 的架构与 API 设计
- ›JavaScript 对象基础:Object.assign、原型链、Object.getPrototypeOf
- ›递归算法的基本概念
- ›对原型污染安全风险的基本了解
深入 Defu 的递归合并:算法、安全与可扩展性
在第 1 篇中,我们梳理了 defu 的整体架构——工厂模式、五个公开导出,以及双格式(CJS/ESM)的发布策略。本篇将带你走进核心引擎。_defu 函数只有大约 40 行代码,但每一行都蕴含着对"对象该如何合并"这一问题的深思熟虑。它涵盖了安全性(原型污染防护)、语义处理(nullish 跳过、数组拼接)、可扩展性(merger 钩子),以及正确性(isPlainObject 守卫)。让我们逐条分析每一个分支。
_defu 函数逐行解析
完整算法如下:
从函数签名可以看出整体结构:接收一个 baseObject(高优先级来源)、defaults(低优先级默认值)、一个可选的 namespace 字符串用于上下文感知合并,以及一个可选的 merger 回调用于自定义逻辑。
算法对 baseObject 中的每个键,按照以下五路决策树依次处理:
flowchart TD
Start["for key in baseObject"] --> Security{"key === '__proto__'<br/>or 'constructor'?"}
Security -->|Yes| Skip1[continue — skip key]
Security -->|No| Nullish{"value === null<br/>or undefined?"}
Nullish -->|Yes| Skip2["continue — keep default"]
Nullish -->|No| Merger{"merger callback<br/>returns true?"}
Merger -->|Yes| Skip3["continue — merger handled it"]
Merger -->|No| ArrayCheck{"Both arrays?"}
ArrayCheck -->|Yes| Concat["Concatenate:<br/>[...value, ...default]"]
ArrayCheck -->|No| ObjectCheck{"Both plain objects?"}
ObjectCheck -->|Yes| Recurse["_defu(value, default,<br/>namespace, merger)"]
ObjectCheck -->|No| Override["object[key] = value"]
这些检查的顺序至关重要。安全检查排在最前面——对于危险键,我们甚至不会去读取它的值。其次是 nullish 检查——来源值为 null 或 undefined 意味着"我没有意见,用默认值就好"。第三步才是 merger 钩子——在内置逻辑介入之前,它优先处理所有非 nullish 的值。只有通过前三关,才会进入标准的数组/对象/原始值分发逻辑。
克隆语义:为什么要用 Object.assign({}, defaults)
第 15 行很容易被忽略,但它至关重要:
const object = Object.assign({}, defaults);
这行代码以 defaults 的浅拷贝作为输出对象的起点,然后遍历 baseObject 的键并逐一覆盖这个克隆对象。这个设计带来两个重要特性:
-
两个输入对象都不会被修改。 输出始终是一个全新的对象。如果
baseObject中某个嵌套对象与默认值发生了深层合并,递归过程同样会在该层级生成一个新对象。 -
默认值优先,来源值覆盖。 以 defaults 的克隆为起点,再用来源值逐步覆盖——这样一来,只存在于 defaults 中的键会自动保留。
for...in循环只遍历baseObject自身及继承的可枚举键,不会删除任何默认值。
这与 Object.assign(target, source) 的思维模型恰好相反。在 Object.assign 中,第二个参数会覆盖第一个;而在 _defu 中,第一个参数(baseObject/来源)拥有更高优先级,但它是在第二个参数(defaults)的副本上进行修补。可以这样理解:先把所有默认值铺好,再精准替换掉用户明确提供的那些键。
原型污染防护
第 18–20 行是安全守卫:
if (key === "__proto__" || key === "constructor") {
continue;
}
原型污染是一类攻击手段——恶意输入(如 {"__proto__": {"isAdmin": true}})在被合并到对象时,会修改 Object.prototype,进而影响运行时中的所有对象。深层合并工具是这类攻击的经典目标,因为它们会递归地赋值属性,包括那些危险的属性名。
Defu 的防御策略简单直接:静默跳过这些键。测试套件中有明确验证:
it("should not override Object prototype", () => {
const payload = JSON.parse(
'{"constructor": {"prototype": {"isAdmin": true}}}',
);
defu({}, payload);
defu(payload, {});
defu(payload, payload);
expect({}.isAdmin).toBe(undefined);
});
测试覆盖了三种参数位置——恶意 payload 分别作为来源、默认值,以及同时作为两者。无论哪种情况,{}.isAdmin 都必须保持 undefined。
提示: 编写任何深层合并工具时,务必防范
__proto__和constructor这两个键,这是抵御原型污染的最低防线。像defu这样的库会明确处理这一点,而很多自行实现的方案往往会遗漏。
isPlainObject 守卫
isPlainObject 函数是决定某个值应当被递归深层合并、还是作为不透明叶节点处理的关键判断器。没有它,defu 会毫不犹豫地递归进入 Date、RegExp、class 实例、Map、Set——而这些对象的内部属性一旦被展开到普通对象中,就会产生错误的结果。
该函数依次执行四项检查:
flowchart TD
A["isPlainObject(value)"] --> B{"value === null or<br/>typeof !== 'object'?"}
B -->|Yes| R1[return false]
B -->|No| C{"prototype !== null<br/>AND !== Object.prototype<br/>AND grandparent !== null?"}
C -->|Yes| R2["return false<br/>(class instance, Error, etc.)"]
C -->|No| D{"Symbol.iterator<br/>in value?"}
D -->|Yes| R3["return false<br/>(Array, Set, Map, etc.)"]
D -->|No| E{"Symbol.toStringTag<br/>in value?"}
E -->|Yes| F{"toString === '[object Module]'?"}
F -->|Yes| R4["return true<br/>(ES Module namespace)"]
F -->|No| R5["return false<br/>(Math, Promise, etc.)"]
E -->|No| R6[return true]
检查一:null 及非对象。 原始值和 null 永远不是普通对象。
检查二:原型链。 普通对象的直接原型要么是 Object.prototype(通过 {} 或 new Object() 创建),要么是 null(通过 Object.create(null) 创建)。其他任何情况——class 实例、Error、Date、RegExp——都拥有更长的原型链。其中,Object.getPrototypeOf(prototype) !== null 这个条件专门用于识别 Object.create(null) 创建的对象(其原型为 null,没有祖先原型)。
检查三:Symbol.iterator。 这一步过滤掉所有可迭代对象:数组、Set、Map、TypedArray、arguments 对象等,它们的原型链上都带有 Symbol.iterator。
检查四:Symbol.toStringTag。 许多内置对象带有 Symbol.toStringTag(如 Math 对应 [object Math],Promise 对应 [object Promise])。函数会拒绝所有这类对象,唯一的例外是 ES Module 命名空间对象,其标签为 [object Module]。这个例外至关重要——当你使用 import * as config from './defaults' 时,得到的命名空间对象应当是可合并的。
测试套件对这些边界情况进行了详尽覆盖:
值得注意的是,{ [Symbol.toStringTag]: true } 返回 false(第 46 行),{ [Symbol.iterator]: true } 同样返回 false(第 47 行)——即便是普通对象,只要自身拥有这些 Symbol 属性,也会被拒绝。这是一种保守策略:避免将外观像可迭代对象或标记对象的值错误地进行深层合并。
Merger 策略模式
如第 1 篇所述,createDefu 接受一个可选的 merger 回调。在 _defu 内部,该回调在第 28 行被调用:
if (merger && merger(object, key, value, namespace)) {
continue;
}
约定很简单:merger 接收当前输出对象、正在处理的键、来源值,以及用点号分隔的命名空间字符串。如果返回真值,_defu 就跳过该键的内置逻辑;如果返回假值(或 undefined),则继续走标准的数组/对象/原始值分发流程。
这是策略模式最精简的体现。来看 defuFn 和 defuArrayFn 的实现:
// defuFn: if the source value is a function, call it with the default
export const defuFn = createDefu((object, key, currentValue) => {
if (object[key] !== undefined && typeof currentValue === "function") {
object[key] = currentValue(object[key]);
return true;
}
});
// defuArrayFn: same, but only when the default is an array
export const defuArrayFn = createDefu((object, key, currentValue) => {
if (Array.isArray(object[key]) && typeof currentValue === "function") {
object[key] = currentValue(object[key]);
return true;
}
});
两者的区别虽然细微,却很重要。defuFn 只要来源提供的是函数且默认值存在,就会调用该函数;defuArrayFn 更为严格——只有当默认值是数组时才会调用。在两种情况下,不满足条件的函数都会继续走标准合并逻辑(此时函数被当作普通值处理)。
namespace 参数让合并逻辑具备上下文感知能力。来看这个测试:
const ext = createDefu((obj, key, val, namespace) => {
if (key === "modules") {
obj[key] = namespace + ":" + [...val, ...obj[key]].sort().join(",");
return true;
}
});
const obj1 = { modules: ["A"], foo: { bar: { modules: ["X"] } } };
const obj2 = { modules: ["B"], foo: { bar: { modules: ["Y"] } } };
expect(ext(obj1, obj2)).toEqual({
modules: ":A,B",
foo: { bar: { modules: "foo.bar:X,Y" } },
});
顶层的 namespace 为 "",所以结果是 ":A,B";在嵌套的 foo.bar 层,namespace 为 "foo.bar",结果为 "foo.bar:X,Y"。这样就可以编写出根据对象树层级表现不同行为的 merger——在配置系统中尤为实用,因为同一个键名在不同嵌套层级往往有不同的含义。
多参数折叠与 reduce
如第 1 篇所述,createDefu 使用 Array.reduce 来折叠多个参数:
return (...arguments_) =>
arguments_.reduce((p, c) => _defu(p, c, "", merger), {} as any);
下面用时序图展示 defu({ a: 1 }, { b: 2, a: 'x' }, { c: 3, a: 'x', b: 'x' }) 的处理过程:
sequenceDiagram
participant R as reduce accumulator
participant S as {a: 1}
participant D1 as {b: 2, a: 'x'}
participant D2 as {c: 3, a: 'x', b: 'x'}
R->>S: _defu({}, {a:1}) → {a: 1}
Note right of S: Start with empty, apply source
S->>D1: _defu({a:1}, {b:2, a:'x'}) → {a:1, b:2}
Note right of D1: a:1 wins over a:'x', b:2 fills gap
D1->>D2: _defu({a:1,b:2}, {c:3,a:'x',b:'x'}) → {a:1,b:2,c:3}
Note right of D2: a and b already set, c:3 fills gap
初始累加器 {} 很关键——第一次 _defu 调用以 {a: 1} 为 defaults 创建克隆,然后将 {} 作为来源(没有任何键)应用,结果实际上等同于 {a: 1} 的克隆。之后每次调用都把已积累的结果与下一个 defaults 对象合并。由于 _defu 始终从 Object.assign({}, defaults) 开始,已积累的高优先级值总能胜出。
test/defu.test.ts#L92-L104 中的测试同时验证了运行时结果和推断出的类型:
const result = defu({ a: 1 }, { b: 2, a: "x" }, { c: 3, a: "x", b: "x" });
expect(result).toEqual({ a: 1, b: 2, c: 3 });
expectTypeOf(result).toMatchTypeOf<{
a: string | number;
b: string | number;
c: number;
}>();
注意:a 的运行时值是 1(数字),但类型是 string | number。类型系统无法知道哪条运行时路径会胜出——它只知道 a 可能是这两种类型之一。第 3 篇将深入探讨这一类型层面的行为。
ES Module 命名空间与自定义 Merger 测试
一个特别有趣的边界情况是合并 ES Module 命名空间对象(即 import * as foo from '...' 的结果)。这类对象的原型为 null,且 Symbol.toStringTag 被设置为 "Module"。如果没有 isPlainObject 中的特殊例外处理,defu 会拒绝对它们进行深层合并。
测试 fixture 很简洁:
test/fixtures/index.ts#L1 从 test/fixtures/nested.ts 重导出一个默认值:
// index.ts
export { default as exp } from "./nested.js";
// nested.ts
export default { nested: 1 };
测试导入整个命名空间并进行合并:
import * as asteriskImport from "./fixtures/";
it("works with asterisk-import", () => {
expect(
defu(asteriskImport, { a: 2, exp: { anotherNested: 2 } }),
).toStrictEqual({
a: 2,
exp: { anotherNested: 2, nested: 1 },
});
});
asteriskImport 是一个 ES Module 命名空间——由于 [object Module] 的特殊例外,isPlainObject 对它返回 true。其中的 exp 属性是普通对象 { nested: 1 },会与 { anotherNested: 2 } 进行深层合并。如果没有 Symbol.toStringTag 的相关处理,命名空间对象就会被当作叶节点,exp 的深层合并也就永远不会发生。
提示: 如果你正在构建使用
import()或import *的配置加载器,请确保你的合并工具能正确处理 ES Module 命名空间。Defu 支持这一点,许多同类工具并不支持。
下一步
至此,我们已经梳理了 _defu 中每一个运行时决策——从顶部的安全守卫,到底部的数组/对象/原始值分发逻辑,整个运行时流程已经完全清晰。但还有一部分我们尚未涉及:src/types.ts 中约 112 行的类型系统,它在类型层面镜像了每一个运行时决策,以递归条件类型的形式实现。第 3 篇将深入剖析这套类型层面的深层合并机制——Defu<S, D> 如何处理变长元组参数、MergeObjects 如何执行感知 nullish 的键合并,以及 Merge 如何在纯类型层面走过与我们刚才追踪的完全相同的决策树。