数据对比实战:高效处理多源异构数据的前端方案
先看效果,再看代码
上周做数据对比功能,客户要的是“一眼看出两个版本的差异”,不是那种表格里标红绿色的弱鸡方案。我一开始也想用现成的 diff 库,结果发现要么太重,要么不支持自定义样式,最后干脆自己撸了一套轻量级方案。亲测有效,核心逻辑就几十行,但能覆盖 90% 的日常需求。
直接上代码,这是最基础的字符串对比渲染:
function renderDiff(oldStr, newStr) {
const oldWords = oldStr.split(' ');
const newWords = newStr.split(' ');
const maxLength = Math.max(oldWords.length, newWords.length);
let result = [];
for (let i = 0; i < maxLength; i++) {
const oldWord = oldWords[i] || '';
const newWord = newWords[i] || '';
if (oldWord === newWord) {
result.push(<span>${oldWord}</span>);
} else {
if (oldWord) result.push(<del class="diff-del">${oldWord}</del>);
if (newWord) result.push(<ins class="diff-ins">${newWord}</ins>);
}
}
return result.join(' ');
}
配合一点 CSS,效果立马出来:
.diff-del {
background-color: #ffe6e6;
text-decoration: line-through;
color: #d32f2f;
}
.diff-ins {
background-color: #e6ffe6;
text-decoration: none;
color: #2e7d32;
}
这个方案简单粗暴,但只适用于按空格分词的场景。中文怎么办?别急,后面会讲。
中文对比?别被空格骗了
上面那个 split(' ') 在中文里完全失效。我第一次上线就翻车了——用户输入“你好世界” vs “你好中国”,结果整个句子被当成一个词,要么全删要么全加,毫无粒度可言。
折腾了半天,发现用正则按字符拆分反而更靠谱(虽然性能差点,但数据量不大时无感):
function splitChinese(str) {
return str.split(/(?<=.)(?=.)/); // 按每个字符切分
}
然后把 renderDiff 里的 split(' ') 换成 splitChinese(str) 就行。不过注意:这样会把标点、数字、英文都单独切开,如果你希望保留英文单词完整性,就得写更复杂的 tokenizer。但说实话,对大多数业务场景,字符级对比已经够用了,别过度设计。
这个场景最好用:表格数据对比
最近做的一个配置管理后台,需要对比两版 JSON 配置。这时候就不能用字符串对比了,得递归遍历对象结构。
我封装了一个 deepDiff 函数,返回差异路径和值:
function deepDiff(obj1, obj2, path = '') {
const diffs = [];
const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
for (const key of keys) {
const currentPath = path ? ${path}.${key} : key;
const val1 = obj1[key];
const val2 = obj2[key];
if (val1 === undefined) {
diffs.push({ path: currentPath, type: 'add', value: val2 });
} else if (val2 === undefined) {
diffs.push({ path: currentPath, type: 'delete', value: val1 });
} else if (typeof val1 === 'object' && typeof val2 === 'object' && val1 !== null && val2 !== null) {
diffs.push(...deepDiff(val1, val2, currentPath));
} else if (val1 !== val2) {
diffs.push({ path: currentPath, type: 'update', oldValue: val1, newValue: val2 });
}
}
return diffs;
}
用法也很简单:
const configA = { theme: 'dark', timeout: 30 };
const configB = { theme: 'light', retry: 3 };
const changes = deepDiff(configA, configB);
// 输出:
// [
// { path: 'theme', type: 'update', oldValue: 'dark', newValue: 'light' },
// { path: 'timeout', type: 'delete', value: 30 },
// { path: 'retry', type: 'add', value: 3 }
// ]
拿到这个结构,你就能在表格里高亮显示具体哪一行变了,甚至做“一键还原”功能。建议直接用这种方式处理结构化数据,比字符串 diff 精准得多。
踩坑提醒:这三点一定注意
1. 别忽略 whitespace 和换行符。我有次对比两个 HTML 片段,肉眼看一样,但 diff 显示全变了。后来发现是 prettier 格式化后多了几个空格。解决办法:对比前先 normalize 字符串,比如 str.replace(/s+/g, ' ').trim()。
2. 性能问题别硬扛。如果你要对比几千行的日志,字符级 diff 会卡死页面。这时候要么用 Web Worker,要么限制对比长度(比如只取前 500 行)。我试过用 Myers diff 算法优化,但代码复杂度飙升,最后还是加了个“仅对比前 1000 字符”的提示,用户反而觉得更实用。
3. 别信“完美同步滚动”。很多教程说用两个 textarea + scroll 事件同步滚动,实际体验极差——尤其在移动端,滚动不同步、卡顿、甚至触发两次。我的妥协方案:用单个容器,左右分栏,固定高度 + overflow-y: auto,靠 CSS 实现视觉对齐。虽然不能独立滚动,但稳定不翻车。
高级技巧:动态生成对比视图
有时候后端直接返回 diff 结果(比如 Git 风格的 patch),前端只需要渲染。我在对接一个 CMS 时就遇到这种情况,API 返回:
{
"diff": [
{ "type": "equal", "value": "Hello " },
{ "type": "delete", "value": "world" },
{ "type": "insert", "value": "React" }
]
}
这种情况下,渲染函数超简单:
function renderFromApi(diffArray) {
return diffArray.map(item => {
if (item.type === 'equal') return <span>${item.value}</span>;
if (item.type === 'delete') return <del class="diff-del">${item.value}</del>;
if (item.type === 'insert') return <ins class="diff-ins">${item.value}</ins>;
return '';
}).join('');
}
关键点在于:**让后端算 diff,前端只负责展示**。这样既减轻前端负担,又保证一致性。如果你的项目有服务端支持,强烈建议走这条路。
最后说两句
数据对比没有银弹。字符串级、字符级、结构化对象、API 返回 diff……每种场景都有最适合的方案。我现在的做法是:先问清楚数据来源和量级,再决定用哪种。别一上来就引入 50KB 的 diff 库,可能几行代码就搞定了。
以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多(比如结合 Monaco Editor 做代码 diff,或者用 Canvas 渲染大文件差异),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。
