调接口调到晚上十点,返回的 JSON 从四十行涨到九百行,产品说某个字段的值不对,我盯着两段几乎一模一样的文本翻来翻去,最后靠搜索才定位到嵌套第三层里那个active从true变成了"true"。这种时刻多了,就特别想要一个真正好用的 JSON 对比工具——不是把两坨文本贴进去做字符级高亮的文本 diff,而是看得懂 JSON 结构、知道哪个 key 少了、哪个数组元素挪了位置、哪个类型悄悄变了的语义级对比。这篇东西聊的就是我从零把一个在线 JSON 可视化对比工具搭出来的全过程,包括解析容错怎么设计、数组对齐为什么是最大的坑、大文件渲染怎么不把浏览器搞崩、以及上线之后被用户反馈一点点教会的那些细节。前端、后端、测试、运维,只要日常要跟 JSON 打交道,都能从里面挑到能直接抄的东西。
1. 先想清楚:这个工具到底解决什么问题
1.1 被文本对比坑过的几个真实瞬间
文本 diff 的算法基础是行级最长公共子序列,它假设"行"是有意义的最小单位。可 JSON 一旦被格式化成两空格缩进,一个嵌套对象就会拆成几十行,行与行之间的关系完全丢失。我遇到过最典型的一次:两个配置文件唯一的差别是某个对象里新加了一个 key,结果因为后面所有兄弟 key 的缩进层级都跟着变化,文本 diff 把下面三百多行全部标成了修改,一片红,真正新增的那一行反而淹没在里面。
还有更阴的一类问题——值看起来一样但类型不一样。1和"1",true和"true",null和"",在文本层面上差异极其细微,肉眼扫过去几乎是同一个东西,但在业务逻辑里它们能直接让判空逻辑失效、让枚举匹配失败。JSON 对比工具必须把类型作为一等公民展示出来,这一条后面我会反复提。
再就是顺序问题。JSON 规范里对象是无序的键值集合,但绝大多数序列化实现会保留插入顺序。拿文本 diff 去比两个对象,只要上游改了字段的生成顺序,整个对象都会被标记成"全变了",实际上语义完全相同。这类假阳性一多,工具就没人用了,因为大家不敢相信它的结论。
1.2 文本对比和语义对比的分界线
我把这个工具的核心价值定义得很清楚:它做的是结构化语义对比,不是字符对比。这两者的差别可以这么理解——文本对比像拿两张照片叠在一起看哪里像素不同;语义对比像把两台机器的零件拆下来,按型号一一对照,告诉你哪个零件换了、哪个零件多装了、哪个零件装反了。
具体到实现上,语义对比要经过这么几步:先把两段文本解析成语法树,然后递归遍历两棵树,在对象节点上按 key 求交集和差集,在数组节点上做元素对齐,最后把差异结果输出成一棵带标记的差异树。输出的形态也很关键,不能只说"这里不一样",得说清楚"是新增、删除、修改,还是类型变了",并且带上完整的访问路径,让人能直接复制去做搜索或者写断言。
这个定位决定了后面所有的技术选型。比如为什么我不做字符级 diff 兜底?因为一旦 JSON 解析失败,语义对比就无从谈起,这时候给用户一个"解析失败,第几行第几列"比给一个勉强的文本 diff 有用得多,能省掉大量"为什么结果怪怪的"的沟通成本。
1.3 谁在用:几类最典型的场景
做这个工具之前我先列了一遍使用场景,这个动作非常值得,因为它直接决定功能优先级。
| 场景 | 典型用户 | 核心诉求 | 对工具的要求 |
|---|---|---|---|
| 接口联调对账 | 后端、前端开发 | 确认两个环境返回是否一致 | 支持大文件、支持噪音字段过滤 |
| 配置变更审阅 | 运维、SRE | 上线前确认只改了预期的字段 | 路径清晰、支持导出结论 |
| 自动化测试快照 | 测试开发 | 快照为何失败、哪里变了 | 精确到类型、结果稳定可复现 |
| 数据迁移校验 | 数据工程 | 迁移前后数据是否等价 | 支持数组乱序比对、统计汇总 |
| 协议文档校对 | 技术写作、架构 | 两个版本的 schema 差异 | 只看结构不看值(结构模式) |
从这张表能看出来,"只看结构"和"只看值"其实是两个不同需求,前者用于 schema 演进分析,后者用于数据对账。最初我只做了全量对比,结果做配置审阅的同事每次都要被一堆无关的值变化干扰,后来才补上"忽略值、只看键结构"的开关。这个功能做起来简单,但它是被真实反馈逼出来的,不是拍脑袋想出来的。
2. 整体设计与技术选型拆解
2.1 为什么全流程都放在浏览器里跑
第一版我曾经想过做个服务端接口,把两段 JSON 传到后端算完再返回。想了半天放弃了,原因有三个,而且一个比一个硬。
第一是数据敏感性。运维同学拿来对比的往往是配置中心的导出内容,里面有数据库连接串、有内部服务地址、有各种密钥字段。哪怕我在页面上写十遍"不留存不记录",只要数据出过浏览器,信任成本就永远存在。纯前端方案在这件事上有天然优势,可以坦率地说"你的数据一行都没离开过这个标签页"。
第二是延迟。几百 KB 的 JSON 传到服务端再算再传回来,一来一回几百毫秒,加上解析和渲染,交互体感会明显发涩。放在本地算,解析加对比基本在几十毫秒量级,剩下的时间全花在渲染上,优化方向非常明确。
第三是部署成本。纯静态资源往任意静态托管上一扔就完事,没有后端就没有数据库、没有扩容、没有运维值班。对一个工具类小产品来说,这个取舍几乎没有悬念。
代价当然也有:大文件的性能天花板受限于用户设备,而且算法实现必须足够克制,不能随手写 O(n²) 的递归。后面讲性能的那一节基本都是围绕这个代价做补偿。
2.2 四层架构:解析、归一化、对齐、渲染
我把整个流程切成四层,层与层之间用明确的数据结构隔开,这样每一层都能独立测试、独立优化、独立换实现。
解析层负责把字符串变成语法树。这一层要处理的不只是标准 JSON,还有各种"看起来像 JSON 但其实不是"的输入——带注释的、带尾逗号的、单引号的、NaN 和 Infinity 的。用户从各种地方复制内容过来,这些情况一定会遇到。
归一化层负责消掉噪音差异。比如键顺序、数字的表示形式(1.0和1)、字符串里的空白、日期的时区表示。这一层是可配置的,因为有些场景下这些差异恰恰是要关注的。
对齐层是算法核心,负责递归比较两棵树,输出差异列表。对象按 key 配对,数组按位置或按指定 key 配对,这一层的每个决策都直接影响结果的准确度。
渲染层负责把差异列表变成人看得懂的东西。并排视图、内联视图、折叠、过滤、跳转,全在这一层。这一层最容易做出体验差距,也最容易被低估。
层与层之间我只传两种东西:解析后的树节点,以及带路径的差异记录。差异记录的结构我后面会详细给,这里先记住一个原则——差异记录必须是自解释的,任何一条记录单独拿出来都能说明"哪里、什么类型的变化、从什么变成什么",不依赖上下文。
2.3 关键依赖的选型取舍
依赖我控制得极少,整个项目就两个外部包,其余全手写。
解析器没有直接用JSON.parse,而是引入了一个宽松解析库。原因很直接:JSON.parse遇到注释、尾逗号、单引号会直接抛错,而用户从代码里复制配置片段时这些写法太常见了。宽松解析能把这些内容先吃下来,再把解析结果转成标准结构。这个决定带来的麻烦是错误定位要自己处理,宽松解析库给的错误信息往往不够精确,所以我又补了一层"预处理 + 逐行扫描"的兜底,用来给出"第几行第几列"的提示。
diff 算法没有引第三方包,因为主流的通用 diff 库都是为文本设计的,语义层面用不上。对象对比本质是集合运算,数组对比才需要用到序列对齐算法,这部分手写反而更可控,也能针对 JSON 的特点做剪枝。
渲染层用了虚拟滚动,但没有引重型表格组件。差异树的行高是固定的,虚拟滚动自己实现也就一百多行代码,引入一个组件库反而要处理样式覆盖、事件冲突这些额外成本。
提示:工具类项目的依赖要极其克制。每加一个包,未来某天你就得为它的破坏性更新付出一次迁移成本,而这类项目通常没有持续投入的预算。
3. 核心算法细节与实操要点
3.1 对象对比:三态键集合的运算
对象对比看起来最简单,实际上只要把边界情况列全,代码量也不小。核心逻辑是求两个 key 集合的三个区域:
- 交集:两边都有的 key,递归比较值。值相等则无差异,否则继续下钻或者标记为修改。
- 仅左侧:右边缺失,标记为删除。
- 仅右侧:左边缺失,标记为新增。
听起来三行代码,但坑在于"值相等"这个判断。JavaScript 里===没法用来比对象,JSON.stringify又依赖键顺序,两个语义相同的对象可能因为键序不同被判定为不等。我的做法是写一个deepEqual,递归比较,对象比较键集合的排序后逐项对比,数组比较长度和逐项内容,原始值用Object.is加类型判断。
类型判断要单独拎出来说。typeof null === "object"这个上古遗留问题必须先排除。另外1和1.0在解析后都是同一个数字,这个不算差异;但1和"1"必须算差异,而且要在结果里明确标出"类型变化",而不是笼统的"值变化"。我在差异记录里专门留了typeChanged标记,UI 上用不同的颜色和文案区分,实测下来这个细节被用户提到的次数非常多。
还有一个容易被忽略的情况:key 是数字字符串还是数字。JSON 里 key 永远是字符串,但有些用户会贴 JavaScript 对象字面量进来,这时候{1: "a"}的 key 在序列化后就变成了"1"。归一化层要统一处理,否则会出现"明明一样却报差异"的诡异结果。
3.2 数组对比:最大的坑在这里
如果说对象对比是送分题,数组对比就是这套工具里唯一有技术含量的部分。按索引逐位比较是最省事的做法,但它的假阳性率高得吓人。设想一个用户列表,上游在第一位插入了一条新记录,按索引对比的结果是后面所有元素全部"被修改",差异条目数量直接爆炸,用户看到一片红,什么都看不出来。
正确做法是先判断这个数组是不是"可对齐"的。我用了三个策略,按顺序降级:
策略一:按元素内的唯一键对齐。遍历两边数组,找出所有对象元素里都唯一存在的字段名作为候选键(常见的是id、uid、key、code、name),要求该字段在两侧数组内部都不重复、且类型是原始值。找到候选键后,以它为映射基准配对元素,配对成功的进入深度比较,未配对的标记为新增或删除。这个策略在真实数据上命中率最高,也是我最推荐优先实现的一条。
策略二:基于序列相似度的对齐。找不到唯一键时,退化到最长公共子序列。但直接上 LCS 有个现实问题——O(n×m) 的复杂度,一千个元素的数组就是一百万次比较,每一次还要做深比较,浏览器直接卡住。我的做法是先给每个元素算一个低成本指纹(对对象取排序后的键名拼接、对原始值直接用值),用指纹代替元素本身跑 LCS。指纹碰撞的概率低到什么程度?要两个对象的所有键名完全一致才会撞,撞了也只是对齐结果不理想,不会产生错误结论。这一步把复杂度压到了可接受范围。
策略三:按索引兜底。数组长度小于某个阈值(我设的是 20)且元素都是原始值时,直接按索引比。原始值数组没有"身份"可言,[1,2,3]和[2,3,1]到底是"全部修改"还是"乱了顺序",这个问题本身就没有标准答案,按索引比反而是最符合直觉的。
还有一个开关值得单独说:忽略顺序模式。数据迁移校验场景里,上游的查询结果顺序是不确定的,这时只能把数组当集合处理。实现方式是对每个元素算一个归一化后的稳定指纹,然后做多重集比较——求左侧有而右侧没有的、右侧有而左侧没有的。这个模式下不能做深度比较,只能报"多了/少了",功能上有损失,但在这个场景里够用。
3.3 差异记录的统一表达
所有对比策略最后都收敛到同一种数据结构,这是整个项目里我最满意的一个设计决策:
{ path: "$.data.list[3].status", // 完整路径 kind: "modified", // added | removed | modified | typeChanged leftValue: "enabled", rightValue: true, typeChanged: true, // 类型是否发生变化 depth: 5 // 层级,用于缩进渲染 }路径用的是 JSONPath 风格,$开头,对象用.key,数组用[index],key 里含特殊字符时转成['a.b']的形式。这么做的好处是结果可以直接复制出去当搜索条件,也能贴进断言里。我试过给每个差异项加一个"复制路径"按钮,使用率高得出乎意料。
差异列表必须是扁平的,不要嵌套。扁平结构让渲染层的虚拟滚动实现变得简单——固定行高,只渲染可视区内的行。代价是丢失了父子结构信息,所以我额外加了depth字段,渲染时按深度做缩进和连接线,视觉上依然是树的形态。这个"数据扁平、视觉成树"的组合,是性能和数据表达之间的一个好平衡点。
3.4 归一化:把噪音差异提前清掉
归一化层的每一个选项都对应一类真实痛点,我把它做成可勾选的开关,默认全关,让用户自己决定。
键排序:递归地把对象的键按字典序重排。开启后,纯粹的键顺序变化不再产生差异。这个开关在对比两个不同序列化实现产出的 JSON 时几乎必开。
数字规范化:1.0和1视为相同,1e3和1000视为相同。实现上要注意大数精度问题,JSON.parse对超出安全整数范围的数字会丢精度,所以我用宽松解析库时开启了原文字符串保留,对比时先比规范化后的数值,数值相等就过,避免1234567890123456789和1234567890123456790被判定为相同。
空白裁剪:字符串值两端的空格统一裁掉。这个开关争议最大——有些场景下尾部空格确实是有意义的,所以默认关闭,只在用户主动勾选后生效。
时间格式统一:识别常见的时间字符串并转成时间戳比较,2024-01-01T00:00:00Z和2024-01-01T08:00:00+08:00视为相同。识别逻辑我写得很保守,只匹配几种明确的时间格式,宁可不识别也不能误伤普通字符串。
注意:归一化开关的状态必须体现在结果里。我早期的版本没做这件事,用户开着"忽略键顺序"看到零差异,然后关掉发现一堆差异,就会怀疑工具算错了。现在结果区顶部会显示当前生效的规则,导出的报告里也会带上,避免结论被误读。
4. 手把手实现核心链路
4.1 解析层:容错与错误定位
解析入口的职责是"尽最大努力吃下输入,吃不下的部分给出精确位置"。流程分三步。
第一步做预处理,用字符扫描把注释、尾逗号处理掉。注释扫描要区分字符串内和字符串外,"http://a.com"里的双斜杠不是注释,这个坑我踩过。实现上用状态机,维护一个inString标记,遇到未转义的双引号就翻转状态,只在状态为 false 时才识别注释起始符。
第二步调宽松解析。解析成功后立刻做一次遍历,把所有的路径收集出来,顺便统计节点数量和最大深度——这两个数后面判断性能策略要用。
第三步是失败路径。解析库抛出的错误信息一般只有"位置 X 附近有语法错误",我把它换算成行列号,然后摘取该行内容,在错误列位置加一个^标记,拼成一段可读的提示。这个体验上的小改进,能减少大量"我就是复制粘贴的为什么报错"的疑问。
function describeParseError(text, pos) { const before = text.slice(0, pos); const line = before.split("\n").length; const col = pos - before.lastIndexOf("\n"); const source = text.split("\n")[line - 1] || ""; return `第 ${line} 行第 ${col} 列解析失败\n${source}\n${" ".repeat(col - 1)}^`; }另外还做了空输入和单侧输入的处理。只贴了一边的情况很常见,我允许单边解析并直接把整棵树标成"新增"或"删除",结果依然有意义,比弹个"请输入两段内容"的提示有用。
4.2 对比层:核心递归骨架
对比层的主函数是递归的,结构不复杂,关键是每个分支的判定顺序不能乱。
function diff(left, right, path, out, options) { if (Object.is(left, right)) return; const lt = typeOf(left); const rt = typeOf(right); if (lt !== rt) { out.push({ path, kind: "typeChanged", leftValue: left, rightValue: right, typeChanged: true }); return; } if (lt === "object") { const lk = Object.keys(left), rk = Object.keys(right); const keys = new Set([...lk, ...rk]); for (const k of keys) { const p = `${path}.${k}`; if (!(k in right)) out.push({ path: p, kind: "removed", leftValue: left[k] }); else if (!(k in left)) out.push({ path: p, kind: "added", rightValue: right[k] }); else diff(left[k], right[k], p, out, options); } return; } if (lt === "array") { diffArray(left, right, path, out, options); return; } out.push({ path, kind: "modified", leftValue: left, rightValue: right }); }几个细节值得说明。Object.is用来短路完全相等的原始值,比===多处理了NaN和-0两种情况,虽然 JSON 里不会出现NaN,但用户贴进来的东西什么都有。类型不同的判断放在最前面并且直接返回,不再向下钻——一个本来是对象的地方变成了字符串,继续递归没有任何意义。
diffArray是单独的函数,因为它要根据策略选择对齐方式,逻辑比对象复杂得多。它的内部流程是:先做长度差和类型分布的快速预检,尝试找唯一键,失败则走指纹 LCS,再失败按索引。每一步都要控制递归深度,我在参数里带了一个maxDepth,避免恶意构造的超深嵌套把栈打爆。
4.3 渲染层:并排和内联两种视图
结果展示我做了两种视图,切换按钮放在右上角。
并排视图把左右两棵结构树并排渲染,差异行整行高亮,左右滚动同步。这个视图适合逐行核对,视觉上更接近用户熟悉的代码编辑器。实现上的难点是行对齐——左右两侧的节点数量不一样,需要给缺失的一侧插入占位行,让语义上对应的行处在同一水平线上。
内联视图把差异合并成一棵树,修改的地方显示成"旧值 → 新值"的形式,新增和删除整行标记。这个视图更适合快速浏览全文,实际使用率比并排视图更高,尤其是差异数量少的时候。
两种视图共用同一份扁平差异数据,渲染层做的是把扁平数据重新组织成按路径排序的行列表。排序规则是按路径字典序,这样同一父节点下的差异会连续出现,符合阅读习惯。
行高固定 28px,容器高度内只渲染可视区上下各 20 行的缓冲。滚动事件用requestAnimationFrame节流,避免高频触发重排。实测一万条差异的情况下,滚动依然流畅。
4.4 性能处理:三个真正有效的动作
动作一:把对比放进 Web Worker。解析和对比都是纯计算,不涉及 DOM,塞进 Worker 后主线程完全不受影响,页面不会出现"点击后卡死两秒"的现象。Worker 和主线程之间只传差异数组,传输量可控。要注意的是 Worker 里不能用structuredClone之外的复杂对象,所以差异记录必须保持纯数据,不能带函数。
动作二:分段计算 + 进度反馈。节点数量超过阈值(我设的是五万)时,对比过程按顶层键分片执行,每片算完向主线程汇报一次进度。用户看到进度条在动,就不会认为页面死了。这个体验改进看起来是心理层面的,但它把"卡死"的负面感受转成了"在处理"的预期。
动作三:折叠优先。首次渲染时不展开深层节点,只渲染深度小于 3 的层级,其余折叠。用户点开哪个展开哪个。这个策略让首屏渲染的节点数从数万降到数百,白屏时间从两三秒降到百毫秒级。
提示:性能优化要先测量再动手。我早期花了一整天优化解析速度,后来打点发现解析只占总耗时的百分之八,渲染占百分之七十。方向错了,力气全白费。
5. 常见问题与排查速查
5.1 那些"明明一样却报差异"的坑
这类问题占了用户反馈的一大半,我整理成了一张速查表,基本覆盖了九成以上的场景。
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| 两份内容肉眼完全一致,报大量差异 | 键顺序不同,序列化实现差异 | 开启键排序归一化 |
| 数字明明一样却报差异 | 一方是数字一方是字符串 | 看结果里的类型变化标记 |
| 大整数被判定为相等 | 超出安全整数范围,精度丢失 | 保留原文字符串做对比 |
| 数组元素全部报修改 | 上游插入元素,按索引对齐 | 启用唯一键对齐 |
| 时间字段每次都报差异 | 时区表示不同 | 开启时间格式统一 |
| 中文字符串报差异但看不出区别 | 全角半角、不可见字符 | 增加字符码点对比视图 |
| 单侧输入时没有结果 | 只解析了一侧就中断 | 允许单边解析 |
第一条我要多说两句。很多用户不理解"键顺序不同为什么算差异",因为在他们看来两个 JSON 就是长得一模一样——实际上他们复制的是格式化后的文本,看起来顺序确实一样,但工具内部解析成对象后如果走了不同的路径,顺序信息可能已被重排。现在的做法是默认保留原始顺序对比,同时把"忽略键顺序"开关做得足够显眼。
5.2 浏览器卡死与内存暴涨的排查
大文件导致的崩溃主要有两个来源。
一个是渲染。十万条差异一次性生成 DOM,浏览器必然扛不住。解决办法就是前面说的虚拟滚动加默认折叠,两条一起上。我加了一个保险:差异条数超过两万时,不自动展开任何节点,并给出"差异过多,建议先过滤"的提示。
另一个是递归深度。有些用户贴进来的 JSON 嵌套几十层,递归容易把调用栈打穿。我在递归函数里带了深度计数器,超过阈值(我设的是 200)就停止下钻,直接在该节点标记"层级过深,已跳过"。这个处理方式比抛异常好,至少用户能看到前面所有层级的差异。
还有一个隐蔽的问题是循环引用导致的无限递归。标准 JSON 不会出现循环引用,但用户可能贴进来的是 JavaScript 对象打印结果,里面带[Circular]之类的标记。我的处理是在解析前做一次字符串检测,发现这类标记直接拒绝并提示,避免后续陷入死循环。
5.3 结果可信度的自我校验
工具最怕的是给出错误结论而用户不知道。我加了一层自校验:对比完成后,如果差异列表为空,就用两种不同策略再各算一遍(一种带归一化、一种不带),如果结果不一致,在页面顶部提示"当前配置下判定为一致,但存在被归一化规则忽略的差异"。这个提示救过好几次场,用户会主动去关掉某个开关验证。
反向的情况也有——差异列表非空但数量异常多,比如差异条数超过了节点总数的一半。这通常意味着对齐策略选错了,页面会提示"检测到大量差异,可能是数组对齐方式不适用,可尝试切换对齐策略"。把选择权交给用户,同时给出方向,比默默地给一个糟糕结果强得多。
6. 体验层:决定工具能不能被长期使用的细节
6.1 过滤与噪音开关的设计
真实使用中,用户往往只关心一小部分差异。比如对账的时候只关心status字段,其他字段的变化都是噪音。所以我做了路径过滤:输入一个路径前缀,只显示匹配的差异;再配一个反向模式,把匹配的排除掉。
过滤条件的语法我做得很简单,支持*通配符,比如$.data.*.status匹配任意层级的 status。没有上正则,是因为正则的学习成本对非开发用户太高,而通配符足够覆盖绝大多数需求。过滤框旁边会实时显示"匹配 N 条 / 共 M 条",这个计数让用户确认过滤是否生效。
另一个被低估的功能是只看结构。勾选后,对比只关心键的存在性和类型,不关心具体值。这个模式用于分析两个版本的接口返回结构差异,一开之后噪音全部消失,剩下的就是纯粹的 schema 演进。做接口文档维护的同事说这是他最常用的功能,比手工比对字段表快太多。
6.2 分享与复现:让结论可传递
工具类产品最容易忽视的一点是"结论怎么带出去"。用户算完差异,通常要做三件事:截图发群、复制某条路径、把结果贴进缺陷单。
前两个好办,第三个需要专门做。我提供了一个"导出报告"按钮,产出的是纯文本 Markdown 格式,包含对比时间、生效的归一化规则、差异总数、以及每条差异的路径和变化描述。之所以选 Markdown 而不是图片,是因为文本能贴进任何系统、能被搜索、能被 diff——有意思的是,这个导出报告本身也经常被再次丢进这个工具里对比,形成了一种奇怪的自举。
还有一个需求是"把这个对比分享给同事"。纯前端方案下没有后端存储,我的做法是把配置(归一化开关、过滤条件、视图模式)编码进 URL 的 hash 里,数据本身不传。同事打开链接后看到的是一模一样的配置,只需要把数据自己贴一遍。这个折中方案不完美,但它在"方便分享"和"数据不出本地"之间找到了一个能接受的位置,而且实现成本几乎为零。
注意:如果哪天真的接了存储分享,务必把"数据是否会上传"做成显式的、必须用户主动勾选的选项,默认不上传。这类工具的用户信任一旦破裂,很难修复。
6.3 一些很小的、但影响了使用体验的决定
最后记几个零散的细节,都是上线后被反馈推动改的。
默认值的选择。归一化开关全默认关闭、视图默认内联、默认折叠深度为 3。这三个默认值是反复调整的结果,早期版本默认开启键排序,导致用户第一次使用时看到"零差异"以为工具坏了。
空状态的文案。初始页面不放空表格,而是放两段示例 JSON 和一句"把你的数据贴进来"。用户第一次打开就知道要做什么,不需要读说明。示例内容我特意选了包含新增、删除、类型变化三种差异的小数据,让用户三秒内就能看懂工具在做什么。
键盘快捷键。只做了三个:Ctrl+Enter触发对比、Esc清空结果、Alt+↑/↓在差异之间跳转。第三个最受欢迎,差异多的时候靠它一行行过,比用鼠标滚轮快得多。
差异数量的上限提示。一开始没有上限,用户贴了两份各十几 MB 的数据进来,页面直接无响应。现在超过 20MB 会直接拦截并提示"内容过大,建议先裁剪",虽然粗暴,但避免了更差的体验。
我个人在实际维护这个工具的过程中最深的体会是:算法决定了结果对不对,而体验决定了有没有人用。最开始我把八成精力放在对比算法的准确性上,后来发现真正让用户留下来的,是路径能复制、结果能导出、大文件不卡死这些看起来不那么"技术"的东西。现在每次收到反馈,我先问的不是"结果算错了吗",而是"你当时想做什么"——答案往往指向一个我完全没考虑过的使用场景,而这些场景最后都变成了功能。