HTML + CSS + JavaScript实现可视化JSON工具:格式化、折叠、搜索与错误定位

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() 抛出的错误文本在不同浏览器中并不完全相同,有些浏览器会给出字符位置,有些只给出语法提示。因此代码先展示原始错误,再尝试提取 positionat 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>

打开页面后,可以按下面的顺序测试:

  1. 点击"载入示例",确认树形节点出现;
  2. 点击对象或数组前的箭头,确认可以折叠和展开;
  3. 搜索 tokenitems,确认不匹配节点被隐藏;
  4. 删除一个逗号,点击"格式化",确认出现错误提示;
  5. 点击"压缩",确认输入区变成单行 JSON;
  6. 粘贴包含 <script> 的字符串,确认它只作为文本显示,不会执行。

十一、这个工具的边界

它使用浏览器内存保存数据,刷新页面后内容会消失;没有大文件流式解析,几十 MB 以上的 JSON 可能让页面变慢;搜索是简单的字符串匹配,不支持 JSONPath;错误位置依赖浏览器提供的信息,不能保证每个浏览器都给出字符位置。

如果要处理生产数据,还应增加脱敏提醒、最大输入大小、导入导出策略和更明确的本地处理说明。不要把密码、访问令牌和客户隐私数据粘贴到不符合组织要求的工具中。

总结

这个项目的重点不在于写出一个复杂框架,而在于把一个高频小问题拆成几块清晰能力:

text 复制代码
JSON.parse()
→ 递归遍历对象和数组
→ 使用 DOM 创建安全节点
→ CSS 控制树形布局
→ 搜索过滤与错误反馈
→ 移动端和数据边界说明

当一个工具能让读者打开就用、看完就能改,它比只展示概念更容易被收藏。下一步可以加入 JSONPath 查询、父级自动展开、复制节点路径和本地文件拖拽,但每项功能都应继续保持"输入只在本地处理"的边界。

参考资料

  1. MDN:JSON.parse()------https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse
  2. MDN:JSON.stringify()------https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
  3. MDN:Document.createElement()------https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement
  4. MDN:HTMLElement.dataset------https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
  5. MDN:ARIA status role------https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role
相关推荐
To_OC9 小时前
面试被问了三回三栏布局,这次我终于把 BFC 那层窗户纸捅破了
前端·css·面试
kyriewen11 小时前
我把最常踩的8个CORS跨域报错整理了一遍——第8个去年还不存在
前端·javascript
CarIise12 小时前
CSS选择器与样式关联
前端·css·tensorflow
里欧跑得慢14 小时前
CSS 模块化架构的演进:BEM、CSS Modules 到 CSS-in-JS 的反思
前端·css·flutter·web·css-in-js
NeilCarmack15 小时前
Deepseek-harness增加桌面版端序列:第 2 讲 · spawn Electron:当前进程如何“交棒“
前端·javascript·electron
CappuccinoRose16 小时前
JSON 数据交互规范
json·交互
躺柒17 小时前
读数据可视化10数据(下)
信息可视化·数据分析·可视化·数据可视化·大数据分析
DS随心转插件18 小时前
Grok生成的html怎么导出——AI导出鸭:大模型结构化输出的“最后一公里”工程化解构
前端·人工智能·ai·html·豆包·deepseek·ai导出鸭