news 2026/9/20 19:57:00

@eggjs/extend2 源码解析:Egg 框架配置合并与深拷贝的核心工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
@eggjs/extend2 源码解析:Egg 框架配置合并与深拷贝的核心工具
  • 后端
  • Web框架

【免费下载链接】egg

🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode

项目地址:https://gitcode.com/gh_mirrors/eg/egg
点击查看免费下载

导读

@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.assignstructuredClone的本质区别。

一、包定位:从一个关键差异说起

@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,并不是返回新对象);
  • 不传 targetextend(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.extendnode-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; } } }

几个值得注意的实现细节:

  1. __proto__过滤:遍历时显式跳过__proto__键(L54),这是针对原型污染攻击(Prototype Pollution)的安全加固。测试 index.test.ts 专门验证了这一点:extend(true, {}, JSON.parse('{"__proto__": {"polluted": "yes"}}'))不会污染Object.prototype,序列化结果仅为{}
  2. 自引用防死循环if (target === copy) continue;(L60)——当来源属性值就是目标对象本身时跳过,避免无限递归。
  3. 只对 plain object 递归:深拷贝分支要求isPlainObject(copy)成立(L63),数组、Date、类实例等统统不会进入递归,这正是"数组按基本类型覆盖"的实现根源。
  4. 只合并 plain object:目标侧同样要求src是 plain object 才原地合并,否则用{}作为新容器(L64),保证"Never move original objects, clone them"。
  5. 浅拷贝模式不复制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],排除数组、DateRegExp等(这些返回[object Array][object Date]等);
  • 再检查constructorisPrototypeOf的来源,排除"挂载了自定义构造器属性但并非原生构造器"的伪造对象;
  • 最后遍历取最后一个键,判断其是否为自有属性——利用"自有属性优先枚举"的引擎行为做快速判定(注释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判断数组——因为数组判断完全交由isPlainObjecttoString标签完成。这是从 jQuery 一路传承下来的健壮性设计。

五、与 Object.assign、structuredClone 的对比

在日常开发中,extend常与原生 API 对比,三者的适用场景有明显差异:

能力extend(本工具)Object.assignstructuredClone
深拷贝仅当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.defaultconfig.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

项目地址:https://gitcode.com/gh_mirrors/eg/egg
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 19:56:55

嵌入式AI与TinyML:MCU上的传感器本地推理实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 19:53:56

不部署向量数据库,Java 向量搜索怎么用 sqlite-vec 跑起来?

不部署向量数据库&#xff0c;Java 向量搜索怎么用 sqlite-vec 跑起来&#xff1f; 【免费下载链接】sqlite-vec A vector search SQLite extension that runs anywhere! 项目地址: https://gitcode.com/GitHub_Trending/sq/sqlite-vec 写给正在评估向量搜索方案的 Java…

作者头像 李华