Appearance
实现 JSON 编辑器 工具
分类:developer | 标签:JSON、编辑器、开发、工具 专业的JSON在线编辑器,支持格式化、验证、树形视图和导入导出
2.1 功能说明
专业的JSON在线编辑器,支持格式化、验证、树形视图和导入导出
核心能力
- 编辑器
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-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 效果截图
