- 后端
- Web框架
【免费下载链接】egg
🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode
导读
@eggjs/extend2是 Egg 框架仓库(packages/extend2)中一个轻量级但至关重要的工具包:它是经典node-extend(jQuery.extend 的 Node.js 移植版)的衍生实现,唯一且核心的差异在于深拷贝时把数组当作基本类型直接覆盖,而不是按下标逐项合并。本篇文章将围绕这个差异展开,先讲透extend的两种调用形态与深拷贝语义,再结合 src/index.ts 的源码逐段拆解实现原理,并用 test/index.test.ts 的测试用例验证各边界行为,最后揭示它在 Egg 框架配置加载与 dump 中的真实应用。读完你将掌握:如何正确使用extend(true, target, ...sources)做深合并、数组覆盖语义的实战价值、以及它和Object.assign、structuredClone的本质区别。
一、包定位:从一个关键差异说起
@eggjs/extend2的官方 README(packages/extend2/README.md)开篇就一句话点明了它的来历与定位:
Forked from node-extend, the difference is overriding array as primitive when deep clone.
即:代码源自node-extend,但与上游的唯一区别是——深拷贝(deep clone)模式下,数组被当作基本类型处理,直接整体覆盖,而不会逐元素合并。
为什么要做这个改动?在 Egg 框架中,extend2大量用于配置文件合并(详见下文第六节)。配置合并的场景里,"后加载的配置应该整体替换掉前者的同名数组"通常是开发者更直觉的预期:比如plugin.js里配的ignore列表、中间件数组,如果逐下标合并,很容易出现"旧数组残留尾巴"的隐蔽 Bug。把数组当作基本类型覆盖,语义简单清晰,也更容易推理。
从包配置(packages/extend2/package.json)可以看到它的基本信息:
| 项目 | 内容 |
|---|---|
| 包名 | @eggjs/extend2 |
| 描述 | Port of jQuery.extend for Node.js |
| 关键词 | clone/extend/merge |
| 许可证 | MIT |
| 模块格式 | ESM("type": "module") |
| 导出 | 开发期exports["."]指向./src/index.ts,发布时指向./dist/index.js+./dist/index.d.ts |
| 运行环境 | Node.js >= 22.18.0 |
源码非常精简:整个包只有一个核心文件 src/index.ts,包含一个isPlainObject辅助函数和一个extend函数,同时提供命名导出export { extend }与默认导出export default extend。
二、基本用法:两种调用形态
README 给出了深拷贝合并的标准用法:
import { extend } from '@eggjs/extend2'; // for deep clone extend(true, {}, object1, objectN);extend支持两种调用形态(源码 src/index.ts 中通过参数类型自动识别):
// 形态一:显式开启深拷贝 extend(deep, target, obj1, obj2, ...) // 形态二:浅拷贝(省略 deep 布尔参数) extend(target, obj1, obj2, ...)关键行为:
- 返回值:返回合并后的
target对象本身(会原地修改target,并不是返回新对象); - 不传 target:
extend(undefined, { a: 1 })会等价于extend({}, { a: 1 }),自动以空对象作为目标; - 多源合并:支持
obj1, obj2, ..., objN任意多个来源对象,后面的覆盖前面的; - null/undefined 来源自动跳过:
options === null || options === undefined时直接continue(src/index.ts)。
三、深拷贝的核心差异:数组按基本类型覆盖
先看一个直观对比。假设有两个对象:
const defaults = { arr: [1, 2, 3] }; const override = { arr: ['x'] };- 使用普通深合并工具(如
jQuery.extend、node-extend的深拷贝),arr会按下标合并,结果往往是['x', 2, 3]; - 使用
@eggjs/extend2,结果是{ arr: ['x'] }——数组被当作"基本类型"整体覆盖。
这一行为被测试显式锁定在 test/index.test.ts:
it('deep clone; arrays are override', () => { const defaults = { arr: [1, 2, 3] }; const override = { arr: ['x'] }; const expectedTarget = { arr: ['x'] }; const target = extend(true, defaults, override); assert.deepEqual(target, expectedTarget, 'arrays are merged'); });测试断言target深等于{ arr: ['x'] },明确把"数组覆盖"而非"数组合并"写进了契约。这是本工具与所有"逐项合并数组"类深合并库最本质的分水岭。
四、源码逐段拆解:extend 的实现原理
下面结合 src/index.ts 完整源码逐段解读。
4.1 目标对象解析(L30-L45)
let target = deepOrTarget as any; let i = 0; const length = objects.length; let deep = false; // Handle a deep copy situation if (typeof target === 'boolean') { // extend(deep, target, obj1, obj2, ...) deep = target; target = objects[0] || {}; // skip the boolean and the target i = 1; } else if ((typeof target !== 'object' && typeof target !== 'function') || target == null) { // extend(null, obj1, obj2, ...) target = {}; }- 第一个参数是
boolean时,视为深拷贝开关,target顺延到第二个参数;target缺省时兜底为{}; - 第一个参数既不是对象也不是函数(或为
null),同样兜底为{}——这就是extend(null, { a: 1 })也能正常工作的原因(测试见 index.test.ts)。
4.2 浅拷贝与深拷贝的分支(L47-L73)
for (; i < length; ++i) { const options = objects[i] as any; // Only deal with non-null/undefined values if (options === null || options === undefined) continue; // Extend the base object for (const name in options) { if (name === '__proto__') continue; const src = target[name]; const copy = options[name]; // Prevent never-ending loop if (target === copy) continue; // Recurse if we're merging plain objects if (deep && copy && isPlainObject(copy)) { const clone = src && isPlainObject(src) ? src : {}; // Never move original objects, clone them target[name] = extend(deep, clone, copy); // Don't bring in undefined values } else if (typeof copy !== 'undefined') { target[name] = copy; } } }几个值得注意的实现细节:
__proto__过滤:遍历时显式跳过__proto__键(L54),这是针对原型污染攻击(Prototype Pollution)的安全加固。测试 index.test.ts 专门验证了这一点:extend(true, {}, JSON.parse('{"__proto__": {"polluted": "yes"}}'))不会污染Object.prototype,序列化结果仅为{}。- 自引用防死循环:
if (target === copy) continue;(L60)——当来源属性值就是目标对象本身时跳过,避免无限递归。 - 只对 plain object 递归:深拷贝分支要求
isPlainObject(copy)成立(L63),数组、Date、类实例等统统不会进入递归,这正是"数组按基本类型覆盖"的实现根源。 - 只合并 plain object:目标侧同样要求
src是 plain object 才原地合并,否则用{}作为新容器(L64),保证"Never move original objects, clone them"。 - 浅拷贝模式不复制
undefined:非深拷贝分支typeof copy !== 'undefined'才赋值(L69),即显式赋undefined的属性不会覆盖目标已有值。
4.3 isPlainObject:如何判定"纯对象"
function isPlainObject(obj: unknown) { if (!obj || toStr.call(obj) !== '[object Object]') { return false; } const hasOwnConstructor = hasOwn.call(obj, 'constructor'); const hasIsPrototypeOf = obj.constructor && obj.constructor.prototype && hasOwn.call(obj.constructor.prototype, 'isPrototypeOf'); // Not own constructor property must be Object if (obj.constructor && !hasOwnConstructor && !hasIsPrototypeOf) { return false; } let key: string | undefined; for (key in obj) { /* */ } return typeof key === 'undefined' || hasOwn.call(obj, key); }判定逻辑分三层:
- 先通过
Object.prototype.toString判断[object Object],排除数组、Date、RegExp等(这些返回[object Array]、[object Date]等); - 再检查
constructor与isPrototypeOf的来源,排除"挂载了自定义构造器属性但并非原生构造器"的伪造对象; - 最后遍历取最后一个键,判断其是否为自有属性——利用"自有属性优先枚举"的引擎行为做快速判定(注释
Own properties are enumerated firstly, so to speed up)。
有趣的是,测试 index.test.ts 构造的obj里故意放了constructor: 'fake'和isPrototypeOf: 'not a function'两个"陷阱字段",用来验证isPlainObject不会把这种"长着奇怪属性、但本质仍是对象字面量"的对象误判掉——它依然被当作可合并的 plain object 处理。
4.4 边界行为一览(来自测试矩阵)
test/index.test.ts 用完整的"类型 × 类型"组合矩阵锁定了行为契约,摘录关键几条:
| 场景 | 行为 | 测试位置 |
|---|---|---|
extend(undefined, {a:1})/extend({a:1}) | 缺参兜底,返回对象 | L41-L47 |
extend()无参 | 返回{} | L46 |
| 字符串/数字等基本类型作 target | 兜底为{}再合并 | L49-L187 |
extend(false, defaults, override) | 浅拷贝,正常覆盖 | L582-L587 |
| 数组与数组 | 浅拷贝下按下标合并,就地修改第一个数组 | L209-L218 |
| 深拷贝 + 数组 | 数组整体覆盖 | L572-L580 |
__proto__键 | 跳过,不污染原型 | L604-L608 |
Array.isArray被禁用 | 仍能正常工作(实现不依赖它) | L595-L602 |
| 深拷贝后修改目标,来源不受影响 | 递归处强制 clone,不移动原对象 | L541-L568 |
特别说明"works without Array.isArray"这条测试:它把Array.isArray临时置为false再执行深拷贝,验证实现不依赖Array.isArray判断数组——因为数组判断完全交由isPlainObject的toString标签完成。这是从 jQuery 一路传承下来的健壮性设计。
五、与 Object.assign、structuredClone 的对比
在日常开发中,extend常与原生 API 对比,三者的适用场景有明显差异:
| 能力 | extend(本工具) | Object.assign | structuredClone |
|---|---|---|---|
| 深拷贝 | 仅当deep=true,且只深合并 plain object | 不支持(仅浅拷贝) | 支持(按结构化克隆算法) |
| 多对象合并 | 支持 N 个来源依次覆盖 | 支持 N 个来源依次覆盖 | 不支持(只能克隆单个值) |
| 原地修改目标 | 是(返回 target 本身) | 是 | 否(返回全新对象) |
| 数组语义 | 深拷贝时整体覆盖 | 引用覆盖 | 深拷贝副本 |
| 非 plain object(Date/类实例) | 整体引用赋值 | 引用赋值 | 深拷贝副本 |
| 自定义合并规则 | 可自行用deep开关控制 | 无 | 无 |
因此:需要"配置默认值 + 多环境覆盖 + 数组整体替换"这类面向配置的场景,extend2是比Object.assign(太浅)和structuredClone(不合并、只克隆)都更贴合的方案;这也是 Egg 框架选择它的根本原因。
六、在 Egg 框架中的真实应用
@eggjs/extend2并非孤立工具,它直接服务于 Egg 框架的配置体系。仓库内共有两处核心引用:
6.1 配置加载器:逐层合并 config 文件
packages/core/src/loader/egg_loader.ts 在加载配置时多次调用extend(true, target, config)(见 L1066、L1074、L1099 等处),典型的合并链模式是:
extend(true, target, config); // 合并某份配置文件 extend(true, target, envConfig); // 再用环境配置覆盖最终形如config = extend(true, {}, config)(L1147)——先以空对象为底座,逐份把config.default、config.prod等合并进去,实现"默认值 → 环境值"的多层覆盖,并把结果继续灌入this.configMeta(L1149)用于记录配置来源元信息。
6.2 配置 dump:深拷贝快照
packages/egg/src/lib/egg.ts 的dumpConfigToObject方法用extend(true, {}, { config, plugins, appInfo })生成一份深拷贝快照,供dumpConfig()写入run/${type}_config.json。这里的语义是:把this.config完整克隆到新的空对象里,避免 dump 过程中任何一方被意外修改,正对应源码注释 "Never move original objects, clone them"。
可以看到,框架对extend2的使用始终是extend(true, ...)深拷贝模式,并且第一参数固定为{}或已有的 config 容器——这正与 README 给出的用法extend(true, {}, object1, objectN)完全一致。
七、结语
@eggjs/extend2以不到 80 行的核心实现,承载了 Egg 框架整个配置体系的合并与克隆需求。它继承自 jQuery.extend 的成熟算法,通过"深拷贝时数组按基本类型覆盖"这一处刻意改造,让配置合并的语义更贴近工程直觉;同时内置__proto__防护、自引用检测、plain object 精确判定等安全与健壮性设计,并有一套覆盖全部类型组合的测试矩阵背书。对于任何需要"多源合并 + 深度克隆 + 数组整体覆盖"的 Node.js/TypeScript 项目,它都是一个经过生产验证的轻量选择。
若想进一步研究,可继续阅读:
- 完整实现:packages/extend2/src/index.ts
- 行为契约测试:packages/extend2/test/index.test.ts
- 包发布配置:packages/extend2/package.json
- 框架内应用示例:packages/core/src/loader/egg_loader.ts 与 packages/egg/src/lib/egg.ts
- 后端
- Web框架
【免费下载链接】egg
🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode
相关推荐
@eggjs/extend2 深度解析:Egg 框架配置合并背后的深拷贝与数组覆盖语义
@eggjs/extend2 深度解析:Egg 框架配置合并背后的深拷贝与数组覆盖语义 @eggjs/extend2 是 Egg 官方维护的 jQuery.ex
后端Web框架Egg 框架文件监听插件 @eggjs/watcher 实战与源码解析
Egg 框架文件监听插件 @eggjs/watcher 实战与源码解析 导读 @eggjs/watcher 是 Egg 框架内置的文件监听插件,为 Worker
后端Web框架Goque完全指南:基于LevelDB的持久化Go数据结构解决方案
Goque完全指南:基于LevelDB的持久化Go数据结构解决方案 Goque是一个为Go语言开发的嵌入式磁盘存储数据结构库,它提供了基于LevelDB的持久化
后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考