起因
今年给自己定了个目标:做 100 个小工具页面。
不是为了流量,就是想把平时开发中遇到的痛点一个个解决掉。这是第 2 个。
第 1 个是发票批量识别工具,这次做的是 JSON 格式化。
为什么要自己做,不用现成的?
用过很多在线 JSON 格式化工具,大部分都是这个模式:
左边粘贴 JSON → 右边展示树形结构右边的树形视图只能看,不能改。
想改某个嵌套字段的值?回左边找到那一行,手动改,再看右边有没有更新。
嵌套层级深一点,找个字段要翻半天。
所以这次做的核心差异点就一个:右侧树形视图可以直接编辑,点击键名可以重命名,点击值可以直接修改,还能添加和删除节点,修改后左侧文本编辑器实时同步。
整体架构
┌─────────────────────────────────────────┐ │ JSON 编辑器 │ ├──────────────────┬──────────────────────┤ │ 左侧:文本编辑器 │ 右侧:树形视图 │ │ - 原始文本输入 │ - 可折叠节点 │ │ - 实时语法验证 │ - 点击编辑键名/值 │ │ - 格式化/压缩 │ - 添加/删除节点 │ └──────────────────┴──────────────────────┘ ↕ 双向同步分屏布局,左右两侧实时联动,任意一侧修改都会同步到另一侧。
一、实时语法验证
最基础的功能,监听 input 事件,用 JSON.parse 捕获异常:
jsonInput.addEventListener('input', function() { updateStatus(); validateJSON(); // 防抖更新树形视图,避免频繁重绘 clearTimeout(updateTreeTimer); updateTreeTimer = setTimeout(() => updateTreeView(), 800); }); function validateJSON() { const text = jsonInput.value.trim(); if (!text) { setStatus('info', '等待输入...'); return; } try { JSON.parse(text); setStatus('valid', 'JSON 格式正确'); } catch (e) { setStatus('invalid', 'JSON 格式错误: ' + e.message); } }防抖延迟 800ms 再更新树形视图,避免用户每输入一个字符就触发一次完整的 DOM 重建。
二、格式化与压缩
本质上就是 JSON.stringify 的两种用法:
// 格式化(美化) function formatJSON() { const obj = JSON.parse(jsonInput.value); const indentSize = document.getElementById('indentSize').value; const indent = indentSize === 'tab' ? '\t' : parseInt(indentSize); jsonInput.value = JSON.stringify(obj, null, indent); } // 压缩(去除所有空白) function compressJSON() { const obj = JSON.parse(jsonInput.value); jsonInput.value = JSON.stringify(obj); }支持 2 空格、4 空格、制表符三种缩进方式,通过 select 元素切换。
三、转义与反转义
在某些场景下(比如把 JSON 作为字符串存入数据库,或者嵌入 HTML 属性),需要对 JSON 进行转义:
// 转义:将 JSON 字符串本身作为字符串值处理 function escapeJSON() { const text = jsonInput.value.trim(); jsonInput.value = JSON.stringify(text); // 会自动转义特殊字符 } // 反转义:解析被转义的 JSON 字符串 function unescapeJSON() { const text = jsonInput.value.trim(); const unescaped = JSON.parse(text); if (typeof unescaped === 'string') { jsonInput.value = unescaped; } }四、树形视图的核心实现
这是整个编辑器最复杂的部分。核心思路是递归渲染,根据数据类型分别处理:
function renderJSON(data, container, level = 0, key = null, path = []) { const line = document.createElement('div'); line.className = 'json-viewer-line'; line.style.marginLeft = (level * 20) + 'px'; if (data === null || typeof data !== 'object') { // 基础类型:直接渲染值,绑定点击编辑事件 renderPrimitive(data, line, key, path); } else if (Array.isArray(data)) { // 数组:渲染折叠按钮 + [ + 递归子项 + ] renderArray(data, line, container, level, key, path); } else { // 对象:渲染折叠按钮 + { + 递归属性 + } renderObject(data, line, container, level, key, path); } }折叠/展开实现
每个可折叠节点生成一个唯一 ID,通过切换 hidden class 控制显示:
function toggleCollapse(id, toggle) { const element = document.getElementById(id); const closeElement = document.getElementById('close-' + id); const ellipsis = document.getElementById('ellipsis-' + id); if (element.classList.contains('hidden')) { element.classList.remove('hidden'); closeElement.style.display = 'block'; ellipsis.style.display = 'none'; toggle.innerHTML = '▼'; } else { element.classList.add('hidden'); closeElement.style.display = 'none'; ellipsis.style.display = 'inline'; // 折叠时显示 "3 items..." toggle.innerHTML = '▶'; } }折叠时显示 3 items... 或 5 properties... 的摘要,让用户知道折叠了多少内容。
五、可视化编辑——双向同步的关键
树形视图不只是展示,还支持直接编辑。关键是维护一个 currentJsonObject 引用,所有编辑操作都直接修改这个对象,然后重新序列化到文本编辑器:
let currentJsonObject = null; // 当前 JSON 对象的引用 function updateJsonValue(path, newValue) { if (path.length === 0) { currentJsonObject = newValue; return; } // 通过 path 数组定位到目标节点的父级 let current = currentJsonObject; for (let i = 0; i < path.length - 1; i++) { current = current[path[i]]; } current[path[path.length - 1]] = newValue; }path 是一个数组,记录从根节点到当前节点的路径,例如 ['address', 'city'] 表示 obj.address.city。
编辑值的交互
点击值时,将 span 替换为 input,失焦或按 Enter 保存:
function editValue(path, currentValue, valueType, element) { const input = document.createElement('input'); input.value = currentValue; element.textContent = ''; element.appendChild(input); input.focus(); const saveEdit = () => { const newValue = input.value.trim(); // 字符串类型允许清空为 "" if (newValue === '' && valueType === 'string') { updateJsonValue(path, ''); syncToTextEditor(); return; } // 数值类型清空时提示将变为 null if (newValue === '' && valueType === 'number') { if (confirm('值已清空,将把该字段设为 null,确认吗?')) { updateJsonValue(path, null); syncToTextEditor(); updateTreeView(); // 类型变了,需要重新渲染 } return; } // 按原类型解析新值 const parsed = parseByType(newValue, valueType); updateJsonValue(path, parsed); syncToTextEditor(); }; input.addEventListener('blur', saveEdit); input.addEventListener('keydown', e => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') cancelEdit(); }); }重命名键名
对象的键名重命名需要特殊处理——JavaScript 对象没有直接重命名 key 的 API,需要删除旧 key 并添加新 key,但这会改变属性顺序:
// 重命名键:删除旧键,添加新键(会移到末尾) const value = parent[oldKey]; delete parent[oldKey]; parent[newKey] = value;如果需要保持顺序,可以重建整个对象:
// 保持顺序的重命名 const newObj = {}; for (const k of Object.keys(parent)) { newObj[k === oldKey ? newKey : k] = parent[k]; } Object.assign(parent, newObj); // 删除多余的旧键(如果 newKey !== oldKey)六、节点的添加与删除
function addNode(path, nodeType) { let target = getByPath(currentJsonObject, path); if (nodeType === 'array') { target.push('新值'); } else { // 自动生成不重复的键名 let newKey = 'newKey'; let counter = 1; while (target.hasOwnProperty(newKey)) { newKey = 'newKey' + counter++; } target[newKey] = '新值'; } syncToTextEditor(); updateTreeView(); } function deleteNode(path) { const parent = getByPath(currentJsonObject, path.slice(0, -1)); const lastKey = path[path.length - 1]; if (Array.isArray(parent)) { parent.splice(lastKey, 1); // 数组用 splice } else { delete parent[lastKey]; // 对象用 delete } syncToTextEditor(); updateTreeView(); }七、性能优化
对于大型 JSON(几千个节点),频繁的 DOM 操作会很慢。几个优化点:
1. 防抖更新
clearTimeout(updateTreeTimer); updateTreeTimer = setTimeout(() => updateTreeView(), 800);2. 默认折叠深层节点
超过一定深度的节点默认折叠,减少初始渲染的 DOM 数量:
function renderJSON(data, container, level = 0, ...) { // 超过 3 层默认折叠 if (level > 3 && isObject(data)) { renderCollapsed(data, container, level, key, path); return; } // ... }3. 虚拟滚动(进阶)
对于超大 JSON,可以引入虚拟滚动,只渲染可视区域内的节点。不过对于日常开发场景,10MB 以内的 JSON 用防抖 + 默认折叠基本够用。
八、全屏模式
全屏模式通过 CSS position: fixed 实现,不依赖 Fullscreen API,兼容性更好:
function toggleFullscreen() { isFullscreen = !isFullscreen; if (isFullscreen) { editorContainer.classList.add('fullscreen-mode'); document.body.style.overflow = 'hidden'; } else { editorContainer.classList.remove('fullscreen-mode'); document.body.style.overflow = ''; } } // ESC 退出全屏 document.addEventListener('keydown', e => { if (e.key === 'Escape' && isFullscreen) toggleFullscreen(); });.fullscreen-mode { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998; border-radius: 0; }总结
成品图片:
实现一个完整的 JSON 编辑器,核心难点在于:
- 树形视图的递归渲染:需要处理对象、数组、基础类型三种情况
- 双向同步:维护一个共享的 JSON 对象引用,任意一侧修改后重新序列化
- path 路径定位:用数组记录节点路径,实现精准的深层节点更新
- 类型感知编辑:不同类型的值有不同的编辑规则(字符串可以为空,数值不能输入字母等)
大家可以来体验一下:Json在线格式化工具
代码量大约 2000 行,纯原生 JS 实现,无框架依赖,可以直接作为独立页面部署。