diff git 代码实现_手动实现高仿github的内容diff效果

本文介绍了如何基于diff库实现高仿GitHub的文本diff效果,包括代码展开、单列和双列对比功能。首先,展示了效果演示,然后详细讲解了实现原理,包括获取输入、分析输出结构和代码实现过程。核心是使用diffLines方法处理文本内容,根据输出数据结构进行二次开发,以实现代码块的渲染和颜色标记。文章还提供了代码片段展示数据处理和UI实现的细节。
摘要由CSDN通过智能技术生成

前言

最近发现了一个比较好用的内容diff库(就叫diff),非常方便js开发者实现文本内容的diff,既可以直接简单输出格式化的字符串比较内容,也可以输出较为复杂的changes数据结构,方便二次开发。这里笔者就基于这个库实现高仿github的文本diff效果。

效果演示

实现了代码展开,单列和双列对比等功能。示例如下:

a14e41393606

image

代码演示站点

如何实现

核心原理

最核心的文本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() {

onChange={this.changeFile.bind(null, 0)}

customRequest={() => {}}

>

点我上传1

}

}

在node端就要方便很多了,调用fs(文件系统库),直接对文件流进行读取即可。

输出结构分析

接下来我们看看diffLines的输出大致长什么样:

a14e41393606

image

这里我们对输出结果进行分析,输出是一个数组,数组的对象有多个属性:

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;这样也可以避免内部内容部分的空格被合并成一个。

相关资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值