JSON 结构比较实战:正确区分缺失、null 与特殊键名

举报
yd_242486459 发表于 2026/10/23 10:43:46 2026/10/23
【摘要】 对象键顺序、数组顺序、字段缺失与 null 应如何比较?本文给出可运行的 JavaScript 结构差异比较器,解析特殊键名和路径表示,并用六组本地断言验证规则及其适用边界。

调试接口时,比较两份 JSON 常常从一行代码开始:JSON.stringify(a) === JSON.stringify(b)。这个写法比较的是序列化结果,不一定符合我们想表达的数据规则。对象属性插入顺序不同,可能产生不同文本;如果为了消除差异而把所有数组排序,又会丢掉数组本身的顺序语义。

在「JSON 口袋工具箱」项目里,我采用了一个明确的约定:对象忽略键顺序,数组按位置比较,字段缺失和显式 null 分开处理。本文把其中的边界拆出来,给出一个可以直接用 Node.js 运行的教学版本。它面向 JSON.parse 得到的数据,不是任意 JavaScript 对象的通用比较器。

1. 先判断字段是否存在,再比较字段值

考虑这两份接口响应:

{"profile":{"nickname":null}}
{"profile":{}}

前者明确返回了 nickname,值是 null;后者没有这个字段。比较器应该报告字段被删除,而不是用真假值判断把两种状态混为一谈。类似地,0、false、空字符串都可以是有效值,不能写成 if (!obj[key]) 来判断字段缺失。

这里需要的是自有属性检查。输入数据里可能恰好有名为 hasOwnProperty 的字段,因此也不直接调用 obj.hasOwnProperty(key),而是从 Object.prototype 取方法,再用 call 传入待检查对象。

2. 一个返回差异路径的教学实现

下面的实现将差异分为 added、deleted 和 changed。类型变化归入 changed;数组沿用下标路径;对象则遍历两边键名的并集。Set 只用来收集键名,不改变输入对象。

const own = (obj, key) =>
  Object.prototype.hasOwnProperty.call(obj, key);
const kind = value => value === null ? 'null'
  : Array.isArray(value) ? 'array' : typeof value;
const segment = key => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
  ? '.' + key : '[' + JSON.stringify(key) + ']';

function diff(a, b, path = '$', out = []) {
  const ta = kind(a), tb = kind(b);
  if (ta !== tb) {
    out.push({ path, type: 'changed', from: a, to: b });
  } else if (ta === 'object') {
    const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
    for (const key of keys) {
      const child = path + segment(key);
      if (!own(a, key)) {
        out.push({ path: child, type: 'added', value: b[key] });
      } else if (!own(b, key)) {
        out.push({ path: child, type: 'deleted', value: a[key] });
      } else {
        diff(a[key], b[key], child, out);
      }
    }
  } else if (ta === 'array') {
    for (let i = 0; i < Math.max(a.length, b.length); i++) {
      const child = path + '[' + i + ']';
      if (i >= a.length) {
        out.push({ path: child, type: 'added', value: b[i] });
      } else if (i >= b.length) {
        out.push({ path: child, type: 'deleted', value: a[i] });
      } else {
        diff(a[i], b[i], child, out);
      }
    }
  } else if (a !== b) {
    out.push({ path, type: 'changed', from: a, to: b });
  }
  return out;
}

console.log(diff(
  JSON.parse('{"profile":{"nickname":null},"a.b":1}'),
  JSON.parse('{"profile":{},"a.b":2}')
));

运行后会得到两处差异:$.profile.nickname 为 deleted,旧值是 null;$["a.b"] 为 changed,值从 1 变成 2。这里的路径是供阅读的自定义表示,并非完整 JSONPath 查询引擎。

3. 特殊键名也必须参与比较

用普通对象作为“已经见过的键名”集合,很容易碰到 __proto__、constructor 这类名字。工具箱的现有实现用 Object.create(null) 建立无原型的集合;上面的教学版本则用 Set。两者的目的相同:集合的成员记录不应与普通对象继承的属性混在一起。

验证这个边界时,要用真正的 JSON 文本构造数据:

console.log(diff(
  JSON.parse('{"__proto__":{"enabled":false},"hasOwnProperty":1}'),
  JSON.parse('{"__proto__":{"enabled":true},"hasOwnProperty":2}')
));

预期会报告 $.__proto__.enabled 和 $.hasOwnProperty 两个路径。这个例子只验证比较器会检查这些字段,不意味着它能替代整个应用的原型污染防护;后续如果把输入合并进配置对象,仍要单独审查那一步。

4. 数组按位置比较,是一项产品约定

["api","test"] 和 ["test","api"] 在这个比较器中会产生两处值变化。对于步骤列表、排名结果或时间序列,这是合理的;对于业务上无序的标签集合,可能需要另一种规则。

不要为了“减少差异”而统一排序数组。对象数组还涉及按哪个字段匹配、标识是否唯一、重复项如何处理。更稳妥的扩展方式是先确定具体字段的业务语义,再为该路径配置匹配规则。当前工具只做结构比较,不推断这些规则,也不把结果当成自动修复补丁。

5. 验证结果与适用边界

本文代码在本地 Node.js 中实际运行,断言覆盖六组样例:对象键顺序不同、缺失与 null、数组顺序变化、特殊键名、带点号键名的路径、数字与字符串之间的类型变化。六组均通过。这些结果说明列出的规则在样例中成立,不能代替大文件性能测试或完整浏览器交互测试。

还要注意解析阶段的损失。大整数如果已经被解析成不精确的 JavaScript 数字,后续比较无法恢复原文;重复对象键被解析覆盖后,也不能靠这个比较器发现。需要原样比较金额、高精度标识或重复键时,应在解析策略上解决,而不是继续堆叠递归条件。深层嵌套还可能触及调用栈限制,实际接入时需要根据输入来源设置大小和深度约束。

项目源码:JSON 口袋工具箱。本文示例为围绕项目规则整理的独立教学版本,采用 AI 辅助整理,并通过本地运行复核代码输出。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。