Read OSS

深入 Defu 的递归合并:算法、安全与可扩展性

中级

前置知识

  • 第 1 篇:Defu 的架构与 API 设计
  • JavaScript 对象基础:Object.assign、原型链、Object.getPrototypeOf
  • 递归算法的基本概念
  • 对原型污染安全风险的基本了解

深入 Defu 的递归合并:算法、安全与可扩展性

在第 1 篇中,我们梳理了 defu 的整体架构——工厂模式、五个公开导出,以及双格式(CJS/ESM)的发布策略。本篇将带你走进核心引擎。_defu 函数只有大约 40 行代码,但每一行都蕴含着对"对象该如何合并"这一问题的深思熟虑。它涵盖了安全性(原型污染防护)、语义处理(nullish 跳过、数组拼接)、可扩展性(merger 钩子),以及正确性(isPlainObject 守卫)。让我们逐条分析每一个分支。

_defu 函数逐行解析

完整算法如下:

src/defu.ts#L5-L47

从函数签名可以看出整体结构:接收一个 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 检查——来源值为 nullundefined 意味着"我没有意见,用默认值就好"。第三步才是 merger 钩子——在内置逻辑介入之前,它优先处理所有非 nullish 的值。只有通过前三关,才会进入标准的数组/对象/原始值分发逻辑。

克隆语义:为什么要用 Object.assign({}, defaults)

第 15 行很容易被忽略,但它至关重要:

const object = Object.assign({}, defaults);

这行代码以 defaults 的浅拷贝作为输出对象的起点,然后遍历 baseObject 的键并逐一覆盖这个克隆对象。这个设计带来两个重要特性:

  1. 两个输入对象都不会被修改。 输出始终是一个全新的对象。如果 baseObject 中某个嵌套对象与默认值发生了深层合并,递归过程同样会在该层级生成一个新对象。

  2. 默认值优先,来源值覆盖。 以 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 的防御策略简单直接:静默跳过这些键。测试套件中有明确验证:

test/defu.test.ts#L106-L115

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——而这些对象的内部属性一旦被展开到普通对象中,就会产生错误的结果。

src/_utils.ts#L1-L26

该函数依次执行四项检查:

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 实例、ErrorDateRegExp——都拥有更长的原型链。其中,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' 时,得到的命名空间对象应当是可合并的。

测试套件对这些边界情况进行了详尽覆盖:

test/utils.test.ts#L1-L49

值得注意的是,{ [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),则继续走标准的数组/对象/原始值分发流程。

这是策略模式最精简的体现。来看 defuFndefuArrayFn 的实现:

src/defu.ts#L61-L74

// 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 参数让合并逻辑具备上下文感知能力。来看这个测试:

test/defu.test.ts#L218-L235

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 来折叠多个参数:

src/defu.ts#L50-L54

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#L1test/fixtures/nested.ts 重导出一个默认值:

// index.ts
export { default as exp } from "./nested.js";

// nested.ts
export default { nested: 1 };

测试导入整个命名空间并进行合并:

test/defu.test.ts#L237-L252

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 如何在纯类型层面走过与我们刚才追踪的完全相同的决策树。