Skip to content

实现 JSON 编辑器 工具

分类:developer | 标签:JSON、编辑器、开发、工具 专业的JSON在线编辑器,支持格式化、验证、树形视图和导入导出

2.1 功能说明

专业的JSON在线编辑器,支持格式化、验证、树形视图和导入导出

核心能力

  • 编辑器

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • runQuery()
  • runFlatten()
  • runPick()
  • loadFromEditor()
  • loadJpSample()
  • formatJpJson()
  • clearJp()
  • copyText()
  • parsePath()
  • readName()
  • readBracket()
  • fmtSeg()
  • collectAll()
  • applySelector()
  • jsonPathQuery()
  • flattenDeep()
  • walk()
  • pickKeys()
  • goBack()
  • getEditorData()
  • setEditorDocId()
  • showSaveDialog()
  • handleSaveDocument()
  • showSaveAsDialog()
  • handleSaveAsDocument()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { 
  ElButton, 
  ElCard, 
  ElMessage,
  ElDialog,
  ElForm,
  ElFormItem,
  ElInput,
  ElTabs,
  ElTabPane,
  ElTable,
  ElTableColumn,
  ElTag,
  ElInputNumber,
  ElAlert,
  ElDivider,
  ElRow,
  ElCol
} from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import DocumentManager from './components/DocumentManager.vue'
import JsonEditorPanel from './components/JsonEditorPanel.vue'
import FeedbackForm from '@/components/FeedbackForm.vue'
import { useJsonEditorStore } from '@/stores/jsonEditor'

const router = useRouter()
const store = useJsonEditorStore()

/* ===================== JSONPath 查询(新增,零依赖) ===================== */
const jpJson = ref('')
const jpQuery = ref('')
const jpResults = ref([])
const jpError = ref('')
const jpFlattenDepth = ref(null)
const jpFlattenResult = ref('')
const jpFlattenError = ref('')
const jpPickKeys = ref('')
const jpPickResult = ref('')
const jpPickError = ref('')

const JP_SAMPLE = JSON.stringify({
  store: {
    name: '示例书店',
    book: [
      { category: 'fiction', title: '活着', author: '余华', price: 39.5 },
      { category: 'tech', title: '深入理解计算机系统', author: 'Bryant', price: 139 },
      { category: 'tech', title: 'JavaScript 高级程序设计', author: 'Zakas', price: 129 }
    ],
    bicycle: { color: 'red', price: 199 }
  },
  users: [
    { id: 1, name: 'Alice', profile: { name: 'A-nick', age: 30 } },
    { id: 2, name: 'Bob', profile: { name: 'B-nick', age: 25 } }
  ],
  a: { b: { c: 'deep-value' } }
}, null, 2)

const jpParsed = computed(() => {
  if (!jpJson.value.trim()) return { ok: false, error: '请输入 JSON 数据' }
  try {
    return { ok: true, data: JSON.parse(jpJson.value) }
  } catch (e) {
    return { ok: false, error: 'JSON 解析失败:' + e.message }
  }
})

const jpValueType = v => {
  if (v === null) return 'null'
  if (Array.isArray(v)) return 'array'
  return typeof v
}
const jpTypeTag = t => {
  return ({ string: 'success', number: 'warning', boolean: 'info', object: 'primary', array: 'primary', null: 'info' })[t] || 'info'
}
const fmtValue = v => (v === null ? 'null' : typeof v === 'object' ? JSON.stringify(v) : String(v))

function runQuery() {
  jpError.value = ''
  jpResults.value = []
  if (!jpParsed.value.ok) { jpError.value = jpParsed.value.error; return }
  if (!jpQuery.value.trim()) { jpError.value = '请输入 JSONPath 查询表达式'; return }
  try {
    const nodes = jsonPathQuery(jpParsed.value.data, jpQuery.value)
    jpResults.value = nodes.map(n => ({ path: n.path, type: jpValueType(n.value), value: fmtValue(n.value) }))
  } catch (e) {
    jpError.value = e.message
  }
}

