1. 整体设计:json-render 到底在解决什么问题
先说说我为什么会对这个标题感兴趣。做前端这些年,几乎每个项目里都会遇到"把 JSON 数据展示给用户"的需求:接口调试面板要格式化返回结果、低代码平台要用 JSON Schema 配置表单、日志系统要看结构化日志、数据大屏要把配置项可视化。早期我都是东拼西凑,今天用
JSON.stringify
加
<pre>
标签糊一个,明天找个现成库塞进去,等到需求变成"要支持折叠、搜索、高亮、复制路径"的时候,才发现自己根本没有一个统一的渲染方案。后来我决心沉淀一个自己的 JSON 渲染组件,标题就叫 json-render,核心目标就一句话:
任何 JSON 数据进来,都能以树形结构、可读性极强、交互顺手的方式展示出去
。
这里要先把"渲染"这个词拆清楚。在 JSON 场景里,渲染至少有三层含义:第一层是把 JSON 字符串变成好看的树形结构,配合缩进、高亮、折叠,这是调试面板和日志查看器最常见的形态;第二层是用 JSON Schema 驱动动态表单渲染,比如一个
{ "type": "string", "title": "用户名" }
的配置项,自动渲染成输入框,这是低代码平台的核心能力;第三层是把 JSON 转成代码文本,比如生成 TypeScript 类型定义、生成配置文件。json-render 这个名字虽然抽象,但绝大多数人搜索它,指的是第一层——树形展示。我做的也是这一层为主,但在设计上预留了 schema 渲染的扩展空间。
从"能用"到"好用",我给自己列了一份需求清单,刚开始很简单:
- 格式化:把压缩后的 JSON 字符串按层级缩进展示
- 高亮:key、字符串、数字、布尔值、null 用不同颜色区分
- 折叠:对象和数组默认只展开两层,支持点击展开收起
- 大数据量:几万甚至几十万个节点的 JSON 不能白屏
做完基础功能后,又追加了搜索定位、路径复制、下载导出这些高频操作。你可能会说,这不就是 react-json-view 或者 vue-json-viewer 已经做过的吗?确实,但这些库在某些场景下有天然痛点:一是体积大,一个组件打包下来动辄几百 KB;二是定制难,几乎每一个细节都要通过 props 传参控制,项目一多就成了配置地狱;三是新框架支持慢,公司技术栈从 React 切到 Vue 再切到小程序,每次都要重找方案。与其被第三方库绑架,不如花小半天时间写一个贴合自己业务、几百行就能搞定核心逻辑的组件,这也是 json-render 这个项目最根本的出发点。
2. 核心细节解析与实操要点
2.1 数据准备:JSON.parse 之前和之后,你必须要处理的边界
很多人写 JSON 渲染组件,上来就
JSON.parse(str)
然后递归渲染,看起来没问题,但真实生产环境的 JSON 远比你想象的脏。我拿到的输入五花八门:
-
有的后端返回的是字符串,有的直接返回 JavaScript 对象,还有的字符串里混着
NaN、Infinity -
有的 JSON 里嵌套了
Date类型,JSON.stringify之后变成了 ISO 字符串,展示出来用户看不懂 -
有的是多层嵌套互相引用的对象,比如
a.b引用了a本身,直接递归遍历会栈溢出 -
有的服务端把大整数作为
id返回,比如{"id": 7300229962095714305},JSON.parse会丢失精度,变成7300229962095714000
所以在进入渲染阶段之前,数据归一化是第一步。我采用的做法是先判断输入类型,如果是字符串就先 parse,parse 失败就当作纯文本展示并给出错误提示;如果是对象或数组,先做一次深度遍历,把所有非标准 JSON 类型都转成可展示的格式。这里有两段核心代码,值得直接抄作业:
function normalizeJSON(input) {
if (typeof input === 'string') {
try {
return JSON.parse(input);
} catch (e) {
return { error: e.message, raw: input };
}
}
return input;
}
function walkAndTransform(node, key = '', seen = new WeakSet()) {
if (node === null || typeof node !== 'object') {
// 处理基本类型边界
if (typeof node === 'bigint') return { type: 'bigint', value: node.toString() };
if (numberIsNaN(node)) return { type: 'nan', value: 'NaN' };
return { type: typeof node, value: node };
}
if (seen.has(node)) {
return { type: 'circular', value: '[Circular]' };
}
seen.add(node);
if (node instanceof Date) {
return { type: 'date', value: node.toISOString() };
}
if (Array.isArray(node)) {
return node.map((item, index) => walkAndTransform(item, String(index), seen));
}
const result = {};
for (const k of Object.keys(node)) {
result[k] = walkAndTransform(node[k], k, seen);
}
return result;
}
注意这段代码里的
WeakSet
,它的作用就是检测循环引用。我踩过的坑是,一开始用
Set
存储已访问对象,后来发现递归过程中如果遇到同一个对象出现在多条不同路径上(比如共享引用),就会被误判成循环引用。改用
WeakSet
后,因为它是弱引用,不干扰垃圾回收,而且只在当前递归栈里生效,只要在递归入口添加、递归出口不需要手动删除,就能准确区分"真循环"和"共享引用"。至于大整数精度问题,光靠
JSON.parse
解决不了,我后面会在常见问题那一节详细说处理方案。
数据归一化之后,我还会做一次扁平化预处理。这个操作一方面用于搜索功能(后面会讲),另一方面用于性能优化——当数据量特别大时,我可以基于扁平化后的节点列表做虚拟滚动,而不是每一次都从树根开始递归。
2.2 渲染核心:递归组件与折叠状态管理
JSON 树形渲染最直观的方案就是递归组件。以 React 为例,我会定义一个
JsonNode
组件,它根据节点类型决定渲染策略:对象节点渲染一个花括号,内部遍历子节点;数组节点渲染方括号,内部渲染元素;基本类型节点直接展示值。这个方案足够简单,但真正决定组件好不好用的,是折叠状态怎么管理。
多看几个现成库,你会发现很多人把展开状态直接存在组件内部,比如
useState(new Set())
存展开节点的 id。这在静态数据下没问题,但一旦数据刷新(比如接口轮询、websocket 推送),所有状态归零,用户看到的树瞬间全部折叠,体验极其割裂。我的做法是把展开状态提升到组件顶层,用
path
作为唯一标识。每个节点在树中的完整路径是由 key 组成的数组,比如
['data', 'users', 0, 'name']
,展开状态就是一个
Set
,里面存的是这些 path 的某种序列化字符串(比如
data.users.0.name
)。
用 path 而不是用索引或 id 的好处有三个:
- 数据刷新后,只要结构没变,展开状态就还在
- 用户点击"展开全部"、"收起全部"、"展开一层"这些批量操作时,只需改一个全局 Set,非常直观
- 展开状态天然支持路径复制——用户想复制某个节点的路径,直接把当前 path 拿出来 join 一下就是
折叠交互本身也有几个细节。一是默认展开层级要可配置,我一般默认展开两层,超过两层的节点显示
...
而不是全部渲染,这样既保证用户第一眼能看到数据结构全貌,又不会因为渲染全部节点造成卡顿。二是折叠动画不要过度设计,展开收起瞬间的高度过渡可以用 CSS Grid 的
grid-template-rows: 0fr -> 1fr
实现,几行代码就能有流畅效果,比硬算高度的 JS 动画方案稳定得多。
function JsonNode({ node, path, depth, expandSet, onToggle }) {
const type = getNodeType(node);
if (type === 'object' || type === 'array') {
const isOpen = expandSet.has(pathKey(path));
const bracket = type === 'object' ? ['{', '}'] : ['[', ']'];
const keys = type === 'object' ? Object.keys(node) : node.map((_, i) => i);
return (
<div className="json-node">
<span
className={`json-toggle ${isOpen ? 'open' : ''}`}
onClick={() => onToggle(pathKey(path))}
>
{isOpen ? '▾' : '▸'}
</span>
<span className="json-bracket">{bracket[0]}</span>
{isOpen ? (
<div className="json-children">
{keys.map((key) => {
const childPath = [...path, key];
return (
<div key={key} className="json-line" style={{ paddingLeft: depth * 16 }}>
<JsonNode
node={type === 'object' ? node[key] : node[key]}
path={childPath}
depth={depth + 1}
expandSet={expandSet}
onToggle={onToggle}
/>
</div>
);
})}
</div>
) : (
<span className="json-collapsed">…</span>
)}
<span className="json-bracket">{bracket[1]}</span>
</div>
);
}
return <span className={`json-value json-${type}`}>{String(node)}</span>;
}
这段代码刻意省略了 key 渲染那一层,实际项目中 key 应该单独一行,方便对齐和复制。还有一点要特别强调: 不要在递归组件里定义新组件 。我有一次把 key 渲染写成内部的箭头函数组件,结果每次父组件刷新,React 都认为"组件的类型变了",整个子树全部重新挂载,一万个节点的 JSON 直接卡爆。这个教训让我再也不敢在递归渲染里玩花活。
2.3 语法高亮:不要用 innerHTML 拼字符串这条路
JSON 高亮看起来是个小事,无非是把 key 染成蓝色、字符串染成绿色、数字染成橙色。但实现方式选错,后面全是坑。
最粗暴的方案是正则匹配加
innerHTML
拼 HTML 字符串:
const html = jsonString.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match) => `<span class="json-${getClass(match)}">${match}</span>`
);
node.innerHTML = html;
这个方案代码量最少,视觉效果也够,但我吃了大亏:第一,如果 JSON 字符串里包含
<script>
之类的 HTML 标签,
innerHTML
会把它当作真实 DOM 插入,直接触发 XSS 漏洞;第二,正则匹配 JSON token 的准确率有限,复杂字符串里的转义字符、Unicode、嵌套引号容易匹配错;第三,无法与折叠交互挂钩——你只能展示纯文本,没法做到"点击对象折叠"。
正确的做法是走 AST 路线:解析 JSON 时生成一个 token 流,每个 token 带类型(key、string、number、boolean、null、punctuation),渲染的时候根据类型加 CSS 类名。实现上不用自己造 JSON parser,可以使用
jsonc-parser
这类轻量库,它本身就是 VSCode 团队维护的,打包体积只有十几 KB,能准确处理 JSONC(带注释的 JSON)和所有边界情况,返回的 token 结构非常清晰:
import { parseTree, NodeType } from 'jsonc-parser';
function parseToTokens(jsonText) {
const tree = parseTree(jsonText);
const tokens = [];
walk(tree, (node, parent) => {
tokens.push({
type: mapNodeType(node.type),
value: jsonText.slice(node.offset, node.offset + node.length),
offset: node.offset,
length: node.length,
parent: parent ? parent.type : null,
});
});
return tokens;
}
token 化之后,渲染就非常灵活了。因为每个 token 都带 offset 和 length,我可以直接把原始 JSON 字符串按 token 切割成数组,每个 token 渲染成带类名的
<span>
。如果要支持点击折叠,就把对象起始 token 和结束 token 的 offset 记下来,点击时根据 offset 计算中间区域,再决定显示还是隐藏。这样高亮和折叠就天然协同了,不再需要两套逻辑。
有一点经验分享:
颜色方案要符合直觉
。key 用偏冷的蓝色或紫色,字符串用绿色或暖橙色,数字用亮橙色,布尔值和 null 用红色系,符号(逗号、冒号、括号)用灰色。不要为了炫酷搞深色背景加霓虹色,用户是来看数据的,不是来看霓虹灯的。我最后定稿的配色是浅色主题下
#005cc5
的 key、
#22863a
的字符串、
#b35900
的数字,这个方案直接抄的 GitHub 代码高亮思路,实际使用反馈很好。
3. 实操过程与核心环节实现
3.1 完整实现一个最小可用的 json-render 组件
纸上谈兵太多,直接上一份能跑的完整实现。我以 React 为例,但核心逻辑可以平移到 Vue、小程序或原生 JS。组件包含数据归一化、token 解析、递归渲染、折叠交互、搜索高亮五个部分。下面是完整的组件代码骨架:
import React, { useMemo, useState, useCallback, useRef } from 'react';
import { parseTree, NodeType } from 'jsonc-parser';
import './JsonRender.css';
const DEFAULT_EXPAND_DEPTH = 2;
function JsonRender({ data, defaultExpandDepth = DEFAULT_EXPAND_DEPTH }) {
const [expandMap, setExpandMap] = useState(() => new Set());
const [searchTerm, setSearchTerm] = useState('');
const [searchMatches, setSearchMatches] = useState([]);
const [activeMatchIndex, setActiveMatchIndex] = useState(-1);
const containerRef = useRef(null);
// 1. 数据归一化
const normalizedData = useMemo(() => {
if (typeof data === 'string') {
try {
return JSON.parse(data);
} catch {
return { __error__: data };
}
}
return data;
}, [data]);
// 2. token 化与扁平化,用于渲染和搜索
const { tokens, flatNodes } = useMemo(() => {
if (typeof data !== 'string') {
// 对象类型无法 token 化,直接转字符串再解析
const jsonText = JSON.stringify(data, null, 2);
const tree = parseTree(jsonText);
// 遍历生成 token 列表 & 扁平节点列表
const tokenList = [];
const flattenList = [];
walkTree(tree, tokenList, flattenList, [], 0);
return { tokens: tokenList, flatNodes: flattenList };
}
const tree = parseTree(data);
const tokenList = [];
const flattenList = [];
walkTree(tree, tokenList, flattenList, [], 0);
return { tokens: tokenList, flatNodes: flattenList };
}, [data]);
// 3. 折叠切换
const toggleExpand = useCallback((pathKey) => {
setExpandMap((prev) => {
const next = new Set(prev);
if (next.has(pathKey)) next.delete(pathKey);
else next.add(pathKey);
return next;
});
}, []);
// 4. 搜索
const handleSearch = useCallback((term) => {
setSearchTerm(term);
if (!term) {
setSearchMatches([]);
setActiveMatchIndex(-1);
return;
}
const lower = term.toLowerCase();
const matches = flatNodes.filter((node) =>
String(node.key).toLowerCase().includes(lower) ||
String(node.value).toLowerCase().includes(lower)
);
setSearchMatches(matches);
setActiveMatchIndex(matches.length > 0 ? 0 : -1);
}, [flatNodes]);
// 5. 确保当前匹配项可见
const jumpToMatch = useCallback((index) => {
if (index < 0 || index >= searchMatches.length) return;
const target = searchMatches[index];
const pathKeyArr = target.path;
// 展开所有祖先节点
setExpandMap((prev) => {
const next = new Set(prev);
for (let i = 0; i <= pathKeyArr.length; i++) {
next.add(pathKeyArr.slice(0, i).join('.'));
}
return next;
});
setActiveMatchIndex(index);
// 滚动到目标位置
requestAnimationFrame(() => {
const el = containerRef.current?.querySelector(`[data-path="${pathKeyArr.join('.')}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}, [searchMatches, searchTerm]);
// 6. 渲染 token 流
const renderTokens = useCallback(() => {
return tokens.map((token) => {
const isCollapsed = shouldCollapse(token, expandMap);
return (
<span
key={token.offset}
data-path={token.pathKey}
className={`json-token json-${token.type} ${isCollapsed ? 'hidden' : ''}`}
onClick={() => {
if (token.type === 'object' || token.type === 'array') {
toggleExpand(token.pathKey);
}
}}
>
{isCollapsed ? '…' : token.text}
</span>
);
});
}, [tokens, expandMap, toggleExpand]);
return (
<div className="json-render">
<div className="toolbar">
<input
type="text"
placeholder="搜索 key 或 value,回车跳转"
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') jumpToMatch(activeMatchIndex + 1);
}}
/>
<span className="search-info">
{searchMatches.length ? `${activeMatchIndex + 1}/${searchMatches.length}` : ''}
</span>
<button onClick={() => setExpandMap(new Set())}>收起全部</button>
<button onClick={() => setExpandMap(new Set(flatNodes.map((n) => n.pathKey)))}>
展开全部
</button>
</div>
<div className="json-container" ref={containerRef}>
{normalizedData?.__error__ ? (
<div className="json-error">解析失败:{normalizedData.__error__}</div>
) : (
<pre>{/* 这里用虚拟 token 渲染替换 innerHTML */}
{renderTokens()}
</pre>
)}
</div>
</div>
);
}
这个组件的核心思路可以拆解成三层理解:
第一层是数据层,包括归一化和 token 化。数据层输出的 tokens 和 flatNodes 是两个关键产物,前者是渲染层的数据来源,带有类型、文本、偏移量、pathKey 等元信息;后者是搜索和跳转的数据来源,每个扁平节点直接指向它在树中的路径。两套数据结构独立但共享 pathKey,这是整个组件能高效工作的基础。
第二层是视图层,直接基于 token 渲染。为什么基于 token 而不是基于递归组件渲染?因为 token 渲染天然支持部分节点显示/隐藏而不影响其他节点,还支持全文搜索高亮时对 token 增强样式。递归组件渲染虽然直观,但在 search 高亮、折叠状态跨层同步这些场景下很别扭。
第三层是交互层,包括折叠、搜索、跳转、快捷键。交互层只依赖 expandMap 和 searchMatches 两个状态,前者是一个 Set,后者是数组。用户操作都归结为对这两个状态的修改,逻辑边界清晰。
实际使用中你会发现,这个组件还有两个可以顺手实现的增强功能。一个是复制路径:右键任意 token,弹出一个 ContextMenu,显示
['data', 'users', 0, 'name']
这种数组格式和
data.users[0].name
这种 JSONPath 格式,点击一键复制。实现思路是每个 token 的 pathKey 已经挂在
data-path
属性上,直接读取就行。另一个是数据导出:工具栏加一个"下载完整 JSON"按钮,把原始字符串转成 Blob 下载。
3.2 大数据量性能优化:虚拟滚动和时间分片
JSON 渲染组件最大的敌人是数据量。调试线上故障时,一个接口返回几百 KB 甚至几 MB 的 JSON 完全是家常便饭。我第一次拿 10 万节点的 JSON 测自己的组件时,页面直接卡死 5 秒,控制台报了一堆 "Long task" 警告。这个问题不解决,组件根本谈不上可用。
先说清楚卡顿的根源。递归渲染 10 万个节点,意味着创建 10 万个 React 组件实例,每个实例都要进行 props 传递、VDOM diff、DOM 挂载,这个过程是同步的,浏览器主线程被占满,用户看到的就是白屏和假死。我试过四个优化手段,效果差异巨大:
| 优化手段 | 实现思路 | 100 个节点 | 10,000 个节点 | 100,000 个节点 | 优缺点 |
|---|---|---|---|---|---|
| 懒渲染 | 默认只渲染前三层 | 毫秒级 | 毫秒级 | 毫秒级 | 显示不全,多层级结构无法浏览 |
| 时间分片 | 把渲染任务拆成多块,每块 5ms,中间让出主线程 | 毫秒级 | 200ms | 1.5s | 不卡白屏但总耗时没减少 |
| 虚拟滚动 | 只渲染可视区内的节点 + 固定行高 | 毫秒级 | 50ms | 120ms | 数据量大时唯一可行方案 |
| Web Worker 解析 | 用 Worker 线程做 JSON.parse 和 token 化 | 毫秒级 | 80ms | 400ms | 改善解析耗时,但对渲染压力无帮助 |
最终我采用的组合拳是: Web Worker 负责解析和 token 化,主线程只负责虚拟滚动渲染 。解析是 CPU 密集任务,放主线程会阻塞其他 JS 执行,放到 Worker 后主线程几乎无感。虚拟滚动则解决了渲染上万个 DOM 节点的问题,只渲染可视区域内大约 50~100 个节点,滚动时动态替换。
虚拟滚动的实现有一个关键前提:行高必须固定。树的每个叶子节点渲染成一个固定高度的行(我用的 24px),内部对象的嵌套缩进只是 padding-left 的变化,不影响行高。这样整棵树的总高度 = 节点总数 × 行高,滚动条位置就能直接换算成"当前可视区域对应哪些节点范围"。
function VirtualJsonTree({ flatNodes, totalHeight, rowHeight = 24, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / rowHeight);
const endIndex = Math.min(flatNodes.length, startIndex + Math.ceil(containerHeight / rowHeight) + 1);
const visibleNodes = flatNodes.slice(startIndex, endIndex);
return (
<div
style={{ height: containerHeight, overflowY: 'auto' }}
onScroll={(e) => setScrollTop(e.target.scrollTop)}
>
<div style={{ height: flatNodes.length * rowHeight, position: 'relative' }}>
{visibleNodes.map((node, index) => (
<div
key={node.pathKey}
style={{
position: 'absolute',
top: (startIndex + index) * rowHeight,
left: 0,
right: 0,
height: rowHeight,
}}
>
<JsonRow node={node} />
</div>
))}
</div>
</div>
);
}
这里面有个容易忽略的点:扁平化节点列表的顺序必须与树的深度优先遍历顺序一致,否则滚动滚到一半,缩进突然从深层跳回浅层,视觉上会非常奇怪。深度优先遍历时,一个对象或数组本身算一个节点(用来显示折叠图标和括号),它的子节点紧随其后排列,这样虚拟滚动的顺序就天然和树形结构对应了。
时间分片方案我没有完全抛弃,而是把它用在了"搜索高亮"这个场景。当用户输入搜索关键字后,如果匹配项有几千个,我不会一次性全部高亮,而是每帧处理 500 个匹配项,处理完就主动
requestIdleCallback
让出主线程,避免输入过程中出现明显卡顿。实际体验下来,输入响应变得很跟手。
3.3 搜索、高亮与路径复制:高频交互的三个实操样例
搜索功能是 JSON 渲染组件最容易做烂的功能,常见的问题是:搜索匹配项太少(只搜 key 不搜 value)、匹配项太多(把嵌套祖先节点的关键词也算进去)、跳转后高亮位置错乱。我的设计原则是"Duck Typing 式搜索"——不管请求的是 key 还是 value,统一把对象转成可读字符串再匹配,这样用户搜
id
能匹配到 key 为
id
的字段,搜
730022
能匹配到 value 包含这串数字的字段。
具体实现参考前面组件里的
handleSearch
。这里要补充一个细节:
搜索匹配的优先级应该区分权重
。key 匹配的权重高于 value 匹配,完全匹配高于模糊匹配。我维护了一个匹配项数组,每一项都带
score
,排序后按分数从高到低展示。用户输入
status
时,如果数据中有
data.status
这个字段,它应该排在最前面,而不是淹没在其他 value 恰好为 "status" 的记录里。
搜索跳转与折叠状态联动是容易踩坑的点。假设匹配到的节点处于折叠状态,直接 scrollIntoView 会把用户带到错误位置(折叠状态下内容根本没渲染出来)。我在
jumpToMatch
里做的处理是:跳转前先把匹配节点所有祖先的 pathKey 都加入 expandMap,强制展开祖先链。这一步必须在滚动之前完成,否则
requestAnimationFrame
回调执行时 DOM 还没更新。
路径复制看起来简单,实际也有小设计:用户复制的路径要区分两种格式。一种是程序友好格式,比如
data.users[0].name
,方便在代码里用 lodash
_.get
取数;一种是人类友好格式,比如
data → users → 0 → name
,方便用户在口头沟通时说清楚"哪个字段"。我做了两个按钮,分别复制两种格式,实测用户反馈很好。
关于搜索高亮,还有个独家建议:匹配文本的高亮颜色不要用红色或黄色,因为 JSON 原始高亮里数字是橙色、布尔是红色,再加一个高亮色容易和语法色打架。我最后选了淡蓝色的背景色加粗字体,同时给匹配元素的祖先链上添加向右淡出的浅色波纹,用户一眼就能看到"这个匹配项在哪个层级下面"。
4. 常见问题与排查技巧实录
4.1 高频问题排查速查表
实战开发 json-render 组件的过程中,我记录了大量问题案例,整理成一份速查表,基本覆盖了其他人在自己实现 JSON 渲染时能遇到的大部分问题:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| JSON.parse 抛异常 | 数据含尾逗号、单引号、注释 | 先尝试 JSON.parse,失败后用 jsonc-parser 解析 |
渲染后字符串中的
<script>
被浏览器执行
| 使用 innerHTML 注入 | 改用 token 化渲染,或先做 HTML 转义 |
| 100 万节点卡死 | 一次性渲染全部 DOM | 虚拟滚动 + Web Worker 解析 |
| 折叠后展开,子节点全部重新渲染 | 递归组件中定义了内部组件 | 将子节点渲染提升为独立组件常量 |
| 搜索无结果 | 只搜 key 没搜 value | 把 key 和 value 都纳入搜索范围 |
| 大整数显示错误 | 服务端返回超过 Number.MAX_SAFE_INTEGER 的数 | 使用 JSON.parse 的 reviver 配合正则或 BigInt 库 |
对象 key 里包含
.
导致 path 冲突
| 展开状态用点号拼接 key | 改用数组 toString 或 JSON.stringify 作为 pathKey |
| 循环引用导致栈溢出 | 对象互相引用 | WeakSet 检测,渲染成 [Circular] |
| 高亮颜色和折叠状态不联动 | 高亮和折叠用两套 DOM | 统一基于 token 渲染 |
| 数据刷新后折叠状态全部丢失 | 展开状态存在组件内部 | 提升到顶层,用 pathKey 做稳定标识 |
| 不同层级相同 key 搜索跳转错误 | 只记录 key 没有记录 path | 维护 pathKey 数组,跳转前展开祖先链 |
| pre 标签里用 CSS 缩进失败 | 普通空格在 pre 中被合并 | 使用 padding-left 代替空格缩进 |
这份表最能说明问题的其实是"pre 标签缩进"这一行。我最初渲染 JSON 时,树形缩进用的是在字符串前面拼接空格,然后放进
<pre>
标签。结果发现,CSS 默认会合并连续空格,除非给 pre 设置
white-space: pre
。这个坑特别隐蔽,因为本地开发时浏览器默认样式刚好有
white-space: pre
,换到某个重置样式表的环境就暴露了。后来我干脆改成每个节点外层
div
的
padding-left
来做缩进,彻底告别空白字符对齐的不可控性。
4.2 我印象最深的三个实战坑
第一个坑是大整数精度丢失。有一天测试反馈,渲染的订单 ID 最后几位神秘变成了 0。我排查了半天,发现问题出在
JSON.parse
环节:JavaScript 的 Number 类型只支持 53 位二进制精度,超过这个范围的整数会被四舍五入。服务端返回了 64 位整数作为订单号,比如
{"id": 7300229962095714305}
,浏览器解析后变成了
7300229962095714000
。这不仅影响展示,用户复制这个 id 去查接口,查到的就是错误数据。这里的解决思路是使用
json-bigint
库,它能把大整数转成字符串,展示时保留原始精度。要注意的是,
json-bigint
默认会把所有整数都转成 BigNumber 对象,使用起来不直观,我一般只对超过安全整数范围的数字做转换,其他保持原样。
第二个坑是"展开全部"按钮引发的内存飙升。我最初实现的"展开全部"是遍历所有节点,把每个 pathKey 都加入 expandMap。这个操作本身没问题,问题是展开后所有节点全部渲染,一个 20 万节点的 JSON 直接渲染了 20 万个 DOM 节点,内存瞬间飙到 1GB+。后来我把"展开全部"改成了"只展开前五层",并且配合虚拟滚动——即使要全部展开,物理上仍然只渲染可视区。如果用户确实要遍历全部数据,我给按钮加了一个二次确认:"展开全部将渲染 N 个节点,可能造成卡顿,是否继续?"这是从浏览器崩溃的血泪教训里总结出来的妥协方案。
第三个坑是高亮和折叠状态不同步。最初我分别实现了高亮组件和折叠组件,高亮用 token 流渲染,折叠用递归组件渲染。当用户折叠某个对象时,高亮区域还在显示那个对象内部的子节点文本。我调试了很久才发现,两个组件各自维护了一套"什么应该显示"的逻辑。后来我把两个功能合并成一套统一基于 token 的状态机:每个 token 在渲染前先判断它是否属于某个折叠的祖先区间,如果属于就直接不渲染。这个"祖先区间判断"要维护一个闭区间集合,每次渲染时二分查找,性能开销可以接受。
4.3 什么情况下不要自己造轮子
最后说点劝退的话。我在这个项目上花了大量时间,但对大多数团队来说,直接用现成的库可能是更理性的选择。我整理了一个选型参考:
-
如果只是渲染几千节点的 JSON,
react-json-view或者vue-json-viewer开箱即用,没必要造轮子 - 如果数据量动辄几十万节点,或者有严格的交互定制需求(比如低代码配置器),自研一个基于 token 渲染 + 虚拟滚动的组件更划算
-
如果要做的是 JSON Schema 驱动的动态表单,直接上
react-jsonschema-form或formily,千万不要从 JSON 渲染器开始硬憋
自研组件的边界在哪里?我认为有一个判断标准:如果你只是在"展示"这层需求上打转,现成库完全够用;如果你需要把展示、交互、状态管理、性能和业务逻辑深度耦合,那自研才有价值。json-render 适合的定位是团队内部的基础设施组件,像公共库一样被多个项目复用,而不是某个业务页面的临时方案。
5. 拓展方向:从展示到 Schema 驱动的升级路径
json-render 完成树形展示后,我发现它天然具备向"Schema 驱动渲染"升级的潜力。很多业务场景不只是把 JSON 展示给用户看,而是希望用户基于 JSON 结构进行配置。举一个真实例子:运营平台的后台配置项是一大段 JSON,产品经理希望运营人员能直接在线修改某些字段的值,而不是每次提交 JSON 字符串等开发改完再发版。这时候需要的就不再是"只读展示",而是"可编辑渲染"。
升级思路不复杂:json-render 的 token 流结构已经包含了每个节点在原始 JSON 中的位置(offset、length),如果要支持编辑某个字段,只需把基本类型 token(字符串、数字、布尔)对应的
<span>
替换成
input
或
textarea
,修改后根据 offset 把新值替换回原始字符串,再重新 token 化。当然,这个方向做深了就是低代码引擎的表单渲染器,比如根据
{ "type": "string", "enum": ["a", "b"] }
渲染下拉框。我把这个方向和 json-render 的关系定位成"展示是理解结构的基础,编辑是控制结构的自然延伸",感兴趣的话可以沿着"JSON Schema → 控件映射 → 表单联动"这条线继续做下去。
另一个拓展方向是 JSON Diff 展示。接口调试时,对比两次请求的响应差异,如果用 diff 算法结合 json-render 渲染,可以让顺序变化、值变化、新增删除的字段一目了然。实现上只需要在 token 化的同时跑一遍 diff 算法,给每个 token 附加上增删改的标记,渲染时给不同标记应用不同背景色即可。这部分我现在只在内部工具里实验过,效果不错,后续可以单独写一篇展开讲。
6. 写在最后:我的几点实操心得
这个 json-render 组件从第一版到现在迭代了四个大版本,说几个我花了不少代价才想明白的经验。
第一, JSON 渲染的核心不在渲染,而在数据结构 。整套组件能不能流畅跑起来,取决于数据归一化、token 化、扁平化这几层基础结构设计得是否合理。递归渲染是最后一步,是最不重要也最容易替代的环节。
第二, 永远不要用 innerHTML 去拼 JSON 字符串 。这句话我说了无数次,但每次看到新的开发者在自己的项目里踩一遍这个坑,我都替他们心疼那一晚上的调试时间。XSS 漏洞在 JSON 渲染场景里不算高风险,因为数据来源主要是后端接口,但一旦出现,就是安全事故级别的危害。
第三, 性能优化的第一准则不是优化而是防退化 。虚拟滚动和时间分片用起来很简单,但如果你没在组件设计初期预留 pathKey、offset 这些元数据字段,后期再补会伤筋动骨。我每次给人评审代码都会问一句:"你的数据结构能不能支撑你未来的性能优化?"大多数情况下答案是否定的。
第四, 交互细节决定工具的上限 。搜索跳转、路径复制、折叠记忆这些功能单个看起来都不起眼,但合在一起,用户才会觉得"这个工具好用"。我自己用 json-render 检查线上问题的时候,最常用的操作就是搜一个关键 ID,点跳转,然后沿着展开的路径一路看下去。没有路径复制功能之前,每次都要手动在地址栏里拼 key,累得要死。
最后分享一个小技巧:在线调试 json-render 时,打开浏览器控制台,在滚动容器上执行一句
console.log(JSON.stringify(flatNodes.slice(0, 20), null, 2))
,能直观看到虚拟滚动到底渲染了哪些节点、缩进和路径是否正确。这个排查手段帮我快速定位了好几次折叠和高亮不同步的问题。如果你也在折腾类似组件,不妨试用一下这个方法。
本文深入解析json-render组件的核心设计,聚焦JSON树形结构的高效渲染与性能优化。重点涵盖数据归一化、基于AST的语法高亮、递归组件的折叠状态管理(以path为键)、扁平化预处理,以及针对大数据量的虚拟滚动实现——通过Web Worker解析+固定行高+深度优先扁平序列保障滚动一致性。同时介绍搜索高亮、路径复制等高频交互的协同实现机制。
135

被折叠的 条评论
为什么被折叠?



