HTML + CSS + JavaScript实现可视化JSON工具:格式化、折叠、搜索与错误定位
调试接口时,最让人难受的往往不是 JSON 太复杂,而是它挤在一行、没有缩进、错误位置又说不清楚。
这篇文章用一个单文件网页解决这个问题:把 JSON 粘贴进去,点击格式化后就能看到可折叠的树形结构;输入关键词可以筛选节点;JSON 不合法时,页面会给出解析错误,并在浏览器提供字符位置时展示原文附近的片段。整个项目不依赖 Node.js、npm 或第三方库,保存一个 index.html 就能运行。
一、先看成品能做什么
这个小工具包含五个功能:
- 格式化 JSON,并按缩进显示;
- 压缩 JSON,方便复制到请求或配置中;
- 用树形结构折叠和展开对象、数组;
- 按关键词搜索键名、字符串和值;
- 解析失败时显示错误信息,并尽量定位到原文附近。
它适合临时查看接口响应、检查配置文件和学习 JSON 结构。它不是安全审计工具,也不会执行 JSON 中的任何脚本内容。
二、项目结构与运行方式
text
031-json-viewer/
├── index.html
└── README.md
把配套目录中的 index.html 双击打开即可。页面只使用浏览器内置的 HTML、CSS 和 JavaScript API,不会上传输入内容,也没有网络请求。
三、JSON为什么适合用树来展示
JSON 只包含六类值:对象、数组、字符串、数字、布尔值和 null。对象由键和值组成,数组由有序元素组成,因此可以自然地映射成一棵树:
text
对象
├── name: 字符串
├── enabled: 布尔值
└── items: 数组
├── 0: 对象
└── 1: 对象
浏览器的 JSON.parse() 负责把文本转换成 JavaScript 值;我们再递归遍历这个值,为每个节点生成 DOM。折叠功能本质上只是切换子节点容器的显示状态。
四、页面结构:输入区、操作区和结果区
html
<textarea id="source" spellcheck="false"></textarea>
<div class="toolbar">
<button id="format">格式化</button>
<button id="minify">压缩</button>
<button id="sample">载入示例</button>
<input id="search" type="search" placeholder="搜索键名或值">
</div>
<p id="status" role="status"></p>
<div id="result" class="tree" aria-live="polite"></div>
输入框使用 textarea,这样用户可以直接粘贴多行内容。按钮是真实的 HTML 控件,键盘可以聚焦,错误信息放进 role="status" 的元素,让辅助技术更容易感知状态变化。
五、格式化和压缩只做一件事
javascript
function parseSource() {
try {
return { value: JSON.parse(source.value), error: null };
} catch (error) {
return { value: null, error };
}
}
function formatJson() {
const parsed = parseSource();
if (parsed.error) return showError(parsed.error);
source.value = JSON.stringify(parsed.value, null, 2);
render(parsed.value);
setStatus('格式化完成');
}
function minifyJson() {
const parsed = parseSource();
if (parsed.error) return showError(parsed.error);
source.value = JSON.stringify(parsed.value);
render(parsed.value);
setStatus('压缩完成');
}
格式化和压缩都先经过同一个解析函数。这样可以保证两个按钮对非法 JSON 的处理一致,也避免把"只是文本换行"误认为格式化成功。
六、递归生成可折叠树
对象和数组需要展开子节点,字符串、数字、布尔值和 null 则是叶子节点:
javascript
function buildNode(key, value, path) {
const row = document.createElement('div');
row.className = 'node';
row.dataset.search = `${key ?? ''} ${value === null ? 'null' : String(value)}`.toLowerCase();
const isBranch = value !== null && typeof value === 'object';
const line = document.createElement('div');
line.className = 'line';
if (isBranch) {
const button = document.createElement('button');
button.className = 'twisty';
button.type = 'button';
button.textContent = '▾';
button.setAttribute('aria-label', '折叠节点');
line.append(button);
button.addEventListener('click', () => {
const collapsed = row.classList.toggle('collapsed');
button.textContent = collapsed ? '▸' : '▾';
button.setAttribute('aria-label', collapsed ? '展开节点' : '折叠节点');
});
}
line.append(createLabel(key, value));
row.append(line);
if (isBranch) {
const children = document.createElement('div');
children.className = 'children';
Object.entries(value).forEach(([childKey, childValue]) => {
children.append(buildNode(childKey, childValue, `${path}.${childKey}`));
});
row.append(children);
}
return row;
}
完整代码中还会显示对象的 {}、数组的 [] 以及子节点数量。这里使用 createElement() 和 textContent,而不是把用户输入拼进 innerHTML,这样可以避免把输入内容当成 HTML 解释。
七、搜索时只隐藏不匹配节点
搜索不需要重新解析 JSON。渲染完成后,每个节点都有一个小写的 data-search 字段,输入框变化时遍历节点即可:
javascript
function filterTree(keyword) {
const query = keyword.trim().toLowerCase();
const nodes = [...result.querySelectorAll('.node')];
nodes.forEach(node => {
const own = !query || node.dataset.search.includes(query);
const descendant = query && [...node.querySelectorAll('.node')]
.some(child => child.dataset.search.includes(query));
node.hidden = !(own || descendant);
});
}
search.addEventListener('input', () => filterTree(search.value));
这段代码会保留匹配节点及其祖先,否则读者会看到一个失去上下文的孤立结果。当前版本不会标出具体的匹配字符,也不会强制打开用户手动折叠的节点;这两项适合继续扩展。
八、错误定位:不要假装知道精确行列
JSON.parse() 抛出的错误文本在不同浏览器中并不完全相同,有些浏览器会给出字符位置,有些只给出语法提示。因此代码先展示原始错误,再尝试提取 position 或 at position 后面的数字:
javascript
function showError(error) {
const message = error instanceof Error ? error.message : String(error);
const match = message.match(/position\s+(\d+)/i);
const suffix = match ? `,附近字符位置:${match[1]}` : ';当前浏览器未提供字符位置';
setStatus(`JSON 解析失败:${message}${suffix}`, true);
}
这里故意使用"尽量定位"的表述。不能把浏览器没有提供的行号编造出来。完整项目会根据字符位置截取一小段上下文,帮助用户快速回到原文检查逗号、引号和括号。
九、样式和可用性细节
结果区采用深色面板和等宽字体,键名、字符串、数字和布尔值使用不同颜色。窄屏时输入区和工具栏改为单列,按钮保持足够的点击高度。
css
.toolbar {
display: grid;
grid-template-columns: auto auto auto minmax(160px, 1fr);
gap: 10px;
}
@media (max-width: 680px) {
.toolbar { grid-template-columns: 1fr 1fr; }
.toolbar input { grid-column: 1 / -1; }
}
搜索框使用 type="search",按钮明确声明 type="button",避免未来把面板放进表单后出现意外提交。
十、完整代码和测试方法
下面这份代码就是可以直接交给读者的完整 index.html。复制到新文件后双击打开即可,不依赖本文作者的本地目录。
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JSON Lens|本地可视化 JSON 工具</title>
<style>
:root { color-scheme: dark; --bg:#07111f; --panel:#0c192b; --line:#233650; --text:#dceaff; --muted:#8fa7c4; --accent:#52d3ff; --bad:#ff7285; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; background:radial-gradient(circle at 20% 0,#143156 0,transparent 35%),var(--bg); color:var(--text); font:15px/1.55 system-ui,sans-serif; }
main { width:min(1180px,calc(100% - 28px)); margin:auto; padding:30px 0 50px; }
h1 { margin:0; font-size:clamp(28px,5vw,48px); color:var(--accent); }
.intro { color:var(--muted); margin:6px 0 20px; }
.layout { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:16px; }
.panel { min-width:0; border:1px solid var(--line); border-radius:14px; background:rgba(12,25,43,.92); overflow:hidden; }
.panel-title { display:flex; justify-content:space-between; padding:11px 14px; border-bottom:1px solid var(--line); color:var(--muted); }
textarea { display:block; width:100%; min-height:520px; resize:vertical; border:0; outline:0; padding:16px; background:#07121f; color:#e5f2ff; font:14px/1.65 Consolas,monospace; }
.toolbar { display:grid; grid-template-columns:auto auto auto minmax(160px,1fr); gap:10px; margin:16px 0; }
button,input { min-height:42px; border:1px solid var(--line); border-radius:9px; background:#10233a; color:var(--text); font:inherit; }
button { padding:0 15px; cursor:pointer; } button:hover,input:focus-visible { border-color:var(--accent); outline:none; }
input { min-width:0; padding:0 12px; }
#status { min-height:24px; margin:0 0 10px; color:var(--muted); } #status.error { color:var(--bad); white-space:pre-wrap; }
.tree { min-height:520px; max-height:70vh; overflow:auto; padding:14px; font:14px/1.7 Consolas,monospace; }
.node[hidden] { display:none; } .line { display:flex; align-items:flex-start; min-height:24px; white-space:nowrap; }
.children { margin-left:18px; padding-left:10px; border-left:1px dashed #29415e; } .collapsed>.children { display:none; }
.twisty { width:24px; min-height:24px; padding:0; margin-right:3px; border:0; background:transparent; color:var(--accent); }
.spacer { display:inline-block; width:27px; } .key { color:#81d4fa; } .string { color:#a8e6a3; } .number { color:#ffc66d; } .boolean { color:#d6a5ff; } .null { color:#ff8fa3; } .meta { margin-left:8px; color:#6f88a6; }
@media(max-width:800px){ .layout{grid-template-columns:1fr} textarea,.tree{min-height:380px} }
@media(max-width:620px){ .toolbar{grid-template-columns:1fr 1fr}.toolbar input{grid-column:1/-1} }
</style>
</head>
<body>
<main>
<h1>JSON Lens</h1>
<p class="intro">格式化、压缩、折叠和搜索均在当前浏览器中完成。</p>
<div class="toolbar">
<button id="format" type="button">格式化</button><button id="minify" type="button">压缩</button><button id="sample" type="button">载入示例</button>
<input id="search" type="search" placeholder="搜索键名或值" aria-label="搜索键名或值">
</div>
<p id="status" role="status">粘贴 JSON,然后点击"格式化"。</p>
<div class="layout">
<section class="panel"><div class="panel-title"><span>原始 JSON</span><span id="size">0 字符</span></div><textarea id="source" spellcheck="false" aria-label="JSON输入"></textarea></section>
<section class="panel"><div class="panel-title"><span>树形结果</span><span id="matches"></span></div><div id="result" class="tree" aria-live="polite"></div></section>
</div>
</main>
<script>
'use strict';
const $ = selector => document.querySelector(selector);
const source=$('#source'), result=$('#result'), statusBox=$('#status'), search=$('#search'), size=$('#size'), matches=$('#matches');
const example={project:'JSON Lens',localOnly:true,version:1,items:[{id:101,name:'格式化',enabled:true},{id:102,name:'树形查看',enabled:true}],note:null};
function setStatus(text,error=false){statusBox.textContent=text;statusBox.classList.toggle('error',error)}
function parseSource(){try{return{value:JSON.parse(source.value)}}catch(error){return{error}}}
function valueType(value){return value===null?'null':Array.isArray(value)?'array':typeof value}
function scalarText(value){return typeof value==='string'?JSON.stringify(value):String(value)}
function label(key,value){const f=document.createDocumentFragment();if(key!==null){const k=document.createElement('span');k.className='key';k.textContent=JSON.stringify(key);f.append(k,document.createTextNode(': '))}const v=document.createElement('span');const branch=value!==null&&typeof value==='object';if(branch){v.className='meta';v.textContent=Array.isArray(value)?'[]':'{}';const n=document.createElement('span');n.className='meta';n.textContent=`${Object.keys(value).length} 项`;f.append(v,n)}else{v.className=valueType(value);v.textContent=scalarText(value);f.append(v)}return f}
function buildNode(key,value,path){const row=document.createElement('div');row.className='node';row.dataset.search=`${key??''} ${value!==null&&typeof value==='object'?'':scalarText(value)}`.toLowerCase();const branch=value!==null&&typeof value==='object',line=document.createElement('div');line.className='line';if(branch){const b=document.createElement('button');b.className='twisty';b.type='button';b.textContent='▾';b.setAttribute('aria-label','折叠节点');b.addEventListener('click',()=>{const c=row.classList.toggle('collapsed');b.textContent=c?'▸':'▾';b.setAttribute('aria-label',c?'展开节点':'折叠节点')});line.append(b)}else{const spacer=document.createElement('span');spacer.className='spacer';line.append(spacer)}line.append(label(key,value));row.append(line);if(branch){const children=document.createElement('div');children.className='children';Object.entries(value).forEach(([k,v])=>children.append(buildNode(k,v,`${path}.${k}`)));row.append(children)}return row}
function filterTree(keyword){const q=keyword.trim().toLowerCase(),nodes=[...result.querySelectorAll('.node')];let count=0;nodes.forEach(node=>{const own=!q||node.dataset.search.includes(q),desc=q&&[...node.querySelectorAll('.node')].some(child=>child.dataset.search.includes(q));node.hidden=!(own||desc);if(q&&own)count++});matches.textContent=q?`${count} 个匹配`:''}
function render(value){result.replaceChildren(buildNode(null,value,'$'));filterTree(search.value)}
function showError(error){result.replaceChildren();const message=error instanceof Error?error.message:String(error),m=message.match(/(?:at\s+)?position\s+(\d+)/i);let detail=';当前浏览器未提供字符位置';if(m){const p=Number(m[1]),start=Math.max(0,p-24),end=Math.min(source.value.length,p+24);detail=`,附近字符位置:${p}\n...${source.value.slice(start,end).replace(/\n/g,'↵')}...`}setStatus(`JSON 解析失败:${message}${detail}`,true)}
function process(transform){const parsed=parseSource();if(parsed.error)return showError(parsed.error);source.value=transform(parsed.value);size.textContent=`${source.value.length} 字符`;render(parsed.value);setStatus('处理完成')}
$('#format').addEventListener('click',()=>process(value=>JSON.stringify(value,null,2)));$('#minify').addEventListener('click',()=>process(value=>JSON.stringify(value)));$('#sample').addEventListener('click',()=>{source.value=JSON.stringify(example,null,2);size.textContent=`${source.value.length} 字符`;render(example);setStatus('已载入示例')});search.addEventListener('input',()=>filterTree(search.value));source.addEventListener('input',()=>size.textContent=`${source.value.length} 字符`);$('#sample').click();
</script>
</body>
</html>
打开页面后,可以按下面的顺序测试:
- 点击"载入示例",确认树形节点出现;
- 点击对象或数组前的箭头,确认可以折叠和展开;
- 搜索
token或items,确认不匹配节点被隐藏; - 删除一个逗号,点击"格式化",确认出现错误提示;
- 点击"压缩",确认输入区变成单行 JSON;
- 粘贴包含
<script>的字符串,确认它只作为文本显示,不会执行。
十一、这个工具的边界
它使用浏览器内存保存数据,刷新页面后内容会消失;没有大文件流式解析,几十 MB 以上的 JSON 可能让页面变慢;搜索是简单的字符串匹配,不支持 JSONPath;错误位置依赖浏览器提供的信息,不能保证每个浏览器都给出字符位置。
如果要处理生产数据,还应增加脱敏提醒、最大输入大小、导入导出策略和更明确的本地处理说明。不要把密码、访问令牌和客户隐私数据粘贴到不符合组织要求的工具中。
总结
这个项目的重点不在于写出一个复杂框架,而在于把一个高频小问题拆成几块清晰能力:
text
JSON.parse()
→ 递归遍历对象和数组
→ 使用 DOM 创建安全节点
→ CSS 控制树形布局
→ 搜索过滤与错误反馈
→ 移动端和数据边界说明
当一个工具能让读者打开就用、看完就能改,它比只展示概念更容易被收藏。下一步可以加入 JSONPath 查询、父级自动展开、复制节点路径和本地文件拖拽,但每项功能都应继续保持"输入只在本地处理"的边界。
参考资料
- MDN:
JSON.parse()------https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse - MDN:
JSON.stringify()------https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify - MDN:
Document.createElement()------https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement - MDN:
HTMLElement.dataset------https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset - MDN:ARIA
statusrole------https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role