用JavaScript实现高效数据对比的实战经验与性能优化技巧

宏春 交互 阅读 1,368
赞 78 收藏
二维码
手机扫码查看
反馈

「又踩坑了,diff出来的差异老是漏掉嵌套对象的变更」

今天下午三点十七分,我盯着控制台里打印出来的两个对象 diff 结果发呆——明明我改了 user.profile.avatar.url,结果 diff 工具只标出了 user.name 变了,avatar 那一层压根没反应。我下意识点了刷新,又改了一遍,还是没标出来。心里一咯噔:完了,这玩意儿在生产环境里跑了一周,怕不是早就在默默吞 bug 了。

用JavaScript实现高效数据对比的实战经验与性能优化技巧

我们这个数据对比功能用在后台的「配置快照比对页」,用户点开两个历史版本,左边是旧版,右边是新版,中间高亮显示差异字段。本来用的是 deep-diff 这个包,文档写得挺清爽,API 就一行:diff(oldObj, newObj),返回一个变更数组。一开始真香,直到上周 QA 提了个 case:「修改了嵌套三级的开关状态,对比页没高亮」。我心想,不至于吧?立马本地复现 —— 果然,{ settings: { features: { darkMode: true } } } 改成 false,diff 返回空数组。

这里我踩了个坑:以为 deep-diff 默认递归所有层级,结果翻源码才发现它默认只比较到「可枚举属性 + 原始类型 + Array/Object」,但遇到 DateRegExpMapSet 直接跳过;更关键的是,它对 undefinednull 的处理很迷——如果旧对象里是 avatar: null,新对象是 avatar: { url: 'xxx' },它会当成「新增」,但如果旧对象压根没有 avatar 字段(即 obj.avatar === undefined),它就当没这回事,直接忽略整个路径。

折腾了半天发现,问题出在我们数据初始化逻辑上:后端返回的 JSON 里,很多字段是「按需下发」的,比如没上传头像时,profile 对象里根本不会包含 avatar 键,而不是塞个 null。所以 diff 工具拿到两个对象:{ profile: {} } vs { profile: { avatar: { url: 'x' } } },它只对比了 profile 这一级的引用变化(都是 object,相等),下面的 key 差异压根没进递归分支。

后来试了下发现,与其硬刚第三方库的边界 case,不如自己撸个轻量级的递归 diff,控制权全在手里。核心就两点:一是必须显式遍历所有 key(包括 in 操作符能拿到的 + Object.getOwnPropertyNames),二是对每个值做类型感知的比较(原始值直接 ===,对象/数组递归,null 单独判断)。不求多 fancy,只要能 cover 我们业务里的 Object / Array / string / number / boolean / null / undefined 就够了。

最终代码没几行,但调试花了俩小时……主要是 undefinednull 的语义容易搞混。比如:old.a = undefinednew.a = null,这算「变更」;但 old.a 根本不存在(即 !old.hasOwnProperty('a')),new.a = null,这算「新增」。这两者在 UI 上要展示不同颜色(橙色变更 vs 蓝色新增),所以 diff 结果里必须带 type 字段。

function diffObjects(oldObj, newObj, path = []) {
  const changes = [];
  
  // 收集所有可能的 keys(兼容 hasOwnProperty + 继承属性,但我们业务没用继承,先简单处理)
  const allKeys = new Set([
    ...Object.keys(oldObj),
    ...Object.keys(newObj),
    ...Object.getOwnPropertyNames(oldObj),
    ...Object.getOwnPropertyNames(newObj)
  ]);

  for (const key of allKeys) {
    const oldVal = oldObj[key];
    const newVal = newObj[key];
    const currentPath = [...path, key];

    // 判断是否「新增」
    if (!(key in oldObj) && key in newObj) {
      changes.push({
        type: 'added',
        path: currentPath,
        oldValue: undefined,
        newValue: newVal
      });
      continue;
    }

    // 判断是否「删除」
    if (key in oldObj && !(key in newObj)) {
      changes.push({
        type: 'removed',
        path: currentPath,
        oldValue: oldVal,
        newValue: undefined
      });
      continue;
    }

    // 两者都存在,开始值比较
    const oldType = getType(oldVal);
    const newType = getType(newVal);

    if (oldType !== newType) {
      // 类型变了,算变更
      changes.push({
        type: 'changed',
        path: currentPath,
        oldValue: oldVal,
        newValue: newVal
      });
      continue;
    }

    // 同类型,再细分
    if (oldType === 'object' && oldVal !== null && newVal !== null) {
      // 递归比较对象或数组
      const nested = diffObjects(oldVal, newVal, currentPath);
      changes.push(...nested);
    } else if (oldType === 'primitive') {
      if (oldVal !== newVal) {
        changes.push({
          type: 'changed',
          path: currentPath,
          oldValue: oldVal,
          newValue: newVal
        });
      }
    }
  }

  return changes;
}

function getType(val) {
  if (val === null) return 'null';
  if (Array.isArray(val)) return 'array';
  if (typeof val === 'object') return 'object';
  return 'primitive';
}

上面这个函数现在跑得挺稳。我们还加了个小优化:对 value 做 JSON.stringify 截断(防大对象卡死),以及把 path 数组转成字符串用于 React key:path.join('.') === 'user.profile.avatar.url'。UI 层就根据 type 渲染不同背景色和 icon,非常直观。

当然,它还不完美。比如目前不支持 Map/Set(我们没用到),也没做循环引用检测(我们的配置数据是纯 JSON,不会有循环)。另外有个小问题:当某个字段从 undefined 变成 {},它会识别为「变更」,但实际渲染时,空对象和 undefined 在 UI 上看起来几乎一样,QA 说「感觉不太准」。我说:兄弟,你要是真想看到区别,下次提需求的时候顺便把「undefined 字段也要显式返回 null」写进接口文档里啊……(手动狗头)

顺带一嘴,如果你要用在服务端,注意别直接传整个 request.body 去 diff,有些字段比如 req.id 或时间戳每次都不一样,建议提前 pick 出你要比的 key,或者用白名单过滤。我们就是吃了这个亏,第一次上线时把 updatedAt 也塞进去了,结果每条记录都标红……

最后,这个 diff 函数我们封装成了 hook:useDiff(oldData, newData),内部用 useMemo 缓存结果,避免重复计算。测试覆盖了 12 个典型 case,包括深层嵌套、数组增删、null/undefined 切换,目前线上跑了五天,零投诉。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如你用过 jsondiffpatch 或手写过更健壮的版本,求分享!这个技巧的拓展用法还有很多,比如结合 immutable.js 做不可变更新追踪,后续有空我会继续写这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论