手动实现高仿github的内容diff效果

12,489 阅读7分钟

前言

最近发现了一个比较好用的内容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() {
        <Upload
            onChange={this.changeFile.bind(null, 0)}
            customRequest={() => {}}
        >
            点我上传1
        </Upload>
    }
}

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

输出结构分析

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

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

  • value: 表示代码块的具体内容
  • count: 表示该代码块的行数
  • added: 如果该代码块为新增内容,其值为true
  • removed: 如果该代码块表示移除的内容,其值为true

到这里我们的实现思路已经大致成型:根据数组内容渲染代码块,以\n为分隔符,划分代码行,added部分标绿,removed部分标红,其余部分正常显示即可,至于具体的代码行数,可以根据count进行计算。

代码实现

原始数据处理

如果参与比较的文件过大,公共部分的代码中过长的部分需要进行折叠,新增和移除的代码需要全量展示,基于这个逻辑,我们将需要展示的代码做如下划分:

确定了我们的展示逻辑,接下来需要做的就是针对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 <div className={cx(s.splitCon)}>{item}</div>
    }

    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 <div key={(isHead ? 'h-' : 't-') + sindex} className={cls}>
                <pre className={cx(s.pre, s.line)}>{posMark}</pre>
                <div className={s.outerPre}><div className={s.splitCon}><div className={s.spanWidth}>{' ' + type + ' '}</div>{this.getPaddingContent(sitem, true)}</div></div>
            </div>
        })
    }

    getUnifiedRenderContent = () => {
        //  根据lineGroup的内容依次渲染代码块
        return this.state.lineGroup.map((item, index) => {
            const { type, content: { hidden }} = item;
            const isNormal = type === ' ';
            //  依次渲染head,hidden,tail三部分内容
            return <div key={index}>
                {this.paintCode(item)}
                {hidden.length && isNormal && this.getHiddenBtn(hidden, index) || null}
                {this.paintCode(item, false)}
            </div>
        })
    }
    render() {
        const { showType } = this.state;
        return (
            <React.Fragment>
                <div className={s.radioGroup}>
                    <Radio.Group value={showType} size='small' onChange={this.handleShowTypeChange}>
                        <Radio.Button value={SHOW_TYPE.UNIFIED}>Unified</Radio.Button>
                        <Radio.Button value={SHOW_TYPE.SPLITED}>Split</Radio.Button>
                    </Radio.Group>
                </div>

                <Content className={s.content}>
                    <div className={s.color}>
                        {this.isSplit ? this.getSplitContent()
                            : this.getUnifiedRenderContent()}
                    </div>
                </Content>
            </React.Fragment>
        )
    }
}

以上的部分将lineGroup中的每个对象的content依次根据head,hidden,tail三部分来渲染,行数根据先前计算的lStartNumrStartNum来进行展示。

分栏模式下的内容展示

接下来是分栏的实现:

export default class ContentDiff extends React.Component {

    //  获取split下的页码node
    getLNPadding = (origin) => {
        const item = ('     ' + origin).slice(-5);
        return <div className={cx(s.splitLN)}>{item}</div>
    }

    //  差异部分的代码渲染
    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 <React.Fragment>
                <div className={cx(s.iBlock, s.lBorder)}>{lArr.map((item, index) => {
                    //  渲染左半边内容,也就是删除的部分(如果有的话)
                    //  两个div分别输出行数和内容
                    return <div className={cx(s.prBlock, lClass)} key={index}>
                        {this.getLNPadding(lLeftPos + index)}
                        {this.getPaddingContent('-  ' + item)}
                    </div>
                })}</div>
                <div className={cx(s.iBlock, lArr.length ? '' : s.rBorder)}>{rArr.map((item, index) => {
                    //  渲染右半边内容,也就是新增的部分(如果有的话)
                    return <div className={cx(s.prBlock, rClass)} key={index}>
                        {this.getLNPadding(rRightPos + index)}
                        {this.getPaddingContent('+  ' + item)}
                    </div>
                })}</div>
            </React.Fragment>
    }

    //  无变化部分的代码渲染
    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 <div key={(isHead ? 'h-' : 't-') + index}>
                <div className={cx(s.iBlock, s.lBorder)}>{this.getLNPadding(leftPos + shift + index)}{this.getPaddingContent('    ' + item)}</div>
                <div className={s.iBlock}>{this.getLNPadding(rightPos + shift +index)}{this.getPaddingContent('    ' + item)}</div>
            </div>
        })
    }

    //  渲染分栏的代码
    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(<div key={i}>
                    {this.getSplitCode(targetBlock)}
                    {hidden.length && this.getHiddenBtn(hidden, i) || null}
                    {this.getSplitCode(targetBlock, false)}
                </div>)
            } else if (type === '-') {
                //  渲染移除的部分
                const nextTarget = this.state.lineGroup[i + 1] || { content: {}};
                const nextIsPlus = nextTarget.type === '+';
                contentList.push(<div key={i}>
                    {this.getCombinePart(targetBlock, nextIsPlus ? nextTarget : {})}
                </div>)
                nextIsPlus ? i = i + 1 : void 0;
            } else if (type === '+') {
                //  渲染新增的部分
                contentList.push(<div key={i}>
                    {this.getCombinePart({}, targetBlock)}
                </div>)
            }
        }
        return <div>
            {contentList}
        </div>
    }

    //  省略重复代码
}

