从零构建 JSON Schema 可视化编辑器:拖拽式表单设计器的技术实现

手把手实现一个浏览器端 JSON Schema 可视化编辑器,涵盖树形结构渲染、拖拽排序、实时预览、Schema 导入导出等核心功能,附完整 TypeScript 代码与性能优化方案,帮开发者构建生产级 Schema 工具。

JSON 工具 2026-06-12 20 分钟

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 最常用的场景。对于 oneOfanyOfallOf 等组合关键字,需要额外的处理逻辑,我们在后面会单独讨论。

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 中的 useDraggablednd-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 时去除内部使用的字段 — 如 idparentIdchildren 等编辑器内部字段
  • 使用 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(只支持 stringnumberbooleanobjectarray 五种基础类型),再逐步添加 formatenum$ref 等高级功能。这比一次性实现所有功能要务实得多。


📚 相关文章