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 推断工具。

相关工具