这里的展示方式和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 <div key='collapse' className={s.cutWrapper}>
            <div className={cx(s.colLeft, this.isSplit ? s.splitWidth : '')}>
                {isSingle ? <div className={s.arrow} onClick={this.openBlock.bind(this, 'all', index)}>
                    {/* 双向箭头 */}
                    <svg className={s.octicon} viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fillRule="evenodd" d="M8.177.677l2.896 2.896a.25.25 0 01-.177.427H8.75v1.25a.75.75 0 01-1.5 0V4H5.104a.25.25 0 01-.177-.427L7.823.677a.25.25 0 01.354 0zM7.25 10.75a.75.75 0 011.5 0V12h2.146a.25.25 0 01.177.427l-2.896 2.896a.25.25 0 01-.354 0l-2.896-2.896A.25.25 0 015.104 12H7.25v-1.25zm-5-2a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM6 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 016 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM12 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 0112 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5z"></path></svg>
                </div>
                    : <React.Fragment>
                        {/* 向上的箭头 */}
                        <div className={s.arrow} onClick={this.openBlock.bind(this, 'head', index)}>
                            <svg className={s.octicon} viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fillRule="evenodd" d="M8.177 14.323l2.896-2.896a.25.25 0 00-.177-.427H8.75V7.764a.75.75 0 10-1.5 0V11H5.104a.25.25 0 00-.177.427l2.896 2.896a.25.25 0 00.354 0zM2.25 5a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM6 4.25a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5a.75.75 0 01.75.75zM8.25 5a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM12 4.25a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5a.75.75 0 01.75.75zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5z"></path></svg>
                        </div>
                        {/* 向下的箭头 */}
                        <div className={s.arrow} onClick={this.openBlock.bind(this, 'tail', index)}>
                            <svg className={s.octicon} viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fillRule="evenodd" d="M7.823 1.677L4.927 4.573A.25.25 0 005.104 5H7.25v3.236a.75.75 0 101.5 0V5h2.146a.25.25 0 00.177-.427L8.177 1.677a.25.25 0 00-.354 0zM13.75 11a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zm-3.75.75a.75.75 0 01.75-.75h.5a.75.75 0 010 1.5h-.5a.75.75 0 01-.75-.75zM7.75 11a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM4 11.75a.75.75 0 01.75-.75h.5a.75.75 0 010 1.5h-.5a.75.75 0 01-.75-.75zM1.75 11a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5z"></path></svg>
                        </div>
                    </React.Fragment>
                }
            </div>
            <div className={cx(s.collRight, this.isSplit ? s.collRightSplit : '')}><div className={cx(s.colRContent, isSingle ? '' : s.cRHeight)}>{`当前隐藏内容:${hidden.length}行`}</div></div>
        </div>
    }
}

这里直接搬运了git官网的svg箭头图片,查看更多的交互一共有三种,折叠内容多于10行的,分别显示上下箭头,每点击一次多展示5行内容,一旦隐藏内容少于10行,显示双向箭头,此时点击将展示所有的折叠内容。这一部分的核心逻辑是可复用的,splited和unified内容皆可以使用,只是在UI的处理上需要有一定的差别。

UI细节

在编码过程中遇到一个问题,diff库处理之后的value是包含空格的,类似于这样 const isSingle = true;但是在展示时div标签默认是会合并(trim)掉开头的空格的,这里有两种方法:

  • 使用<pre>标签包裹内容:使用这个标签包裹的内容将会展示其内部的真实内容,不会有其他逻辑,不过这个标签同于div,在字体样式等方面会有微小的差异(chrome下如此,其他浏览器未确认)

  • 在div样式添加white-space: pre-wrap;这样也可以避免内部内容部分的空格被合并成一个。

相关资料

diff库官方文档
diff使用指南
演示站点
演示站点代码仓库