最近在处理 API 返回的数据时,经常遇到压缩成一行的 JSON ,调试起来很痛苦。找了几个在线工具,要么广告多,要么功能单一。干脆自己实现一个,顺便把实现思路记录下来。
JSON 格式化的本质
其实核心就两行代码:
bash
const parsed = JSON.parse(input)
const formatted = JSON.stringify(parsed, null, 2)
JSON.stringify 的第三个参数就是缩进空格数。传 2 就是 2 空格缩进,传 4 就是 4 空格,传 0 就是压缩。
但实际做工具时,远没这么简单。
错误定位:从 position 到行列号
JSON.parse 报错时,错误信息类似:
bash
Unexpected token } in JSON at position 45
这个 position 45 是字符位置,对用户来说毫无意义。用户需要的是"第几行第几列"。
转换算法很简单:
bash
function getLineAndColumn(input: string, position: number) {
const lines = input.substring(0, position).split('\n')
const line = lines.length
const column = lines[lines.length - 1].length + 1
return { line, column }
}
截取错误位置之前的 字符串 ,按换行符分割,行数就是数组长度,列数就是最后一行的字符数。
这样报错信息就变成了:
bash
JSON 解析错误: Unexpected token } (行 3, 列 12)
用户一眼就能定位问题。
树形视图的递归实现
格式化后的 JSON 虽然可读,但层级深的时候还是不够直观。树形视图能更好地展示结构。
核心是一个递归组件:
bash
function TreeNode({ data, name, level }: Props) {
const [expanded, setExpanded] = useState(true)
const isObject = data !== null && typeof data === 'object'
const isArray = Array.isArray(data)
if (!isObject) {
// 基础类型:直接显示值
return (
<div style={{ marginLeft: level * 16 }}>
<span className="key">{name}:</span>
<span className={getTypeColor(data)}>
{typeof data === 'string' ? `"${data}"` : String(data)}
</span>
</div>
)
}
// 对象/数组:递归渲染子节点
const keys = Object.keys(data)
return (
<div>
<button onClick={() => setExpanded(!expanded)}>
{expanded ? '▼' : '▶'} {name}
{isArray ? `[${keys.length}]` : `{${keys.length}}`}
</button>
{expanded && keys.map(key => (
<TreeNode
key={key}
data={isArray ? data[+key] : data[key]}
name={isArray ? `[${key}]` : key}
level={level + 1}
/>
))}
</div>
)
}
几个细节:
- 类型着色:字符串绿色、数字青色、布尔紫色,一眼区分类型
- 数组标记 :用
[3]显示数组长度,{5}显示对象属性数 - 缩进控制 :通过
level * 16px实现层级缩进
性能优化:大文件处理
当 JSON 文件达到几 MB 时,直接渲染会卡顿。几个优化手段:
1. 虚拟滚动
只渲染可视区域的节点,配合 react-window 或自己实现:
bash
import { FixedSizeList } from 'react-window'
function VirtualTree({ data }: { data: object }) {
const nodes = flattenTree(data) // 扁平化树结构
return (
<FixedSizeList
height={600}
itemCount={nodes.length}
itemSize={24}
>
{({ index, style }) => (
<div style={style}>
<TreeNode data={nodes[index]} />
</div>
)}
</FixedSizeList>
)
}
2. 延迟解析
用户输入时不要实时解析,用 debounce 延迟处理:
bash
const debouncedParse = useMemo(
() => debounce((value: string) => {
try {
const parsed = JSON.parse(value)
setOutput(formatJson(parsed))
} catch (e) {
setError(e.message)
}
}, 300),
[]
)
3. Web Worker
把 JSON 解析放到 Web Worker 中,避免阻塞 UI :
bash
// worker.ts
self.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data)
self.postMessage({ success: true, data: parsed })
} catch (e) {
self.postMessage({ success: false, error: e.message })
}
}
// main.tsx
const worker = new Worker('worker.ts')
worker.postMessage(largeJson)
worker.onmessage = (e) => {
if (e.data.success) {
setOutput(formatJson(e.data.data))
}
}
一些边界情况
实现过程中踩过的坑:
1. 循环引用
JSON.stringify 遇到循环引用会报错:
bash
const obj = { a: 1 }
obj.self = obj
JSON.stringify(obj) // TypeError: Converting circular structure to JSON
检测循环引用:
bash
function hasCircular(obj: any, seen = new WeakSet()): boolean {
if (obj && typeof obj === 'object') {
if (seen.has(obj)) return true
seen.add(obj)
return Object.values(obj).some(v => hasCircular(v, seen))
}
return false
}
2. 特殊字符
JSON 中的特殊字符需要正确转义:
bash
const json = '{"text": "Line1\nLine2"}' // \n 是换行
JSON.parse(json) // 正确解析
const json2 = '{"text": "Line1
Line2"}' // 直接换行会报错
编辑器组件需要处理这种情况,或者提示用户。
3. 大整数精度
JavaScript 的 Number.MAX_SAFE_INTEGER 是 2^53 - 1,超过这个值的整数会丢失精度:
bash
const json = '{"id": 9007199254740993}'
const obj = JSON.parse(json)
console.log(obj.id) // 9007199254740992,精度丢失
解决方案是用 JSON.parse 的 reviver 参数:
bash
function safeParse(json: string) {
return JSON.parse(json, (key, value) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
return String(value) // 转为字符串保留精度
}
return value
})
}
最终效果
基于以上思路,做了一个在线工具:JSON 格式化
主要功能:
- 格式化 / 压缩 / 校验
- 错误定位到行列号
- 树形视图折叠展开
- 支持最大 10MB 文件
代码实现不复杂,但把细节做好需要花些心思。希望这篇对你有帮助。
相关工具:JSON 差异对比 | JSON 转 CSV