4、版本对比实现:前端对比组件设计、高亮显示变更行、字段级变更的可视化(增删改)
版本对比这个功能,说白了就是给用户一面「照妖镜」。配方从 v1 改到 v10,中间到底动了哪些字段?谁改的?改成了什么?如果前端对比做不好,用户只能靠肉眼一行行对 JSON,那体验就太糟糕了。
我个人习惯把版本对比拆成三个层次:行级对比(哪几行变了)、字段级对比(具体哪个字段增删改)、语义级对比(改动的业务含义是什么)。这一章我们重点讲前两层,第三层留到后面结合元数据来讲。
4.1 对比组件的整体架构
先看一张我画的架构图。它展示了前端对比组件在整个配方版本管理中的位置,以及数据流向。
整个流程是这样的:用户选择两个版本后,前端把版本 A 和版本 B 的原始数据传给 Diff 引擎。引擎返回一个变更列表,每个变更项包含:变更类型(增/删/改)、字段路径(比如 ingredients[2].ratio)、旧值和新值。然后由三个视图组件分别消费这份数据。
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;
}
前端渲染时,我会用 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;
}
4.5 交互细节与用户体验
对比组件做得好不好,往往体现在细节上。我总结了几点:
- 同步滚动:左右两个版本的面板要同步滚动。用户滚动左边,右边跟着动。这样用户才能直观地对比同一位置的内容。
- 点击跳转:点击行级对比中的某一行,自动展开该行的字段级对比详情。减少用户的操作步骤。
- 统计概览:在对比页面顶部显示「共 X 处变更,其中新增 Y 处,删除 Z 处,修改 W 处」。让用户先有个整体印象。
- 键盘导航:支持用上下箭头键在变更行之间跳转。高级用户很喜欢这个功能。
嗯,这一章的内容差不多就这些。行级对比和字段级对比是版本对比的基石,做好了,用户才能信任你的回滚操作。下一层语义级对比,我们后面再聊。