前言
最近发现了一个比较好用的内容diff库(就叫diff),非常方便js开发者实现文本内容的diff,既可以直接简单输出格式化的字符串比较内容,也可以输出较为复杂的changes数据结构,方便二次开发。这里笔者就基于这个库实现高仿github的文本diff效果。
效果演示
实现了代码展开,单列和双列对比等功能。示例如下:
代码演示站点
如何实现
核心原理
最核心的文本diff算法,由diff
库替我们实现,这里我们使用的是diffLines
方法(关于diff库的使用,笔者有一篇博文diff使用指南有详细介绍)。通过该库输出的数据结构,对其进行二次开发,以便实现类似gitHub的文件diff效果。
获取输入
这里我们的比较内容都是以字符串的形式进行输入。至于如何将文件转化成字符串,在浏览器端可以使用Upload
进行文件上传,然后在获得的文件句柄上调用text
方法,即可获得文件对应的字符串,类似这样:
import React from 'react';
import { Upload } from 'antd';
// 不一定要用react和antd,就是表达下思路
class Test extends React.Fragment {
changeFile = async (type, info) => {
const { file } = info;
const content = await file.originFileObj.text();
console.log(content);
}
render() {
{}}
>
点我上传1
}
}
在node
端就要方便很多了,调用fs
(文件系统库),直接对文件流进行读取即可。
输出结构分析
接下来我们看看diffLines
的输出大致长什么样:
这里我们对输出结果进行分析,输出是一个数组,数组的对象有多个属性:
- value: 表示代码块的具体内容
- count: 表示该代码块的行数
- added: 如果该代码块为新增内容,其值为true
- removed: 如果该代码块表示移除的内容,其值为true
到这里我们的实现思路已经大致成型:根据数组内容渲染代码块,以\n
为分隔符,划分代码行,added
部分标绿,removed
部分标红,其余部分正常显示即可,至于具体的代码行数,可以根据count
进行计算。
代码实现
原始数据处理
如果参与比较的文件过大,公共部分的代码中过长的部分需要进行折叠,新增和移除的代码需要全量展示,基于这个逻辑,我们将需要展示的代码做如下划分:
[图片上传失败...(image-63e84a-1596593154285)]
确定了我们的展示逻辑,接下来需要做的就是针对diff
库处理之后的数据进行处理,相关代码如下:
import React from 'react';
import { Upload, Button, Layout, Menu, Radio } from 'antd';
import s from './index.css';
import cx from 'classnames';
const { Content } = Layout;
const SHOW_TYPE = {
UNIFIED: 0,
SPLITED: 1
}
const BLOCK_LENGTH = 5;
export default class ContentDiff extends React.Component {
state = {
// 供渲染的数据
lineGroup: [],
// 展示的类型
showType: SHOW_TYPE.UNIFIED
}
// 刷新供渲染的数据
flashContent = (newArr) => {
const initLineGroup = (newArr || this.props.diffArr).map((item, index, originArr) => {
let added, removed, value, count;
added = item.added;
removed = item.removed;
value = item.value;
count = item.count;
// 以\n为分隔符,将value分割成以行划分的代码
const strArr = value?.split('\n').filter(item => item) || [];
// 获得当前数据块的类型+标识新增 -表示移除 空格表示相同的内容
const type = (added && '+') || (removed && '-') || ' ';
// 定义代码块的内部结构,分为头部,尾部和中间的隐藏部分
let head, hidden, tail;
// 如果是增加或者减少的代码块,头部填入内容,尾部和隐藏区域都为空
if (type !== ' ') {
hidden = [];
tail = [];
head = strArr;
} else {
const strLength = strArr.length;
// 如果公共部分的代码量过少,就统一展开
if (strLength <= BLOCK_LENGTH * 2) {
hidden = [];
tail = [];
head = strArr;
} else {
// 否则只展示代码块头尾部分的代码,中间部分折叠
head = strArr.slice(0, BLOCK_LENGTH)
hidden = strArr.slice(BLOCK_LENGTH, strLength - BLOCK_LENGTH);
tail = strArr.slice(strLength - BLOCK_LENGTH);
}
}
return {
// 代码块类型,新增,移除,或者没变
type,
// 代码行数
count,
// 内容区块
content: {
hidden,
head,
tail
}
}
});
// 接下来处理代码的行数,标记左右两侧代码块的初始行数
let lStartNum = 1;
let rStartNum = 1;
initLineGroup.forEach(item => {
const { type, count } = item;
item.leftPos = lStartNum;
item.rightPos = rStartNum;
// 移除代码和新增代码的两部分分开计算
lStartNum += type === '+' ? 0 : count;
rStartNum += type === '-' ? 0 : count;
})
this.setState({
lineGroup: initLineGroup
});
}
render() {
return (
// ...
)
}
}
通过上述代码完成对原始数据的处理,将表示内容的数组中的对象划分为三种:added
,removed
和公共代码,并将内容分成head,hidden和tail三部分(主要是为了公共代码部分隐藏冗余的代码),然后计算代码块在对比显示时的初始行数行数,分栏(splited)和整合(unified)模式下都可使用。
整合模式下的内容展示
接下来是整合模式的展示代码:
export default class ContentDiff extends React.Component {
state = {
// 供渲染的数据
lineGroup: [],
// 展示的类型
showType: SHOW_TYPE.UNIFIED
}
// 转换展示模式
handleShowTypeChange = (e) => {
this.setState({
showType: e.target.value
})
}
// 判断状态
get isSplit() {
return this.state.showType === SHOW_TYPE.SPLITED;
}
// 刷新供渲染的数据
flashContent = (newArr) => {
// 省略重复内容
}
// 给行号补足位数
getLineNum = (number) => {
return (' ' + number).slice(-5);
}
// 获取split下的内容node
getPaddingContent = (item) => {
return {item}
}
paintCode = (item, isHead = true) => {
const { type, content: { head, tail, hidden }, leftPos, rightPos} = item;
// 是否是公共部分
const isNormal = type === ' ';
// 根据类型选择合适的class
const cls = cx(s.normal, type === '+' ? s.add : '', type === '-' ? s.removed : '');
// 占位空格
const space = " ";
// 渲染头部或者尾部内容
return (isHead ? head : tail).map((sitem, sindex) => {
let posMark = '';
if (isNormal) {
// 计算行号的偏移值
const shift = isHead ? 0: (head.length + hidden.length);
// 左右两侧的行数不一定一样
posMark = (space + (leftPos + shift + sindex)).slice(-5)
+ (space + (rightPos + shift + sindex)).slice(-5);
} else {
// 增减部分的行号计算
posMark = type === '-' ? this.getLineNum(leftPos + sindex) + space
: space + this.getLineNum(rightPos + sindex);
}
// 依次渲染行号,+ -号和代码内容
return
{posMark}
{' ' + type + ' '}{this.getPaddingContent(sitem, true)}
})
}
getUnifiedRenderContent = () => {
// 根据lineGroup的内容依次渲染代码块
return this.state.lineGroup.map((item, index) => {
const { type, content: { hidden }} = item;
const isNormal = type === ' ';
// 依次渲染head,hidden,tail三部分内容
return
{this.paintCode(item)}
{hidden.length && isNormal && this.getHiddenBtn(hidden, index) || null}
{this.paintCode(item, false)}
})
}
render() {
const { showType } = this.state;
return (
Unified
Split
{this.isSplit ? this.getSplitContent()
: this.getUnifiedRenderContent()}
)
}
}
以上的部分将lineGroup
中的每个对象的content
依次根据head,hidden,tail三部分来渲染,行数根据先前计算的lStartNum
和rStartNum
来进行展示。
分栏模式下的内容展示
接下来是分栏的实现:
export default class ContentDiff extends React.Component {
// 获取split下的页码node
getLNPadding = (origin) => {
const item = (' ' + origin).slice(-5);
return {item}
}
// 差异部分的代码渲染
getCombinePart = (leftPart = {}, rightPart = {}) => {
const { type: lType, content: lContent, leftPos: lLeftPos, rightPos: lRightPos } = leftPart;
const { type: rType, content: rContent, leftPos: rLeftPos, rightPos: rRightPos } = rightPart;
// 分别获取左右两侧对应的内容和class
const lArr = lContent?.head || [];
const rArr = rContent?.head || [];
const lClass = lType === '+' ? s.add : s.removed;
const rClass = rType === '+' ? s.add : s.removed;
return
{lArr.map((item, index) => {
// 渲染左半边内容,也就是删除的部分(如果有的话)
// 两个div分别输出行数和内容
return
{this.getLNPadding(lLeftPos + index)}
{this.getPaddingContent('- ' + item)}
})}
{rArr.map((item, index) => {
// 渲染右半边内容,也就是新增的部分(如果有的话)
return
{this.getLNPadding(rRightPos + index)}
{this.getPaddingContent('+ ' + item)}
})}
}
// 无变化部分的代码渲染
getSplitCode = (targetBlock, isHead = true) => {
const { type, content: { head, hidden, tail }, leftPos, rightPos} = targetBlock;
return (isHead ? head : tail).map((item, index) => {
const shift = isHead ? 0: (head.length + hidden.length);
// 左右两边除了样式,基本没有差异
return
{this.getLNPadding(leftPos + shift + index)}{this.getPaddingContent(' ' + item)}
{this.getLNPadding(rightPos + shift +index)}{this.getPaddingContent(' ' + item)}
})
}
// 渲染分栏的代码
getSplitContent = () => {
const length = this.state.lineGroup.length;
const contentList = [];
for (let i = 0; i < length; i++) {
const targetBlock = this.state.lineGroup[i];
const { type, content: { hidden } } = targetBlock;
// 渲染相同的部分
if (type === ' ') {
contentList.push(
{this.getSplitCode(targetBlock)}
{hidden.length && this.getHiddenBtn(hidden, i) || null}
{this.getSplitCode(targetBlock, false)}
)
} else if (type === '-') {
// 渲染移除的部分
const nextTarget = this.state.lineGroup[i + 1] || { content: {}};
const nextIsPlus = nextTarget.type === '+';
contentList.push(
{this.getCombinePart(targetBlock, nextIsPlus ? nextTarget : {})}
)
nextIsPlus ? i = i + 1 : void 0;
} else if (type === '+') {
// 渲染新增的部分
contentList.push(
{this.getCombinePart({}, targetBlock)}
)
}
}
return
{contentList}
}
// 省略重复代码
}
这里的展示方式和unified
模式下略有不同。公共部分和差异部分要使用不同的渲染函数,相同的部分代码要对齐,差异的部分左右两侧需要等高。
展开摁钮的实现
接下来我们实现点击展开的功能:
export default class ContentDiff extends React.Component {
// 省略重复的内容
// 根据三种点击的状态,更新head,tail和hidden的内容
openBlock = (type, index) => {
const copyOfLG = this.state.lineGroup.slice();
const targetGroup = copyOfLG[index];
const { head, tail, hidden } = targetGroup.content;
if (type === 'head') {
// 如果是点击向上的箭头,对head和hidden部分的内容进行更新
targetGroup.content.head = head.concat(hidden.slice(0, BLOCK_LENGTH));
targetGroup.content.hidden = hidden.slice(BLOCK_LENGTH);
} else if (type === 'tail') {
// 如果是点击向下的箭头,对tail和hidden的部分进行更新
const hLenght = hidden.length;
targetGroup.content.tail = hidden.slice(hLenght - BLOCK_LENGTH).concat(tail);
targetGroup.content.hidden = hidden.slice(0, hLenght - BLOCK_LENGTH);
} else {
// 如果是双向箭头,展开所有的内容到head
targetGroup.content.head = head.concat(hidden);
targetGroup.content.hidden = [];
}
copyOfLG[index] = targetGroup;
this.setState({
lineGroup: copyOfLG
});
}
// 渲染隐藏的部分
getHiddenBtn = (hidden, index) => {
// 如果隐藏的内容过少,则显示双向箭头
const isSingle = hidden.length < BLOCK_LENGTH * 2;
return
{isSingle ?
{/* 双向箭头 */}
:
{/* 向上的箭头 */}
{/* 向下的箭头 */}
}
{`当前隐藏内容:${hidden.length}行`}
}
}
这里直接搬运了git官网的svg箭头图片,查看更多的交互一共有三种,折叠内容多于10行的,分别显示上下箭头,每点击一次多展示5行内容,一旦隐藏内容少于10行,显示双向箭头,此时点击将展示所有的折叠内容。这一部分的核心逻辑是可复用的,splited和unified内容皆可以使用,只是在UI的处理上需要有一定的差别。
UI细节
在编码过程中遇到一个问题,diff
库处理之后的value是包含空格的,类似于这样const isSingle = true;
但是在展示时div标签默认是会合并(trim)掉开头的空格的,这里有两种方法:
使用
标签包裹内容:使用这个标签包裹的内容将会展示其内部的真实内容,不会有其他逻辑,不过这个标签同于div,在字体样式等方面会有微小的差异(chrome下如此,其他浏览器未确认)
在div样式添加
white-space: pre-wrap;
这样也可以避免内部内容部分的空格被合并成一个。
相关资料
diff库官方文档
diff使用指南
演示站点
演示站点代码仓库