JSON Schema $ref:definitions / $defs 与模块化复用
当 Schema 从几十行增长到几百行,重复的类型定义会变成维护负担。 $ref 让你把公共片段抽到一处、按指针复用。本文讲清本地指针、转义、循环保护与组合关键字,示例可在 Schema → JSON 示例 工具里复现。
一、为什么要 $ref
一个 API 里 User 类型往往在多个响应、多个嵌套位置重复出现。内联复制会导致「改一处、漏一处」。把 User 抽成一个具名定义,各处用指针引用, 结构就只有一份真相。draft-07 习惯放在 definitions,较新草案用 $defs,站内工具两者都支持。
二、带 $ref 的 Schema 示例
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"user": { "$ref": "#/definitions/User" }
},
"definitions": {
"User": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["admin", "member"] }
},
"required": ["id", "email"]
}
}
} 生成示例时会沿指针找到 User 并展开,得到形如 { "user": { "id": 0, "email": "user@example.com", "role": "admin" } } 的结果(email 采用 format 占位值,role 取 enum 首项)。
三、指针与转义:~0 / ~1
$ref 的值是一个 JSON Pointer,用 / 分隔层级。因为 / 与 ~ 在路径里有特殊含义,键名中出现它们时必须转义:
- 键名含
/→ 写作~1,例如属性"a/b"引用为#/properties/a~1b。 - 键名含
~→ 写作~0。 - 解析顺序是先
~1再~0,否则~01会被误读。
四、循环引用:递归栈保护
递归结构(例如评论里再嵌评论)会让 Schema 引用自身。生成示例时若不加保护就会无限展开。站内工具的规则是: 第二次进入同一个 $ref 立即返回 null,终止递归。因此像 #/definitions/Node 引用自身的结构也能安全产出示例,深层节点以 null 占位。
注意:只有本地指针(# 开头)才做解析与循环保护;外部 URL 引用不拉取,直接 null 占位。
五、组合关键字与整数、浮点的合并
- allOf 合并。各分支结果合并:对象取属性并集(先出现的键优先保留),数组拼接(最多 5 个元素),叶子取首个非 null;自身除组合关键字外的其它约束会一同参与,不丢 title 之类声明。
- oneOf / anyOf 取首个。生成示例时取第一个对象分支;要看到其它分支只好手工调整顺序或替换成具体子 Schema。
- $ref 与兄弟关键字。draft-07 中 $ref 与相邻关键字的行为有历史争议,站内工具的做法是:见到 $ref 即以被引用目标为准生成,不额外合并同级约束。
六、两种 items:tuple 与 list
数组的 items 有两种写法,语义完全不同:
- items 为对象(list)。所有元素同型,生成时按
minItems补足数量(上限 5 个),默认 1 个。 - items 为数组(tuple)。逐位置对应不同类型,生成时按数组逐项展开(最多 5 项),适合固定长度、位置有语义的结构。
- 空
items({})会生成[{}],因为无从得知元素形状。
七、常见错误
- 用外部 URL 引用。纯前端不做网络拉取,跨文件引用会得到 null;请把目标定义合并进同一文档,或改用 $defs 本地化。
- 指针层级写错。
#/definitions/User里的definitions是真实键名,改过存放位置后引用要同步更新。 - 忘记转义。键名含
/或~时未用~1/~0,会解析失败。 - 指望循环引用全展开。递归结构第二次进入即 null,需要完整样例请手工替换深层节点。
- tuple 与 list 混用。items 既想表达「固定位置类型」又想表达「统一元素类型」会语义冲突,按场景二选一。
动手跑一遍
把本文带 $ref 的 Schema 粘进 Schema → JSON 示例工具,观察指针解析与枚举、format 占位。改动引用目标,输出会立即变化。
常见问题
$ref 支持哪些引用形式?
站内工具只解析「文档内本地指针」,即以 # 开头、指向本文件节点的引用,形如 #/definitions/User 或 #/$defs/User。跨文件、http(s) URL 等外部引用不做网络拉取(纯前端零网络策略),对应节点会输出 null 占位。
为什么指针里要用 ~0 和 ~1?
JSON Pointer 用 / 分隔路径段,用 ~ 做转义引导符。如果键名本身含 /,必须写成 ~1;含 ~ 则写成 ~0。这样像属性名 "a/b" 这样的键才能被无歧义地寻址,对应引用写作 #/properties/a~1b。
循环引用的 Schema 会不会死循环?
不会。生成示例时维护一个「当前递归栈」集合,第二次进入同一个 $ref 就立即以 null 终止,不会再展开。这样树形或自引用结构(如 comments 里再嵌 comments)也能安全产出示例,只是深层节点以 null 占位,需要时手工替换成具体样例。
allOf / oneOf / anyOf 生成示例时怎么处理?
allOf 把各分支的结果合并:对象取属性并集(先到先得填键)、数组拼接,叶子取首个非 null。oneOf / anyOf 取第一个对象分支生成。组合关键字与自身其它约束(如 title)也会参与合并,避免丢掉自身声明。
怎样在这页的例子上做实验?
打开 Schema → JSON 示例工具,把本文带 $ref 的 Schema 粘贴进去,停顿约 400ms 即生成示例;改动引用指针或目标定义可实时看到输出变化。反向推断见 JSON Schema 推断工具。