function runFlatten() {
  jpFlattenError.value = ''
  jpFlattenResult.value = ''
  if (!jpParsed.value.ok) { jpFlattenError.value = jpParsed.value.error; return }
  const data = jpParsed.value.data
  if (!Array.isArray(data)) { jpFlattenError.value = '根节点必须是数组才能进行扁平化'; return }
  const depth = (jpFlattenDepth.value === null || jpFlattenDepth.value === '') ? null : Number(jpFlattenDepth.value)
  jpFlattenResult.value = JSON.stringify(flattenDeep(data, depth), null, 2)
}

function runPick() {
  jpPickError.value = ''
  jpPickResult.value = ''
  if (!jpParsed.value.ok) { jpPickError.value = jpParsed.value.error; return }
  const keys = jpPickKeys.value.split(/[,,]/).map(s => s.trim()).filter(Boolean)
  if (!keys.length) { jpPickError.value = '请输入要保留的字段名(逗号分隔)'; return }
  jpPickResult.value = JSON.stringify(pickKeys(jpParsed.value.data, keys), null, 2)
}

function loadFromEditor() {
  try {
    jpJson.value = JSON.stringify(store.jsonData, null, 2)
    ElMessage.success('已从编辑器载入当前文档')
  } catch {
    ElMessage.warning('编辑器当前内容不是有效 JSON,无法载入')
  }
}
function loadJpSample() { jpJson.value = JP_SAMPLE }
function formatJpJson() {
  if (!jpParsed.value.ok) { ElMessage.warning(jpParsed.value.error); return }
  jpJson.value = JSON.stringify(jpParsed.value.data, null, 2)
}
function clearJp() {
  jpJson.value = ''
  jpQuery.value = ''
  jpResults.value = []
  jpError.value = ''
  jpFlattenResult.value = ''
  jpPickResult.value = ''
}
function copyText(text, msg = '已复制到剪贴板') {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(() => ElMessage.success(msg)).catch(() => ElMessage.warning('复制失败'))
  } else {
    ElMessage.warning('当前环境不支持复制')
  }
}

/* ---------- 轻量 JSONPath 求值器(无第三方库) ---------- */
const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v)
const isArr = Array.isArray

