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

来源:广兰路地铁 

https://juejin.im/post/6857316059851325453

前言

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

效果演示

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

手动实现高仿github的内容diff效果_第1张图片

代码演示站点:http://tangshisanbaishou.xyz/diff/index.html

如何实现

核心原理

最核心的文本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的输出大致长什么样:

手动实现高仿github的内容diff效果_第2张图片

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

  • value: 表示代码块的具体内容

  • count: 表示该代码块的行数

  • added: 如果该代码块为新增内容,其值为true

  • removed:如果该代码块表示移除的内容,其值为true

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

代码实现

原始数据处理

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

手动实现高仿github的内容diff效果_第3张图片

确定了我们的展示逻辑,接下来需要做的就是针对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三部分来渲染,行数根据先前计算的lStartNumrStartNum来进行展示。

分栏模式下的内容展示

接下来是分栏的实现:

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)掉开头的空格的,这里有两种方法:

最后

欢迎关注「前端瓶子君」,回复「交流」加入前端交流群!

欢迎关注「前端瓶子君」,回复「算法」自动加入,从0到1构建完整的数据结构与算法体系!

在这里(算法群),你可以每天学习一道大厂算法编程题(阿里、腾讯、百度、字节等等)或 leetcode,瓶子君都会在第二天解答哟!

另外,每周还有手写源码题,瓶子君也会解答哟!

》》面试官也在看的算法资料《《

“在看和转发”就是最大的支持

你可能感兴趣的:(js,html,javascript,java,react)