Read OSS

Defu:百行深度默认值库的架构与 API 设计

中级

前置知识

  • 掌握基础 JavaScript 及 Node.js 模块系统(ESM 与 CommonJS)
  • 熟悉 npm 包结构及 package.json 各字段含义
  • 了解对象合并与默认值填充的基本概念

Defu:百行深度默认值库的架构与 API 设计

每个框架都需要一种将用户配置与合理默认值合并的机制。你可能会想到 Object.assign 或展开语法——对于扁平对象,它们确实够用。但一旦配置出现嵌套结构(比如 Nuxt 的 nuxt.config.ts 中嵌套的 vitenitroapp 配置),浅合并就会悄无声息地丢掉你的嵌套覆盖项。defu 正是为解决这个问题而生:用不到 100 行 TypeScript 实现递归默认属性赋值,并借助类型系统精确推断合并结果的类型。

Defu 是什么,为何存在?

Defu 是 "defaults" 去掉 "alts" 后的缩写,是 UnJS 生态 的核心深度默认值工具库。它是 Nuxt、Nitro、c12、unenv 以及数十个其他包的直接依赖。它的职责看似简单:给定一个源对象和一个或多个默认值对象,递归地将源对象中缺失或为空的属性用默认值补全,返回一个新对象。

Object.assign 的关键区别如下:

// Object.assign:浅合并——嵌套默认值会丢失
Object.assign({ a: { b: 2 } }, { a: { b: 1, c: 3 } });
// => { a: { b: 2 } }  — 属性 `c` 消失了!

// defu:深度合并——嵌套默认值得以保留
defu({ a: { b: 2 } }, { a: { b: 1, c: 3 } });
// => { a: { b: 2, c: 3 } }

展开语法同样存在这个浅合并问题。Lodash 的 _.defaultsDeep 能解决这个问题,但引入了更大的依赖体积。defu 在压缩后不到 1 KB,提供相同的深度默认值合并语义,同时具备完整的 TypeScript 类型推断。

目录结构与文件职责

这个仓库小得出奇。以下是所有关键文件的完整结构:

路径 职责 行数
src/defu.ts 核心合并逻辑、工厂函数、所有公共导出 ~76
src/types.ts 类型层面的深度合并推断 ~111
src/_utils.ts isPlainObject 守卫函数(源自 sindresorhus) ~26
lib/defu.cjs 手写的 CJS 兼容包装层 ~11
test/defu.test.ts 运行时测试与类型层面测试 ~253
test/utils.test.ts isPlainObject 边界用例测试 ~49
test/fixtures/ ES Module 命名空间测试固定件 ~4

首先映入眼帘的是:类型系统比运行时代码更大src/types.ts 有 112 行,而 src/defu.ts 中实际的合并算法 _defu 函数只有约 47 行。这个比例清楚地反映了项目的优先级——类型正确性不是事后补充的,而是一等交付物。

graph LR
    subgraph "src/"
        defu["defu.ts<br/>(runtime + exports)"]
        types["types.ts<br/>(type-level merge)"]
        utils["_utils.ts<br/>(isPlainObject)"]
    end
    subgraph "lib/"
        cjs["defu.cjs<br/>(CJS wrapper)"]
    end
    subgraph "dist/ (built)"
        mjs["defu.mjs"]
        dcjs["defu.cjs"]
        dts["defu.d.ts"]
    end

    defu --> utils
    defu --> types
    cjs --> dcjs
    mjs -.-> dts

_utils.ts 文件名以下划线开头,这是一种约定,表示它是内部模块,不属于公共 API。它只导出一个函数 isPlainObject,用于判断某个值应当被深度合并,还是作为叶子节点直接保留。

公共 API 一览

defu 恰好导出五个内容。来看核心模块底部的完整导出列表:

src/defu.ts#L50-L76

导出项 类型 用途
defu DefuInstance 标准深度默认值合并,主导出项
createDefu (merger?) => DefuFn 用于创建自定义合并变体的工厂函数
defuFn DefuFn 变体:若源值为函数,则以默认值为参数调用该函数
defuArrayFn DefuFn 变体:与 defuFn 类似,但仅作用于数组类型的默认值
Defu 类型导出 供外部使用的类型层面合并工具类型

这几个导出项之间是层级关系:

flowchart TD
    createDefu["createDefu(merger?)"] --> defu["defu = createDefu()"]
    createDefu --> defuFn["defuFn = createDefu(fnMerger)"]
    createDefu --> defuArrayFn["defuArrayFn = createDefu(arrayFnMerger)"]
    createDefu --> custom["yourCustom = createDefu(yourMerger)"]

    style createDefu fill:#f0db4f,color:#000

createDefu 是唯一真正的构造器。defudefuFndefuArrayFn 都是通过传入不同(或不传)merger 回调来调用它所得到的实例。这是工厂模式的经典应用——一套创建机制,产出多种变体。

提示: 如果你需要自定义合并行为——比如对数字求和而不是直接覆盖——可以直接使用 createDefu。你将获得相同的递归深度合并基础设施,同时注入自己的逐键处理逻辑。

工厂模式:createDefu 与 reduce

工厂函数只有五行,却蕴含了两个重要的设计决策:

src/defu.ts#L50-L54

export function createDefu(merger?: Merger): DefuFunction {
  return (...arguments_) =>
    arguments_.reduce((p, c) => _defu(p, c, "", merger), {} as any);
}