function parsePath(path) {
  let s = String(path ?? '').trim()
  if (!s) throw new Error('查询表达式不能为空')
  if (s === '$') return []
  if (s[0] === '$') s = s.slice(1)

  const segs = []
  let i = 0
  const readName = () => {
    const start = i
    while (i < s.length && s[i] !== '.' && s[i] !== '[') i++
    if (i === start) throw new Error(`位置 ${start + 1} 处缺少属性名`)
    return s.slice(start, i)
  }
  const readBracket = () => {
    const start = i
    i++
    let quote = ''
    let body = ''
    while (i < s.length) {
      const ch = s[i]
      if (quote) {
        if (ch === '\\' && i + 1 < s.length) { body += s[i + 1]; i += 2; continue }
        if (ch === quote) { quote = ''; i++; continue }
        body += ch
        i++
        continue
      }
      if (ch === "'" || ch === '"') { quote = ch; i++; continue }
      if (ch === ']') { i++; return { body: body.trim(), quoted: s.slice(start, i) } }
      body += ch
      i++
    }
    throw new Error(`位置 ${start + 1} 处的 "[" 没有闭合`)
  }
  const bracketToSelector = raw => {
    const body = raw.body
    if (body === '*') return { type: 'wildcard' }
    if (body === '') throw new Error('"[]" 中不能为空')
    if (/^-?\d+$/.test(body)) return { type: 'index', index: Number(body) }
    if (/^-?\d*:-?\d*(:-?\d+)?$/.test(body) && body.includes(':')) {
      const [a, b, c] = body.split(':')
      return {
        type: 'slice',
        start: a === '' ? null : Number(a),
        end: b === '' ? null : Number(b),
        step: c === undefined || c === '' ? 1 : Number(c)
      }
    }
    if (body.includes(',')) {
      const parts = body.split(',').map(p => p.trim()).filter(Boolean)
      if (parts.every(p => /^-?\d+$/.test(p))) return { type: 'union', items: parts.map(Number) }
      return { type: 'union', items: parts.map(p => p.replace(/^['"]|['"]$/g, '')) }
    }
    return { type: 'name', name: body }
  }

  while (i < s.length) {
    const ch = s[i]
    if (ch === '.') {
      if (s[i + 1] === '.') {
        i += 2
        if (i >= s.length) throw new Error('".." 之后缺少属性名或 "[...]"')
        if (s[i] === '[') {
          segs.push({ desc: true, sel: bracketToSelector(readBracket()) })
        } else {
          const name = readName()
          segs.push({ desc: true, sel: name === '*' ? { type: 'wildcard' } : { type: 'name', name } })
        }
      } else {
        i += 1
        if (i >= s.length) throw new Error('"." 之后缺少属性名')
        if (s[i] === '[') {
          segs.push({ desc: false, sel: bracketToSelector(readBracket()) })
        } else {
          const name = readName()
          segs.push({ desc: false, sel: name === '*' ? { type: 'wildcard' } : { type: 'name', name } })
        }
      }
    } else if (ch === '[') {
      segs.push({ desc: false, sel: bracketToSelector(readBracket()) })
    } else {
      const name = readName()
      segs.push({ desc: false, sel: name === '*' ? { type: 'wildcard' } : { type: 'name', name } })
    }
  }
  return segs
}

function fmtSeg(key) {
  return typeof key === 'number' ? `[${key}]` : /^[A-Za-z_$][\w$]*$/.test(key) ? `.${key}` : `['${key}']`
}

function collectAll(node) {
  const out = []
  const walk = n => {
    out.push(n)
    if (isArr(n.value)) n.value.forEach((v, idx) => walk({ path: n.path + `[${idx}]`, value: v }))
    else if (isObj(n.value)) Object.keys(n.value).forEach(k => walk({ path: n.path + fmtSeg(k), value: n.value[k] }))
  }
  walk(node)
  return out
}

function applySelector(node, sel) {
  const v = node.value
  const out = []
  switch (sel.type) {
    case 'name':
      if (isObj(v) && Object.prototype.hasOwnProperty.call(v, sel.name)) {
        out.push({ path: node.path + fmtSeg(sel.name), value: v[sel.name] })
      }
      break
    case 'wildcard':
      if (isArr(v)) v.forEach((item, idx) => out.push({ path: node.path + `[${idx}]`, value: item }))
      else if (isObj(v)) Object.keys(v).forEach(k => out.push({ path: node.path + fmtSeg(k), value: v[k] }))
      break
    case 'index': {
      if (!isArr(v)) break
      const idx = sel.index < 0 ? v.length + sel.index : sel.index
      if (idx >= 0 && idx < v.length) out.push({ path: node.path + `[${idx}]`, value: v[idx] })
      break
    }
    case 'slice': {
      if (!isArr(v)) break
      const len = v.length
      const step = sel.step || 1
      let start = sel.start === null ? (step > 0 ? 0 : len - 1) : sel.start < 0 ? len + sel.start : sel.start
      let end = sel.end === null ? (step > 0 ? len : -1) : sel.end < 0 ? len + sel.end : sel.end
      if (step > 0) {
        start = Math.max(0, start)
        end = Math.min(len, end)
        for (let k = start; k < end; k += step) out.push({ path: node.path + `[${k}]`, value: v[k] })
      } else {
        start = Math.min(len - 1, start)
        end = Math.max(-1, end)
        for (let k = start; k > end; k += step) out.push({ path: node.path + `[${k}]`, value: v[k] })
      }
      break
    }
    case 'union':
      for (const item of sel.items) {
        if (typeof item === 'number') out.push(...applySelector(node, { type: 'index', index: item }))
        else out.push(...applySelector(node, { type: 'name', name: item }))
      }
      break
  }
  return out
}

function jsonPathQuery(data, path) {
  const segs = parsePath(path)
  let nodes = [{ path: '$', value: data }]
  for (const seg of segs) {
    const next = []
    const sources = seg.desc ? nodes.flatMap(collectAll) : nodes
    for (const n of sources) next.push(...applySelector(n, seg.sel))
    if (seg.desc) {
      const seen = new Set()
      nodes = next.filter(n => (seen.has(n.path) ? false : (seen.add(n.path), true)))
    } else {
      nodes = next
    }
  }
  return nodes
}

function flattenDeep(value, depth) {
  if (!isArr(value)) return value
  const d = depth === null || depth === undefined ? Infinity : depth
  const walk = (arr, left) => {
    const out = []
    for (const item of arr) {
      if (isArr(item) && left > 0) out.push(...walk(item, left - 1))
      else out.push(item)
    }
    return out
  }
  return walk(value, d)
}

function pickKeys(value, keys) {
  const set = new Set(keys)
  const walk = v => {
    if (isArr(v)) return v.map(walk)
    if (isObj(v)) {
      const out = {}
      for (const k of Object.keys(v)) if (set.has(k)) out[k] = walk(v[k])
      return out
    }
    return v
  }
  return walk(value)
}

const activeTab = ref('editor')
const saveDialogVisible = ref(false)
const saveAsDialogVisible = ref(false)
const saveForm = ref({ name: '', editor: 'main' })
const saveAsForm = ref({ name: '', editor: 'main' })

const goBack = () => {
  router.push('/developer')
}

const getEditorData = (editor) => {
  if (editor === 'left') return { data: store.leftJsonData, docId: store.leftDocumentId }
  if (editor === 'right') return { data: store.rightJsonData, docId: store.rightDocumentId }
  return { data: store.jsonData, docId: store.currentDocumentId }
}

const setEditorDocId = (editor, id) => {
  if (editor === 'left') store.leftDocumentId = id
  else if (editor === 'right') store.rightDocumentId = id
  else store.currentDocumentId = id
}

const showSaveDialog = (editor = 'main') => {
  const { data, docId } = getEditorData(editor)
  try {
    JSON.stringify(data)
  } catch {
    ElMessage.error('当前JSON格式不正确,无法保存')
    return
  }
  saveForm.value.editor = editor
  saveForm.value.name = docId ? store.getDocumentName(docId) : `JSON文档_${new Date().toLocaleString('zh-CN', { hour12: false }).replace(/[\/\s:]/g, '-')}`
  saveDialogVisible.value = true
}

const handleSaveDocument = async () => {
  if (!saveForm.value.name.trim()) {
    ElMessage.warning('请输入文档名称')
    return
  }
  try {
    const { data, docId } = getEditorData(saveForm.value.editor)
    const isUpdate = !!docId
    const savedId = await store.saveDocument(docId, saveForm.value.name, data)
    setEditorDocId(saveForm.value.editor, savedId)
    ElMessage.success(isUpdate ? '文档更新成功' : '文档保存成功')
    saveDialogVisible.value = false
  } catch (error) {
    ElMessage.error('保存失败: ' + error.message)
  }
}

const showSaveAsDialog = (editor = 'main') => {
  const { data } = getEditorData(editor)
  try {
    JSON.stringify(data)
  } catch {
    ElMessage.error('当前JSON格式不正确,无法保存')
    return
  }
  saveAsForm.value.editor = editor
  saveAsForm.value.name = `JSON文档_${new Date().toLocaleString('zh-CN', { hour12: false }).replace(/[\/\s:]/g, '-')}`
  saveAsDialogVisible.value = true
}

const handleSaveAsDocument = async () => {
  if (!saveAsForm.value.name.trim()) {
    ElMessage.warning('请输入新文档名称')
    return
  }
  try {
    const { data } = getEditorData(saveAsForm.value.editor)
    const newId = await store.saveDocument(null, saveAsForm.value.name, data)
    setEditorDocId(saveAsForm.value.editor, newId)
    ElMessage.success('另存为成功')
    saveAsDialogVisible.value = false
  } catch (error) {
    ElMessage.error('另存为失败: ' + error.message)
  }
}

onMounted(() => {
  store.initDB()
})

onBeforeUnmount(() => {
  store.closeDB()
})

2.3 效果截图

JSON 编辑器 效果截图

工具访问地址:https://www.i91tools.com/tools/json-editor