實現文本diff比較與展示
作爲編程人員,文本diff比較與展示應該不陌生,最常見的是在Git中使用git diff
命令,可以查看代碼修改前後的對比。在git中diff比較與展示的最小單位是行,因爲代碼修改涉及的改動一般較多,以行爲單位顯示出來的效果,美觀且容易閱讀。
自己最近在某個項目中遇到與一個git diff類似的需求:“一段普通長度的文本經過修改後,希望向閱讀者展示修改所帶來的前後差異”,本文就講述這個需求自己是如何實現的。
分析需求
需求是針對一段普通長度文本展示修改前後的diff,因文本內容較短,所以不選擇編碼中常見的行作爲比較單位,而是以字符爲最小單位。進一步分析,可以發現問題的核心是尋找文本修改前後的相同部分,然後再將前後內容與相同部分進行比較,得到差異內容進行可視化呈現。
尋找前後文本中的相同部分,這個描述並不準確,應該是尋找前後文本中長度最長的共同子序列,這個問題的專業名稱是“最長公共子序列”,最佳求解方法是使用動態規劃求解,具體算法推導詳詢Google、百度。
假設原文本(before)-“你今天吃飯了嗎?”,修改後(after)-“今天你吃飯了嗎?”,在這個簡單的例子中我們不需要計算也可以看出最長公共子序列(lcs)是:“今天吃飯了嗎?”。
接着我們拿before與lcs進行比較,發現差異字符是"你",而after與lcs進行比較得到的差異字符也是"你",但前者表示刪除後者則是插入,所以最終我們的diff可以表示爲:[“你(delete)”, “今天”, “你(insert)”, “吃飯了嗎?”],用這個數組做可視化展示,也就是diff的展示。
最長公共子序列求解
前面的例子由於before與after長度太短,肉眼能看出最長公共子序列,實際中我們則需要用算法與代碼實現這部分,思路是先求長度再反推內容,自己用js按算法寫了如下實現代碼:
function lcs(x, y) {
x = x ? x.split('') : [];
y = y ? y.split('') : [];
let mark = [];
for (let i = 0; i < x.length; i++) {
mark[i] = [];
for (let j = 0; j < y.length; j++) {
if (x[i] === y[j]) {
mark[i][j] = (i === 0 ? 0 : (mark[i - 1][j - 1] || 0)) + 1;
continue;
}
mark[i][j] = Math.max(mark[i][j - 1] || 0, i === 0 ? 0 : mark[i - 1][j]);
}
}
let i = x.length - 1;
let j = y.length - 1;
let result = [];
while (i >= 0 && j >= 0) {
if (x[i] === y[j]) {
result.unshift(x[i]);
i--;
j--;
continue;
}
if (i !== 0 && mark[i][j] === mark[i - 1][j]) {
i--;
continue;
}
if (j !== 0 && mark[i][j] === mark[i][j - 1]) {
j--;
continue;
}
break;
}
return result.join('');
}
// lcs('今天你吃飯了嗎?', '你今天吃飯了嗎?') = '今天吃飯了嗎?';
// lcs('我今天去你家吃飯,你在家嗎?', '你在家嗎?我打算今天去你家吃飯') = '我今天去你家吃飯';
對比差異
得到兩個字符串的最長公共子序列之後,接着我們需要計算前後字符串與公共子序列的差異併合併到一個結構當中,最終用這個合併的結構做可視化展示。
這部分不需要啥特殊方法求解,以求before與lcs的diff爲例:
let before = '今天你吃飯了嗎?'.split('');
let lcs = '今天吃飯了嗎?'.split('');
let beforeDiff = [];
let chunk = '';
let flag = before[0] === lcs[0];
for (let i = 0, j = 0; i < before.length; i++) {
if (flag === (before[i] === lcs[j])) {
chunk += before[i];
j += flag ? 1 : 0;
continue;
}
beforeDiff.push({
text: chunk,
isCommon: flag // lcs中存在
});
chunk = before[i];
flag = !flag;
if (before[i] === lcs[j]) {
j++;
}
}
beforeDiff.push({
text: chunk,
isCommon: flag
});
上述代碼得到的beforeDiff是原文本before與lcs的diff,可用來展示修改中被刪除的內容,而用同樣方法可以得到afterDiff,用於展示修改後插入的內容。
通過beforeDiff與afterDiff可以分別展示修改的刪除與插入,但要在一個結構中同時體現兩者,則需要進行合併,其思路是:“通過diff(before、after)與lcs對比,確定差異字符串在lcs中的index,再將diff內容插入到lcs的這些index位置中”,代碼如下:
// _ is lodash
let result = [];
let len = 0;
beforeDiff = _.map(beforeDiff, item => {
if (item.isCommon) {
len += item.text.length;
return item;
}
item.type = 'delete';
item.index = len;
return item;
});
len = 0;
afterDiff = _.map(afterDiff, item => {
if (item.isCommon) {
len += item.text.length;
return item;
}
item.type = 'insert';
item.index = len;
return item;
});
let diffItems = _.filter(beforeDiff.concat(afterDiff), item => {
return item.type === 'insert' || item.type === 'delete';
});
diffItems = _.sortBy(diffItems, 'index');
let index = 0;
_.each(diffItems, item => {
if (item.index !== 0) {
result.push({
text: lcs.substring(index, item.index),
type: 'origin'
});
index = item.index;
}
result.push({
text: item.text,
type: item.type
});
});
if (lcs.length > cursor) {
result.push({
text: lcs.substring(index, lcs.length),
type: 'origin'
});
}
經上訴代碼合併得到的result就是最終用來呈現diff的結構,結構爲:[{text: 'string', type: 'string, origin|delete|insert'}]
可視化
能得到diff的數據表示結構,做可視化相對容易,展示一下小程序的做法:
<!-- wxml -->
<view class="diff">
<text wx:for="{{diff}}" wx:key="{{index}}" class="{{item.type}}" space="ensp">{{item.text}}</text>
</view>
上面wx:for之中的diff變量是最終得到的diff表示結構,而樣式控制則在不同的class中體現。