决策一:通过 reduce 支持多参数。 调用 defu(a, b, c) 时,参数会从左到右依次折叠。初始累加器是空对象 {},处理顺序如下:

sequenceDiagram
    participant Acc as Accumulator ({})
    participant A as Arg 1 (source)
    participant B as Arg 2 (defaults)
    participant C as Arg 3 (more defaults)

    Acc->>A: _defu({}, a) → result₁
    Note over Acc,A: Source properties win
    A->>B: _defu(result₁, b) → result₂
    Note over A,B: a's values win over b's
    B->>C: _defu(result₂, c) → result₃
    Note over B,C: a's and b's values win over c's

这意味着最左边的参数优先级最高。第一个参数是你的源对象(用户配置),后续参数是按优先级递减排列的默认值。这符合直觉:「从空对象出发,先填入最具体的值,再用越来越通用的默认值补全空缺。」

决策二:通过闭包捕获 merger 回调。 merger 参数被捕获在 createDefu 返回的闭包中。每次调用返回的函数时,所有递归的 _defu 调用都会使用同一个 merger。这是策略模式的体现——合并算法本身是固定的,但每个键的处理逻辑是可插拔的。

CJS/ESM 双格式发布策略

defu 同时支持两种模块系统。package.json 的 exports 映射告诉 Node.js 应该加载哪个文件:

package.json#L7-L13

"exports": {
  ".": {
    "types": "./dist/defu.d.ts",
    "import": "./dist/defu.mjs",
    "require": "./lib/defu.cjs"
  }
}

ESM 路径(./dist/defu.mjs)指向 unbuild 的构建产物,即 TypeScript 源码的直接编译结果。而 CJS 路径指向的是 lib/defu.cjs——这是一个手写的包装层,而非构建产物。该文件存放在 lib/ 目录下并被提交到版本控制。

lib/defu.cjs#L1-L11

const { defu, createDefu, defuFn, defuArrayFn } = require('../dist/defu.cjs');

module.exports = defu;

module.exports.defu = defu;
module.exports.default = defu;

module.exports.createDefu = createDefu;
module.exports.defuFn = defuFn;
module.exports.defuArrayFn = defuArrayFn;

为什么要手写这个文件?因为 CJS 存在一个特殊的使用体验问题:用户希望以下两种写法都能正常工作:

// 写法一:默认导入风格
const defu = require('defu');
defu({ a: 1 }, { b: 2 });

// 写法二:具名导入风格
const { defu } = require('defu');
defu({ a: 1 }, { b: 2 });

第 3 行(module.exports = defu)让写法一成立——模块的默认导出就是这个函数本身。第 5 行(module.exports.defu = defu)让写法二成立——该函数对象上还挂载了具名属性 defu。第 6 行(module.exports.default = defu)则是为了支持那些在模拟 ESM 默认导出时期望存在显式 .default 属性的打包工具。

这种三重赋值的写法在 Node.js 生态中是广为人知的模式,但自动化构建工具往往处理得不够准确。手写这个文件,确保了它能完全按预期工作。

提示: 如果你在发布 CJS/ESM 双格式包,而你的构建工具无法生成正确的 CJS 互操作代码,可以考虑手写一个类似的包装层。它只有寥寥数行,却能彻底消除「无法导入你的包」这一类问题。

构建、CI 与质量流水线

构建工具使用的是 unbuild——同属 UnJS 家族的另一个项目。它读取 package.jsonexports 映射,自动生成对应的输出格式。build 脚本非常简洁:

"build": "unbuild"

无需任何配置文件。unbuild 会从 exports 映射中自动推断出需要生成 dist/defu.mjsdist/defu.cjsdist/defu.d.ts

.github/workflows/ci.yml 中的 CI 流水线依次执行六个步骤:

flowchart LR
    A[pnpm install] --> B[pnpm lint]
    B --> C[pnpm build]
    C --> D["pnpm test:types<br/>(tsc --noEmit)"]
    D --> E["pnpm vitest<br/>--coverage"]
    E --> F[codecov upload]

    style D fill:#f0db4f,color:#000

高亮显示的步骤——tsc --noEmit——是最值得关注的一环。它按照 tsconfig.json 的配置,对 src/test/ 运行完整的 TypeScript 编译器检查,但不生成任何输出文件。其目的是验证 src/types.ts 中的类型层面合并逻辑,能否正确推断出测试套件中 expectTypeOf 断言所期望的输出类型。

这是一种有意为之的关注点分离。Vitest 负责运行时测试——defu({a: 1}, {b: 2}) 返回的值是否正确?TypeScript 负责类型测试——该表达式的类型是否匹配 {a: number; b: number}?两者都必须通过,CI 才能放行。

TypeScript 配置本身严格而精简:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "test"]
}

strict: true 开启所有严格类型检查选项。skipLibCheck: true 跳过对 node_modules 中类型的重复检查——这是一个务实的选择,能加快类型检查速度,同时不影响项目自身代码的类型安全。

下一步

我们已经了解了整体架构骨架:三个源文件、工厂模式、双格式发布,以及一条将类型视为与运行时行为同等重要的 CI 流水线。在第二篇中,我们将深入那个 47 行的 _defu 函数,逐一梳理每个分支决策——从原型污染防护,到数组拼接处理,再到可插拔的 merger 钩子。那里才是合并算法真正运转的地方,其复杂程度远超它的代码行数所暗示的。