Effect Formatter.formatJson 详解:只省略循环引用、保留共享引用,并移除 Inspectable.stringifyCircular
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
本篇技术指南围绕 Effect 仓库中的一条 patch 级变更记录展开:移除Inspectable.stringifyCircular辅助函数,并修复Formatter.formatJson的引用处理语义,使其在序列化时保留共享(diamond)对象引用、只省略真正的循环引用。读完后,你将理解 Effect 中format与formatJson两个值格式化函数对循环引用的判定原理(“当前祖先路径”语义),并能结合源码与测试用例验证、正确使用这两个 API。
1. 变更背景:这条 changeset 描述了什么
本次变更由 changeset 文件 记录,内容非常凝练,原文共两点:
--- "effect": patch --- Remove `Inspectable.stringifyCircular` and fix `Formatter.formatJson` so shared object references are preserved while only circular references are omitted.即:
- 移除
Inspectable.stringifyCircular:从Inspectable模块中删掉这个用于处理循环引用的字符串化辅助函数; - 修复
Formatter.formatJson:让“共享对象引用”(同一对象被多个属性引用但不构成环)在 JSON 输出中被完整保留,只有“循环引用”(对象沿着自身祖先链再次出现)才会被省略,而不是像朴素实现那样把两者一并丢弃。
changeset 文件位于.changeset/pre/目录下。从 .changeset/pre.json 可以看到仓库当前处于 changesets 的 pre-release 模式("mode": "pre"、"tag": "rc"),因此该记录会随 rc 版本流程一并应用。变更级别为patch,说明它不改变公开 API 的兼容性,只修正行为并移除一个旧辅助函数。仓库 packages/effect/CHANGELOG.md 中已收录了这条变更的发布记录(“RemoveInspectable.stringifyCircularand fixFormatter.formatJsonso shared object references are preserved while only circular references are omitted”)。
2. Inspectable.stringifyCircular 的移除:Inspectable 模块的现状
在 packages/effect/src/Inspectable.ts 中,stringifyCircular已不存在。通过对整个仓库搜索stringifyCircular可以确认:packages/目录下仅剩packages/effect/CHANGELOG.md的历史记录中提及该名称,源码中已无任何定义或调用——这与 changeset 的“Remove”描述一致。
当前Inspectable模块对外暴露的 API 面为:
| API | 作用 | 位置 |
|---|---|---|
NodeInspectSymbol | Symbol.for("nodejs.util.inspect.custom"),供 Node.jsutil.inspect()/REPL 自定义对象展示 | Inspectable.ts#L49 |
Inspectable接口 | 要求toString()、toJSON()、[NodeInspectSymbol]()三个成员 | Inspectable.ts#L126-L130 |
toJson | 先redact再调用零参toJSON(),失败时返回"[toJSON threw]" | Inspectable.ts#L153-L169 |
toStringUnknown | 对象走formatJson、其他值走format,兜底String(u) | Inspectable.ts#L187-L196 |
BaseProto/Class | 为自定义值提供统一的检查(inspect)行为基座 | Inspectable.ts#L234-L244、L291 |
对使用者而言,移除stringifyCircular的含义是:自定义数据类型的字符串化/JSON 化应统一走format/formatJson,而不再依赖 Inspectable 模块内部的循环引用字符串化工具。模块 JSDoc 也说明了这一设计意图:Inspectable负责“控制值在日志和调试输出中的呈现”,而循环引用的处理职责收敛到了Formatter模块(见下节)。自定义类型推荐的写法仍是继承Inspectable.Class并只实现toJSON(),toString()与 Node inspect 行为由基类通过format(this.toJSON())提供(Inspectable.ts#L291-L331)。
3. formatJson 的修复核心:共享引用 vs 循环引用
3.1 为什么需要区分这两类引用
直接对含环的对象调用JSON.stringify会抛出TypeError: Converting circular structure to JSON。一个常见的“安全包装”方案是用WeakSet记录所有“已经见过”的对象,再次遇到就跳过——但这会误伤共享引用:
const shared = { a: 1 } // 期望输出两个完整副本: // {"left":{"a":1},"right":{"a":1}} JSON.stringify({ left: shared, right: shared })如果只按“是否出现过”判重,right会被错误地省略。本次修复确立的语义是:以“当前序列化路径”(祖先栈)为判定基准——只有当一个对象再次出现在当前路径的祖先上时才是循环引用,才返回undefined让它从输出中消失;而路径之外重复出现的共享对象则正常完整序列化。
3.2 源码实现:replacer 内的祖先栈
修复后的formatJson实现位于 packages/effect/src/Formatter.ts#L302-L330。核心逻辑:
export function formatJson(input: unknown, options?: { readonly space?: number | string | undefined }): string { const ancestors: Array<object> = [] return JSON.stringify( input, function(this: object, key: string, value: unknown) { const original = Object.getOwnPropertyDescriptor(this, key)?.value const redacted = Predicate.hasProperty(original, symbolRedactable) ? redact(original) : redact(value) if (typeof redacted === "bigint") { return format(redacted) } if (typeof redacted !== "object" || redacted === null) { return redacted } while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { ancestors.pop() } if (ancestors.includes(redacted)) { return undefined // circular reference } ancestors.push(redacted) return redacted }, options?.space ) ?? "null" }几个关键点:
- 祖先栈的进栈/出栈即“路径”语义:
ancestors数组模拟了JSON.stringify的回溯过程。每次处理完一个对象后,while循环把栈顶回退到当前this(Formatter.ts#L319-L321),因此任意时刻栈中元素恰好是“当前对象到根的路径”。共享对象在left分支处理完出栈后,处理right时ancestors.includes(redacted)为false,于是被完整序列化第二次;而真正的环(obj.self = obj)会让obj仍留在当前祖先栈中,从而返回undefined被JSON.stringify省略(Formatter.ts#L322-L325)。 - Redactable 脱敏:通过
Object.getOwnPropertyDescriptor(this, key)?.value先取“原始自有属性值”再判断symbolRedactable(Formatter.ts#L309-L312),避免 replacer 拿到的已经是toJSON()之后的结果,保证Redacted值在任何位置都会被脱敏为[REDACTED]。 - BigInt 支持:JSON 原生不支持
bigint,这里用format(redacted)产出形如"123n"的字符串(Formatter.ts#L313-L315)。 - 根级返回值兜底:当根输入是
undefined、symbol或函数时JSON.stringify返回undefined,末尾的?? "null"将其统一为"null"。这是模块 JSDoc 中明确写出的“Gotchas”(Formatter.ts#L264-L268)。 space选项:透传给JSON.stringify控制缩进,缺省为0(紧凑输出)。
formatJson的 JSDoc(Formatter.ts#L246-L301)给出的示例也与上述语义一致:
import { Formatter } from "effect" Formatter.formatJson({ name: "Alice", age: 30 }) // => "{\"name\":\"Alice\",\"age\":30}" const obj: any = { name: "test" } obj.self = obj Formatter.formatJson(obj) // => "{\"name\":\"test\"}" // self 被省略,不抛错 const output = Formatter.formatJson({ name: "Alice", age: 30 }, { space: 2 }) // => "{\n \"name\": \"Alice\",\n \"age\": 30\n}"3.3 对照:format 的人读字符串语义
format(Formatter.ts#L108-L195)产出非JSON 的可读字符串,供日志、诊断与错误消息使用。它用WeakSet记录当前递归路径(进入前add、返回前delete,同样是路径语义),命中时输出[Circular]占位符而不抛错(Formatter.ts#L152-L153、L197)。因此共享引用在format中同样会出现两次,与formatJson的修复语义保持一致:
const obj: any = { name: "loop" } obj.self = obj Formatter.format(obj) // => "{\"name\":\"loop\",\"self\":[Circular]}"两者选型建议:需要可解析的 JSON(如结构化日志、错误上报)用formatJson;需要人读友好(Map、Set、BigInt、RegExp、自定义toString、Error.cause等JSON.stringify无法表达的形态)用format。formatJson在仓库中被Logger相关代码及 Effect 内部实现引用(见 packages/effect/src/Logger.ts 与 packages/effect/src/internal/effect.ts),所以这次引用语义的修复会直接作用于运行期日志与诊断输出的正确性。
4. 测试用例:语义修复的可验证依据
packages/effect/test/Formatter.test.ts 中的断言完整覆盖了两种引用形态,是验证本次行为的最直接证据。
format侧(Formatter.test.ts#L107-L143):
it("circular object", () => { const obj: any = { a: 1 } obj.b = obj strictEqual(format(obj), `{"a":1,"b":[Circular]}`) }) it("preserves repeated non-circular references", () => { const shared = { value: 1 } strictEqual(format({ first: shared, second: shared }), `{"first":{"value":1},"second":{"value":1}}`) })同一文件还覆盖了循环数组([1,[Circular]])、循环Map/Set内容等边界形态(L107-L125)。
formatJson侧(Formatter.test.ts#L325-L344):
it("should omit circular references", () => { // obj.a = 1; obj.self = obj strictEqual(formatJson(obj), `{"a":1}`) }) it("preserves repeated non-circular references", () => { const shared = { a: 1 } strictEqual(formatJson({ left: shared, right: shared }), `{"left":{"a":1},"right":{"a":1}}`) }){"left":{"a":1},"right":{"a":1}}这一断言正是 changeset 所述“shared object references are preserved”的直接验证:共享引用完整保留,self这类真环才从输出中消失。此外测试还确认了formatJson(undefined) === "null"的根级兜底、BigInt的"123n"字符串化,以及Redacted值在对象、嵌套对象与数组中的脱敏输出(L325-L355)。
5. 适用前提与使用建议
- 版本前提:
format标注@since 2.0.0,formatJson标注@since 4.0.0(见 Formatter.ts#L106、L300),且当前仓库处于 rc 预发布模式,stringifyCircular的移除与formatJson的语义修复将在该 pre-release 周期内随patch版本生效;如果你依赖的是更早期的已发布版本,行为可能不同,以对应版本的 CHANGELOG 为准。 - 选型:日志/诊断/错误消息用
format(可读、容错,[Circular]占位不抛错);需要下游可JSON.parse的输出用formatJson(合法 JSON,环被静默省略,共享引用保留)。 - 边界行为:
formatJson对 JSON 原生不支持的值仍遵循JSON.stringify的标准行为(如函数、undefined在嵌套位置),仅根级特殊输入会被统一为"null";format则对检查过程中的异常渲染为[inspection threw]等诊断占位符而非抛出。 - 自定义类型:如需让自己的值在日志与
util.inspect中呈现良好,继承Inspectable.Class实现toJSON()即可;序列化与循环引用处理交给Formatter模块,不要再自行实现字符串化的判环逻辑。
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考