4、版本对比实现:前端对比组件设计、高亮显示变更行、字段级变更的可视化(增删改)

版本对比这个功能,说白了就是给用户一面「照妖镜」。配方从 v1 改到 v10,中间到底动了哪些字段?谁改的?改成了什么?如果前端对比做不好,用户只能靠肉眼一行行对 JSON,那体验就太糟糕了。

我个人习惯把版本对比拆成三个层次:行级对比(哪几行变了)、字段级对比(具体哪个字段增删改)、语义级对比(改动的业务含义是什么)。这一章我们重点讲前两层,第三层留到后面结合元数据来讲。

核心原则:前端只做展示和交互,对比算法由后端或纯前端 diff 库完成。前端组件要做的,是把 diff 结果「翻译」成用户一眼能看懂的视觉语言。

4.1 对比组件的整体架构

先看一张我画的架构图。它展示了前端对比组件在整个配方版本管理中的位置,以及数据流向。

版本对比前端组件架构图 版本对比前端组件架构 版本 A(旧) 配方数据快照 版本 B(新) 配方数据快照 Diff 引擎 deep-diff / jsdiff 递归对比 + 路径映射 对比结果(变更列表) 行级对比视图 高亮变更行 字段级对比视图 增删改可视化 语义级对比视图 业务含义解读 本章重点:行级对比视图 + 字段级对比视图

整个流程是这样的:用户选择两个版本后,前端把版本 A 和版本 B 的原始数据传给 Diff 引擎。引擎返回一个变更列表,每个变更项包含:变更类型(增/删/改)、字段路径(比如 ingredients[2].ratio)、旧值和新值。然后由三个视图组件分别消费这份数据。

我的经验:Diff 引擎最好放在 Web Worker 里跑。配方数据动辄几百个字段,如果放在主线程,用户点击对比后页面会卡住几百毫秒。我曾经因为这个被测试小姐姐提了 bug,后来改成 Worker 就丝滑了。

4.2 行级对比与高亮设计

行级对比是最直观的。用户打开对比页面,左右两边各显示一个版本,中间用颜色标注哪些行变了。

我习惯用三种颜色:

  • 绿色背景:新增的行(版本 B 有,版本 A 没有)
  • 红色背景:删除的行(版本 A 有,版本 B 没有)
  • 黄色背景:修改的行(两个版本都有,但内容不同)

具体实现时,我会把配方数据按「行」拆分。这里的「行」不一定是 JSON 里的物理行,而是业务逻辑行。比如一个原料对象,即使它在 JSON 里占 5 行,我也把它当作一行来处理。

// 行级对比的核心数据结构
interface DiffLine {
  lineNumber: number;      // 行号
  type: 'added' | 'removed' | 'modified' | 'unchanged';
  oldContent: string;      // 版本 A 的内容(纯文本)
  newContent: string;      // 版本 B 的内容(纯文本)
  fieldPath: string;       // 字段路径,用于展开字段级对比
}

// 使用 jsdiff 库进行行级对比
import { diffLines } from 'diff';

function computeLineDiff(oldText: string, newText: string): DiffLine[] {
  const changes = diffLines(oldText, newText);
  let lineNumber = 0;
  const result: DiffLine[] = [];

  for (const part of changes) {
    const lines = part.value.split('\n').filter(l => l.length > 0);
    for (const line of lines) {
      result.push({
        lineNumber: ++lineNumber,
        type: part.added ? 'added' : part.removed ? 'removed' : 'unchanged',
        oldContent: part.removed ? line : '',
        newContent: part.added ? line : '',
        fieldPath: extractFieldPath(line),  // 从行内容提取字段路径
      });
    }
  }
  return result;
}
注意:行级对比有一个坑——对齐问题。如果版本 A 有 10 行,版本 B 有 12 行,diff 算法可能会把行号对歪。我建议使用「基于内容的对齐」而不是「基于行号的对齐」。具体做法是:先找到两个版本中完全相同的行作为锚点,再处理差异部分。

前端渲染时,我会用 virtual-scroll 来优化性能。配方数据可能上千行,一次性渲染所有 DOM 节点会卡。只渲染可视区域内的行,滚动时动态加载,这样即使 5000 行的配方也能流畅对比。

4.3 字段级变更的可视化

行级对比只能告诉用户「这一行变了」,但具体变了哪个字段?是值改了,还是字段被删了?这就需要字段级对比。

