JSON Schema 是现代 API 开发的基石——从 OpenAPI 规范到 MCP Tool 定义,从表单验证到 AI 结构化输出,几乎所有需要「描述数据形状」的场景都依赖 JSON Schema。然而,手写 JSON Schema 是一种令人痛苦的体验:深层嵌套的 properties、容易遗漏的 required 数组、令人困惑的 oneOf/anyOf 组合逻辑。根据 State of API 2025 调查,68% 的开发者承认曾在生产环境中因手写 Schema 出错导致线上 bug。
本文将从零构建一个浏览器端的 JSON Schema 可视化编辑器——用户可以通过点击、拖拽的方式交互式地设计 JSON Schema,实时预览生成结果,并支持从现有 JSON 数据反向推导 Schema。这不是一个简单的 demo,而是一个包含完整状态管理、性能优化和错误处理的工程化实现。
🎯 一、核心架构与数据模型设计
1.1 Schema 的内部表示
JSON Schema 本身是一个嵌套的 JSON 对象,但直接在 UI 中操作这个嵌套结构非常困难。我们需要将其转换为一种更适合 UI 操作的「扁平化树形结构」。
核心思路是:将 JSON Schema 解析为一棵树,每个节点代表一个属性定义,节点之间通过 id 维持父子关系。
// SchemaNode: JSON Schema 的内部树节点表示
interface SchemaNode {
id: string // 唯一标识符
key: string // 属性名(如 "name"、"address.city")
type: SchemaType // 类型:string | number | boolean | object | array | null
required: boolean // 是否必填
description?: string // 描述
format?: string // 格式约束(email、date-time、uri 等)
enum?: unknown[] // 枚举值
default?: unknown // 默认值
parentId: string | null // 父节点 ID
children: string[] // 子节点 ID 列表(object 的 properties)
// array 特有
items?: SchemaNode // 数组元素的 Schema
// 数值约束
minimum?: number
maximum?: number
// 字符串约束
minLength?: number
maxLength?: number
pattern?: string
}
type SchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'
💡 **提示:**使用扁平化的
{ [id: string]: SchemaNode }结构而非嵌套对象,是因为 UI 框架(如 React、Vue)在更新深层嵌套状态时性能较差。扁平结构配合 Immer 或手写的 immutable 更新函数,可以实现 O(1) 的节点更新。
1.2 从 JSON Schema 解析为内部表示
我们需要一个双向转换器:JSON Schema → 内部树,以及内部树 → JSON Schema。
// 将 JSON Schema 解析为扁平化的节点映射
function parseSchemaToNodes(
schema: Record<string, unknown>,
parentId: string | null = null,
key: string = 'root'
): { nodes: Map<string, SchemaNode>; rootId: string } {
const nodes = new Map<string, SchemaNode>()
const id = crypto.randomUUID()
const node: SchemaNode = {
id,
key,
type: (schema.type as SchemaType) || 'string',
required: false,
parentId,
children: [],
description: schema.description as string | undefined,
format: schema.format as string | undefined,
enum: schema.enum as unknown[] | undefined,
default: schema.default,
minimum: schema.minimum as number | undefined,
maximum: schema.maximum as number | undefined,
minLength: schema.minLength as number | undefined,
maxLength: schema.maxLength as number | undefined,
pattern: schema.pattern as string | undefined,
}
nodes.set(id, node)
// 递归解析 object 类型的 properties
if (schema.type === 'object' && schema.properties) {
const required = new Set((schema.required as string[]) || [])
for (const [propKey, propSchema] of Object.entries(
schema.properties as Record<string, Record<string, unknown>>
)) {
const { nodes: childNodes, rootId: childId } = parseSchemaToNodes(
propSchema, id, propKey
)
const childNode = childNodes.get(childId)!
childNode.required = required.has(propKey)
node.children.push(childId)
childNodes.forEach((n, nid) => nodes.set(nid, n))
}
}
// 解析 array 类型的 items
if (schema.type === 'array' && schema.items) {
const { nodes: itemNodes, rootId: itemId } = parseSchemaToNodes(
schema.items as Record<string, unknown>, id, '[items]'
)
node.children.push(itemId)
itemNodes.forEach((n, nid) => nodes.set(nid, n))
}
return { nodes, rootId: id }
}
这个解析器覆盖了 JSON Schema 最常用的场景。对于 oneOf、anyOf、allOf 等组合关键字,需要额外的处理逻辑,我们在后面会单独讨论。
1.3 从内部表示生成 JSON Schema
反向转换的核心是一个递归的树遍历:
// 从扁平节点映射生成标准 JSON Schema
function nodesToSchema(
nodes: Map<string, SchemaNode>,
rootId: string
): Record<string, unknown> {
const root = nodes.get(rootId)!
const schema: Record<string, unknown> = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: root.type,
}
if (root.description) schema.description = root.description
if (root.type === 'object' && root.children.length > 0) {
const properties: Record<string, unknown> = {}
const required: string[] = []
for (const childId of root.children) {
const child = nodes.get(childId)!
properties[child.key] = nodesToSchema(nodes, childId)
if (child.required) required.push(child.key)
}
schema.properties = properties
if (required.length > 0) schema.required = required
}
if (root.type === 'array' && root.children.length > 0) {
schema.items = nodesToSchema(nodes, root.children[0])
}
// 添加约束字段
if (root.format) schema.format = root.format
if (root.enum?.length) schema.enum = root.enum
if (root.minimum !== undefined) schema.minimum = root.minimum
if (root.maximum !== undefined) schema.maximum = root.maximum
if (root.minLength !== undefined) schema.minLength = root.minLength
if (root.maxLength !== undefined) schema.maxLength = root.maxLength
if (root.pattern) schema.pattern = root.pattern
if (root.default !== undefined) schema.default = root.default
return schema
}
📌 **记住:**生成 JSON Schema 时务必包含
$schema字段。虽然这不是严格必需的,但它声明了使用的规范版本(推荐2020-12),能帮助下游工具正确解析和验证。
🚀 二、树形 UI 渲染与交互实现
2.1 递归渲染 Schema 树
树形结构的渲染是一个经典的递归组件问题。核心挑战在于:如何在保持渲染性能的同时,支持深层嵌套的交互操作。
// Vue 3 组合式 API 实现的 Schema 树节点组件
// SchemaTreeNode.vue
<template>
<div class="schema-node" :class="{ 'is-required': node.required }">
<div class="node-header" @click="toggleExpand">
<span class="expand-icon">{{ expanded ? '▼' : '▶' }}</span>
<span class="node-key">{{ node.key }}</span>
<span class="node-type" :class="`type-${node.type}`">{{ node.type }}</span>
<span v-if="node.required" class="required-badge">必填</span>
<span v-if="node.format" class="format-badge">{{ node.format }}</span>
<div class="node-actions">
<button @click.stop="$emit('add-child', node.id)" title="添加子属性">+</button>
<button @click.stop="$emit('delete', node.id)" title="删除">🗑</button>
</div>
</div>
<div v-if="expanded && node.children.length > 0" class="node-children">
<SchemaTreeNode
v-for="childId in node.children"
:key="childId"
:node="getNode(childId)"
:nodes="nodes"
:depth="depth + 1"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</div>
</template>
2.2 拖拽排序的实现
拖拽排序是 Schema 编辑器的核心交互之一。我们使用 HTML5 Drag and Drop API 实现,同时保持 Schema 节点的 children 数组顺序同步更新。
// 拖拽排序的核心逻辑
function useDragSort(nodes: Ref<Map<string, SchemaNode>>) {
const dragState = reactive({
draggingId: null as string | null,
overId: null as string | null,
dropPosition: 'before' as 'before' | 'after' | 'inside',
})
function onDragStart(event: DragEvent, nodeId: string) {
dragState.draggingId = nodeId
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', nodeId)
}
function onDragOver(event: DragEvent, targetId: string) {
event.preventDefault()
if (dragState.draggingId === targetId) return
const rect = (event.target as HTMLElement).getBoundingClientRect()
const y = event.clientY - rect.top
const height = rect.height
// 根据鼠标位置判断放置位置
if (y < height * 0.25) {
dragState.dropPosition = 'before'
} else if (y > height * 0.75) {
dragState.dropPosition = 'after'
} else {
dragState.dropPosition = 'inside' // 放入目标节点内部(变为子节点)
}
dragState.overId = targetId
}
function onDrop(event: DragEvent) {
event.preventDefault()
const { draggingId, overId, dropPosition } = dragState
if (!draggingId || !overId || draggingId === overId) return
const nodeMap = nodes.value
const draggingNode = nodeMap.get(draggingId)!
const overNode = nodeMap.get(overId)!
// 1. 从原父节点的 children 中移除
if (draggingNode.parentId) {
const oldParent = nodeMap.get(draggingNode.parentId)!
oldParent.children = oldParent.children.filter(id => id !== draggingId)
}
// 2. 根据放置位置更新
if (dropPosition === 'inside') {
overNode.children.push(draggingId)
draggingNode.parentId = overId
// 确保目标节点是 object 或 array 类型
if (overNode.type !== 'object' && overNode.type !== 'array') {
overNode.type = 'object'
}
} else {
const parent = nodeMap.get(overNode.parentId!)!
const overIndex = parent.children.indexOf(overId)
const insertIndex = dropPosition === 'before' ? overIndex : overIndex + 1
parent.children.splice(insertIndex, 0, draggingId)
draggingNode.parentId = overNode.parentId
}
// 3. 清理拖拽状态
dragState.draggingId = null
dragState.overId = null
}
return { dragState, onDragStart, onDragOver, onDrop }
}
⚠️ **警告:**HTML5 Drag and Drop API 在移动端浏览器上支持度很差。如果你需要支持移动端,建议使用
@vueuse/integrations中的useDraggable或dnd-kit等库来实现跨平台的拖拽功能。
2.3 大型 Schema 的性能优化
当 Schema 包含数百个字段时,全量渲染会导致明显的卡顿。解决方案是虚拟滚动——只渲染可视区域内的节点。
// 虚拟滚动核心:将树展平为有序列表,只渲染可见部分
function useVirtualTree(
nodes: Ref<Map<string, SchemaNode>>,
rootId: Ref<string>,
containerHeight: Ref<number>,
itemHeight: number = 40
) {
// 将树展平为有序列表(只包含可见/展开的节点)
const flatList = computed(() => {
const result: Array<{ node: SchemaNode; depth: number }> = []
const expandedIds = new Set(expandedState.value)
function walk(id: string, depth: number) {
const node = nodes.value.get(id)!
result.push({ node, depth })
if (expandedIds.has(id)) {
for (const childId of node.children) {
walk(childId, depth + 1)
}
}
}
walk(rootId.value, 0)
return result
})
// 计算可见范围
const visibleRange = computed(() => {
const totalHeight = flatList.value.length * itemHeight
const scrollTop = scrollState.value
const start = Math.floor(scrollTop / itemHeight)
const visibleCount = Math.ceil(containerHeight.value / itemHeight)
// 前后各多渲染 5 个作为缓冲
const bufferedStart = Math.max(0, start - 5)
const bufferedEnd = Math.min(flatList.value.length, start + visibleCount + 5)
return { start: bufferedStart, end: bufferedEnd, totalHeight }
})
// 实际渲染的节点列表
const visibleItems = computed(() => {
const { start, end } = visibleRange.value
return flatList.value.slice(start, end).map((item, index) => ({
...item,
style: {
position: 'absolute' as const,
top: (start + index) * itemHeight + 'px',
height: itemHeight + 'px',
width: '100%',
}
}))
})
return { flatList, visibleRange, visibleItems }
}
💡 **提示:**虚拟滚动的实现核心是「将树展平为列表」。这样做的好处是每个节点的
top位置可以通过index * itemHeight简单计算,避免了递归计算每个节点高度的复杂性。代价是每次展开/折叠都需要重新展平——对于 1000 个节点以内的 Schema,这个开销可以忽略不计。
📊 三、高级功能:JSON 反向推导与 Schema 预览
3.1 从 JSON 数据自动推导 Schema
这是 Schema 编辑器中最有价值的功能之一:用户粘贴一段 JSON 数据,系统自动推导出对应的 JSON Schema。核心挑战在于类型推断的准确性——一个值为 null 的字段无法确定类型,一个空数组无法确定 items 类型。
// 从 JSON 值推导 Schema 类型
function inferSchema(value: unknown, key: string = 'root'): Record<string, unknown> {
if (value === null) {
return { type: 'null' }
}
if (Array.isArray(value)) {
const schema: Record<string, unknown> = { type: 'array' }
if (value.length > 0) {
// 取第一个元素的类型作为 items 类型
// 更高级的做法:合并所有元素的 Schema
const merged = mergeArrayItemSchemas(value)
schema.items = merged
}
return schema
}
if (typeof value === 'object') {
const properties: Record<string, unknown> = {}
const required: string[] = []
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
properties[k] = inferSchema(v, k)
if (v !== null && v !== undefined) required.push(k)
}
return { type: 'object', properties, required }
}
// 基础类型推断
const typeMap: Record<string, string> = {
string: 'string',
number: 'number',
boolean: 'boolean',
}
const schema: Record<string, unknown> = { type: typeMap[typeof value] || 'string' }
// 智能 format 推断(仅对 string 类型)
if (typeof value === 'string') {
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) schema.format = 'date-time'
else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) schema.format = 'date'
else if (/^[^@]+@[^@]+\.[^@]+$/.test(value)) schema.format = 'email'
else if (/^https?:\/\//.test(value)) schema.format = 'uri'
else if (/^[0-9a-f]{8}-/.test(value)) schema.format = 'uuid'
}
// 整数检测
if (typeof value === 'number' && Number.isInteger(value)) {
schema.type = 'integer'
}
return schema
}
// 合并数组中所有元素的 Schema(处理异构数组)
function mergeArrayItemSchemas(items: unknown[]): Record<string, unknown> {
if (items.length === 0) return { type: 'string' }
const schemas = items.map(item => inferSchema(item))
const types = [...new Set(schemas.map(s => s.type))]
if (types.length === 1) {
// 所有元素类型相同,可以进一步合并 object 的 properties
if (types[0] === 'object') {
return mergeObjectSchemas(schemas)
}
return schemas[0]
}
// 类型不同时使用 oneOf
return { oneOf: schemas }
}
// 合并多个 object Schema(取所有属性的并集)
function mergeObjectSchemas(schemas: Record<string, unknown>[]): Record<string, unknown> {
const allProperties: Record<string, Record<string, unknown>> = {}
const allRequired = new Set<string>()
for (const schema of schemas) {
const props = schema.properties as Record<string, Record<string, unknown>> || {}
for (const [key, propSchema] of Object.entries(props)) {
if (!allProperties[key]) {
allProperties[key] = propSchema
} else if (JSON.stringify(allProperties[key]) !== JSON.stringify(propSchema)) {
// 同名但类型不同时,使用 oneOf
allProperties[key] = { oneOf: [allProperties[key], propSchema] }
}
}
const req = schema.required as string[] || []
req.forEach(r => allRequired.add(r))
}
return {
type: 'object',
properties: allProperties,
required: [...allRequired],
}
}
3.2 实时 Schema 验证预览
编辑器应该提供一个「测试面板」,让用户输入 JSON 数据,实时验证是否符合当前 Schema。我们可以使用 ajv(Another JSON Schema Validator)来实现:
// 实时验证预览
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
const ajv = new Ajv({ allErrors: true, verbose: true })
addFormats(ajv)
function useSchemaValidation(
schema: Ref<Record<string, unknown>>,
testInput: Ref<string>
) {
const validationResult = computed(() => {
try {
const data = JSON.parse(testInput.value)
const validate = ajv.compile(schema.value)
const valid = validate(data)
return {
valid,
errors: validate.errors?.map(err => ({
path: err.instancePath || '/',
message: err.message || '未知错误',
params: err.params,
})) || [],
}
} catch (e) {
return {
valid: false,
errors: [{ path: '/', message: `JSON 解析错误: ${(e as Error).message}`, params: {} }],
}
}
})
return { validationResult }
}
⚠️ **警告:**AJV 编译 Schema 的开销不小(约 1-5ms)。如果用户在输入框中实时打字,每次 keystroke 都重新编译会导致卡顿。务必使用 debounce(建议 300ms)来限制验证频率。
3.3 方案对比:主流 JSON Schema 编辑器方案
在选择技术方案之前,先了解市面上的几种主流方案:
| 方案 | 开源 | 可嵌入 | 支持 Draft 2020-12 | 可视化拖拽 | 自定义扩展 | 适合场景 |
|---|---|---|---|---|---|---|
| 本文方案(自研) | ✅ | ✅ | ✅ | ✅ | ✅✅ 完全控制 | 定制化需求高的场景 |
| JSON Editor (json-editor) | ✅ | ✅ | ⚠️ Draft-07 | ❌ | ⚠️ 中等 | 快速集成表单 |
| React JSON Schema Form | ✅ | ✅ | ✅ | ❌ | ✅ 高 | React 表单场景 |
| Angular JSON Schema Form | ✅ | ✅ | ⚠️ | ❌ | ⚠️ 中等 | Angular 表单场景 |
| Stoplight Studio | ❌ | ❌ | ✅ | ✅ | ❌ | API 设计 |
| Swagger Editor | ✅ | ❌ | ✅ | ❌ | ❌ | OpenAPI 编辑 |
⚡ **关键结论:**如果你只需要「从 Schema 生成表单」,用 React JSON Schema Form 即可;如果你需要「可视化编辑 Schema 本身」(即本文的目标),市面上几乎没有成熟的开源方案——这正是本文的价值所在。
🔧 四、工程化细节与避坑指南
4.1 $ref 引用的处理
生产级的 JSON Schema 大量使用 $ref 来避免重复定义。在可视化编辑器中处理 $ref 是一个棘手的问题。
// $ref 解析与展开
function resolveRef(
schema: Record<string, unknown>,
rootSchema: Record<string, unknown>
): Record<string, unknown> {
if (!schema.$ref || typeof schema.$ref !== 'string') return schema
const refPath = schema.$ref
// 仅处理本地引用(#/$defs/xxx 或 #/definitions/xxx)
if (!refPath.startsWith('#/')) {
return { ...schema, _unresolvedRef: refPath }
}
const parts = refPath.replace('#/', '').split('/')
let current: unknown = rootSchema
for (const part of parts) {
if (current && typeof current === 'object' && part in current) {
current = (current as Record<string, unknown>)[part]
} else {
return { ...schema, _unresolvedRef: refPath }
}
}
// 递归解析(防止循环引用)
if (typeof current === 'object' && current !== null && '$ref' in (current as Record<string, unknown>)) {
return resolveRef(current as Record<string, unknown>, rootSchema)
}
return current as Record<string, unknown>
}
⚠️ 警告:
$ref可能形成循环引用(A 引用 B,B 引用 A)。上面的代码没有处理循环引用——在生产环境中,你需要维护一个visited集合来检测并中断循环。
4.2 关键避坑清单
在实现过程中,以下是开发者最容易踩的坑:
- ❌ 不要直接操作嵌套的 JSON Schema 对象 — 用扁平化的节点映射代替,否则每次更新都需要 immutable 深拷贝,性能灾难
- ❌ 不要忽略
additionalProperties的默认值 — JSON Schema 2020-12 中,additionalProperties默认为{}(允许任意额外属性),而非false - ❌ 不要在拖拽排序时使用
splice直接修改数组 — 在 Vue 3 的响应式系统中,splice会触发多次响应式更新,应该用新数组整体替换 - ✅ 始终在生成 Schema 时去除内部使用的字段 — 如
id、parentId、children等编辑器内部字段 - ✅ 使用 debounce 处理实时预览 — 避免每次击键都触发 JSON Schema 生成和验证
- ✅ 支持 Schema 的导入/导出 — 至少支持
.json文件拖拽导入和剪贴板粘贴
4.3 状态管理建议
对于中大型 Schema 编辑器(100+ 字段),状态管理是性能的关键:
// 使用 Command 模式实现撤销/重做
function useSchemaHistory(nodes: Ref<Map<string, SchemaNode>>) {
const history = ref<Array<Map<string, SchemaNode>>>([])
const historyIndex = ref(-1)
const maxHistory = 50
function snapshot() {
// 深拷贝当前状态
const copy = new Map<string, SchemaNode>()
nodes.value.forEach((node, id) => {
copy.set(id, { ...node, children: [...node.children] })
})
// 截断后续历史(分支覆盖)
history.value = history.value.slice(0, historyIndex.value + 1)
history.value.push(copy)
// 限制历史长度
if (history.value.length > maxHistory) {
history.value.shift()
}
historyIndex.value = history.value.length - 1
}
function undo() {
if (historyIndex.value <= 0) return
historyIndex.value--
restoreSnapshot(history.value[historyIndex.value])
}
function redo() {
if (historyIndex.value >= history.value.length - 1) return
historyIndex.value++
restoreSnapshot(history.value[historyIndex.value])
}
function restoreSnapshot(snapshot: Map<string, SchemaNode>) {
nodes.value.clear()
snapshot.forEach((node, id) => {
nodes.value.set(id, { ...node, children: [...node.children] })
})
}
return { snapshot, undo, redo, canUndo: computed(() => historyIndex.value > 0), canRedo: computed(() => historyIndex.value < history.value.length - 1) }
}
💡 **提示:**使用
Ctrl+Z/Ctrl+Shift+Z绑定撤销/重做是编辑器的基本体验要求。Command 模式虽然简单,但对于 Schema 编辑器这种更新频率不高的场景已经足够。如果需要更精细的增量历史,可以考虑 CRDT 或 operational transform。
✅ 总结与工具推荐
构建一个生产级的 JSON Schema 可视化编辑器,核心挑战在于三个方面:数据模型设计(扁平化节点映射 vs 嵌套对象)、树形交互(拖拽排序、虚拟滚动)和双向转换(JSON Schema ↔ 内部表示 ↔ JSON 数据推导)。
本文提供的完整实现方案可以直接用于构建 jsjson.com 等开发者工具站点的 Schema 编辑功能。以下是相关的工具和库推荐:
| 工具 | 用途 | 推荐度 |
|---|---|---|
| AJV | JSON Schema 验证(Draft 2020-12) | ✅✅✅ 生产首选 |
| TypeBox | 用 TypeScript 类型定义生成 Schema | ✅✅ 类型安全 |
| Zod | 运行时验证 + 类型推断 | ✅✅ DX 最佳 |
| @dnd-kit | 跨平台拖拽排序 | ✅✅ React 推荐 |
| VueDraggable | Vue 3 拖拽排序 | ✅✅ Vue 推荐 |
| @vueuse/core | useVirtualList 等工具函数 | ✅✅ Vue 生态必备 |
⚡ **关键结论:**JSON Schema 可视化编辑器不是一个可以「快速做完」的项目——仅 $ref 解析和 oneOf/anyOf 的 UI 表达就需要大量边界处理。建议先实现 MVP(只支持 string、number、boolean、object、array 五种基础类型),再逐步添加 format、enum、$ref 等高级功能。这比一次性实现所有功能要务实得多。
- 🔧 JSON Schema 校验器 — 在线 JSON Schema 验证工具
- 📊 JSON 格式化工具 — JSON 格式化与压缩
- 🛠️ JSON 转 TypeScript — 从 JSON 自动生成 TypeScript 类型