字段级对比的核心是递归遍历。对于 JSON 对象中的每个字段,比较它在两个版本中的值。我常用 deep-diff 这个库,它返回的变更类型很清晰:

变更类型 含义 可视化方式
E (edit) 字段值被修改 旧值红色删除线 + 新值绿色背景
N (new) 新增字段 整行绿色背景 + "+" 图标
D (delete) 删除字段 整行红色背景 + "-" 图标
A (array) 数组元素变更 展开数组,逐项对比

举个例子,假设配方中有一个 mixingParams 对象:

// 版本 A
{
  "mixingParams": {
    "speed": 1200,
    "temperature": 85,
    "duration": 30
  }
}

// 版本 B
{
  "mixingParams": {
    "speed": 1500,
    "temperature": 85,
    "duration": 30,
    "viscosity": 5000  // 新增字段
  }
}

字段级对比的结果应该是:

  • mixingParams.speed:修改(1200 → 1500)
  • mixingParams.temperature:未变更
  • mixingParams.duration:未变更
  • mixingParams.viscosity:新增(5000)

前端渲染时,我会把每个字段渲染成一行,左侧显示字段路径,右侧显示变更详情。对于修改操作,我用 <del> 标签显示旧值,<ins> 标签显示新值,配合 CSS 的删除线和下划线效果。

关键设计:字段级对比视图要支持「展开/折叠」。默认只显示变更的字段,未变更的字段折叠起来。用户想看全部字段时,可以一键展开。这样既保证了信息密度,又不会让用户被大量未变更数据淹没。

4.4 数组元素的对比策略

数组对比是版本对比中最头疼的部分。为什么?因为数组元素的顺序可能变了,但业务上可能只是「重新排序」,而不是「删除再新增」。

我遇到过这样一个案例:配方中的原料列表,版本 A 是 [A, B, C],版本 B 是 [B, A, C]。如果按位置对比,会显示 A 被删除、B 被新增,但实际上只是交换了顺序。用户看到这个结果会一脸懵。

解决方案是使用基于 ID 的对比。如果数组元素有唯一标识(比如 materialId),就按 ID 匹配,而不是按索引匹配。

// 基于 ID 的数组对比
function diffArraysById(oldArr: any[], newArr: any[], idField: string = 'id') {
  const oldMap = new Map(oldArr.map(item => [item[idField], item]));
  const newMap = new Map(newArr.map(item => [item[idField], item]));

  const changes = [];

  // 检查删除和修改
  for (const [id, oldItem] of oldMap) {
    if (!newMap.has(id)) {
      changes.push({ type: 'removed', id, oldItem, newItem: null });
    } else {
      const newItem = newMap.get(id);
      const fieldChanges = deepDiff(oldItem, newItem);
      if (fieldChanges.length > 0) {
        changes.push({ type: 'modified', id, oldItem, newItem, fieldChanges });
      }
    }
  }

  // 检查新增
  for (const [id, newItem] of newMap) {
    if (!oldMap.has(id)) {
      changes.push({ type: 'added', id, oldItem: null, newItem });
    }
  }

  return changes;
}
避坑指南:如果数组元素没有 ID,那就只能按位置对比了。但我会在界面上加一个提示:「数组元素无唯一标识,对比结果可能不准确」。我曾经在一个项目中忘了加这个提示,结果用户拿着对比结果去审计,差点出事。

4.5 交互细节与用户体验

对比组件做得好不好,往往体现在细节上。我总结了几点:

  • 同步滚动:左右两个版本的面板要同步滚动。用户滚动左边,右边跟着动。这样用户才能直观地对比同一位置的内容。
  • 点击跳转:点击行级对比中的某一行,自动展开该行的字段级对比详情。减少用户的操作步骤。
  • 统计概览:在对比页面顶部显示「共 X 处变更,其中新增 Y 处,删除 Z 处,修改 W 处」。让用户先有个整体印象。
  • 键盘导航:支持用上下箭头键在变更行之间跳转。高级用户很喜欢这个功能。
性能警告:字段级对比的递归遍历在数据量大时很慢。我建议做两层缓存:第一层,Diff 引擎的结果缓存到内存中,用户切换对比版本时不用重新计算;第二层,渲染结果缓存到虚拟 DOM 的 memo 中,避免不必要的重渲染。

嗯,这一章的内容差不多就这些。行级对比和字段级对比是版本对比的基石,做好了,用户才能信任你的回滚操作。下一层语义级对比,我们后面再聊。