手動實(shí)現(xiàn)高仿github的內(nèi)容diff效果
來源:廣蘭路地鐵? https://juejin.im/post/6857316059851325453
前言
最近發(fā)現(xiàn)了一個比較好用的內(nèi)容diff庫(就叫diff),非常方便js開發(fā)者實(shí)現(xiàn)文本內(nèi)容的diff,既可以直接簡單輸出格式化的字符串比較內(nèi)容,也可以輸出較為復(fù)雜的changes數(shù)據(jù)結(jié)構(gòu),方便二次開發(fā)。這里筆者就基于這個庫實(shí)現(xiàn)高仿github的文本diff效果。
效果演示
實(shí)現(xiàn)了代碼展開,單列和雙列對比等功能。示例如下:

代碼演示站點(diǎn):http://tangshisanbaishou.xyz/diff/index.html
如何實(shí)現(xiàn)
核心原理
最核心的文本diff算法,由diff庫替我們實(shí)現(xiàn),這里我們使用的是diffLines方法(關(guān)于diff庫的使用,筆者有一篇博文diff使用指南有詳細(xì)介紹)。通過該庫輸出的數(shù)據(jù)結(jié)構(gòu),對其進(jìn)行二次開發(fā),以便實(shí)現(xiàn)類似gitHub的文件diff效果。
獲取輸入
這里我們的比較內(nèi)容都是以字符串的形式進(jìn)行輸入。至于如何將文件轉(zhuǎn)化成字符串,在瀏覽器端可以使用Upload進(jìn)行文件上傳,然后在獲得的文件句柄上調(diào)用text方法,即可獲得文件對應(yīng)的字符串,類似這樣:
import?React?from?'react';
import?{?Upload?}?from?'antd';
//??不一定要用react和antd,就是表達(dá)下思路
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={()?=>?{}}
????????>
????????????點(diǎn)我上傳1
????????</Upload>
????}
}
在node端就要方便很多了,調(diào)用fs(文件系統(tǒng)庫),直接對文件流進(jìn)行讀取即可。
輸出結(jié)構(gòu)分析
接下來我們看看diffLines的輸出大致長什么樣:

這里我們對輸出結(jié)果進(jìn)行分析,輸出是一個數(shù)組,數(shù)組的對象有多個屬性:
value: 表示代碼塊的具體內(nèi)容 count: 表示該代碼塊的行數(shù) added: 如果該代碼塊為新增內(nèi)容,其值為true removed:如果該代碼塊表示移除的內(nèi)容,其值為true
到這里我們的實(shí)現(xiàn)思路已經(jīng)大致成型:根據(jù)數(shù)組內(nèi)容渲染代碼塊,以\n為分隔符,劃分代碼行,added部分標(biāo)綠,removed部分標(biāo)紅,其余部分正常顯示即可,至于具體的代碼行數(shù),可以根據(jù)count進(jìn)行計(jì)算。
代碼實(shí)現(xiàn)
原始數(shù)據(jù)處理
如果參與比較的文件過大,公共部分的代碼中過長的部分需要進(jìn)行折疊,新增和移除的代碼需要全量展示,基于這個邏輯,我們將需要展示的代碼做如下劃分:

確定了我們的展示邏輯,接下來需要做的就是針對diff庫處理之后的數(shù)據(jù)進(jìn)行處理,相關(guān)代碼如下:
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?=?{
????????//??供渲染的數(shù)據(jù)
????????lineGroup:?[],
????????//??展示的類型
????????showType:?SHOW_TYPE.UNIFIED
????}
????//??刷新供渲染的數(shù)據(jù)
????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)?||?[];
????????????//??獲得當(dāng)前數(shù)據(jù)塊的類型+標(biāo)識新增?-表示移除?空格表示相同的內(nèi)容
????????????const?type?=?(added?&&?'+')?||?(removed?&&?'-')?||?'?';
????????????//??定義代碼塊的內(nèi)部結(jié)構(gòu),分為頭部,尾部和中間的隱藏部分
????????????let?head,?hidden,?tail;
????????????//??如果是增加或者減少的代碼塊,頭部填入內(nèi)容,尾部和隱藏區(qū)域都為空
????????????if?(type?!==?'?')?{
????????????????hidden?=?[];
????????????????tail?=?[];
????????????????head?=?strArr;
????????????}?else?{
????????????????const?strLength?=?strArr.length;
????????????????//??如果公共部分的代碼量過少,就統(tǒng)一展開
????????????????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,
????????????????//??代碼行數(shù)
????????????????count,
????????????????//??內(nèi)容區(qū)塊
????????????????content:?{
????????????????????hidden,
????????????????????head,
????????????????????tail
????????????????}
????????????}
????????});
????????//??接下來處理代碼的行數(shù),標(biāo)記左右兩側(cè)代碼塊的初始行數(shù)
????????let?lStartNum?=?1;
????????let?rStartNum?=?1;
????????initLineGroup.forEach(item?=>?{
????????????const?{?type,?count?}?=?item;
????????????item.leftPos?=?lStartNum;
????????????item.rightPos?=?rStartNum;
????????????//??移除代碼和新增代碼的兩部分分開計(jì)算
????????????lStartNum?+=?type?===?'+'???0?:?count;
????????????rStartNum?+=?type?===?'-'???0?:?count;
????????})
????????this.setState({
????????????lineGroup:?initLineGroup
????????});
????}
????render()?{
????????return?(
????????????//??...
????????)
????}
}
通過上述代碼完成對原始數(shù)據(jù)的處理,將表示內(nèi)容的數(shù)組中的對象劃分為三種:added,removed和公共代碼,并將內(nèi)容分成head,hidden和tail三部分(主要是為了公共代碼部分隱藏冗余的代碼),然后計(jì)算代碼塊在對比顯示時(shí)的初始行數(shù)行數(shù),分欄(splited)和整合(unified)模式下都可使用。
整合模式下的內(nèi)容展示
接下來是整合模式的展示代碼:
export?default?class?ContentDiff?extends?React.Component?{
????state?=?{
????????//??供渲染的數(shù)據(jù)
????????lineGroup:?[],
????????//??展示的類型
????????showType:?SHOW_TYPE.UNIFIED
????}
????//??轉(zhuǎn)換展示模式
????handleShowTypeChange?=?(e)?=>?{
????????this.setState({
????????????showType:?e.target.value
????????})
????}
????//??判斷狀態(tài)
????get?isSplit()?{
????????return?this.state.showType?===?SHOW_TYPE.SPLITED;
????}
????//??刷新供渲染的數(shù)據(jù)
????flashContent?=?(newArr)?=>?{
????????//??省略重復(fù)內(nèi)容
????}
????//??給行號補(bǔ)足位數(shù)
????getLineNum?=?(number)?=>?{
????????return?('?????'?+?number).slice(-5);
????}
????//??獲取split下的內(nèi)容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?===?'?';
????????//??根據(jù)類型選擇合適的class
????????const?cls?=?cx(s.normal,?type?===?'+'???s.add?:?'',?type?===?'-'???s.removed?:?'');
????????//??占位空格
????????const?space?=?"?????";
????????//??渲染頭部或者尾部內(nèi)容
????????return?(isHead???head?:?tail).map((sitem,?sindex)?=>?{
????????????let?posMark?=?'';
????????????if?(isNormal)?{
????????????????//??計(jì)算行號的偏移值
????????????????const?shift?=?isHead???0:?(head.length?+?hidden.length);
????????????????//??左右兩側(cè)的行數(shù)不一定一樣
????????????????posMark?=?(space?+?(leftPos?+?shift?+?sindex)).slice(-5)
????????????????????+?(space?+?(rightPos?+?shift?+?sindex)).slice(-5);
????????????}?else?{
????????????????//??增減部分的行號計(jì)算
????????????????posMark?=?type?===?'-'???this.getLineNum(leftPos?+?sindex)?+?space
????????????????????:?space?+?this.getLineNum(rightPos?+?sindex);
????????????}
????????????//??依次渲染行號,+?-號和代碼內(nèi)容
????????????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?=?()?=>?{
????????//??根據(jù)lineGroup的內(nèi)容依次渲染代碼塊
????????return?this.state.lineGroup.map((item,?index)?=>?{
????????????const?{?type,?content:?{?hidden?}}?=?item;
????????????const?isNormal?=?type?===?'?';
????????????//??依次渲染head,hidden,tail三部分內(nèi)容
????????????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}>UnifiedRadio.Button>
????????????????????????<Radio.Button?value={SHOW_TYPE.SPLITED}>SplitRadio.Button>
????????????????????Radio.Group>
????????????????div>
????????????????<Content?className={s.content}>
????????????????????<div?className={s.color}>
????????????????????????{this.isSplit???this.getSplitContent()
????????????????????????????:?this.getUnifiedRenderContent()}
????????????????????div>
????????????????Content>
????????????React.Fragment>
????????)
????}
}
以上的部分將lineGroup中的每個對象的content依次根據(jù)head,hidden,tail三部分來渲染,行數(shù)根據(jù)先前計(jì)算的lStartNum和rStartNum來進(jìn)行展示。
分欄模式下的內(nèi)容展示
接下來是分欄的實(shí)現(xiàn):
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;
????????//??分別獲取左右兩側(cè)對應(yīng)的內(nèi)容和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)?=>?{
????????????????????//??渲染左半邊內(nèi)容,也就是刪除的部分(如果有的話)
????????????????????//??兩個div分別輸出行數(shù)和內(nèi)容
????????????????????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)?=>?{
????????????????????//??渲染右半邊內(nèi)容,也就是新增的部分(如果有的話)
????????????????????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?????????????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>
????}
????//??省略重復(fù)代碼
}
這里的展示方式和unified模式下略有不同。公共部分和差異部分要使用不同的渲染函數(shù),相同的部分代碼要對齊,差異的部分左右兩側(cè)需要等高。
展開摁鈕的實(shí)現(xiàn)
接下來我們實(shí)現(xiàn)點(diǎn)擊展開的功能:
export?default?class?ContentDiff?extends?React.Component?{
????//??省略重復(fù)的內(nèi)容
????//??根據(jù)三種點(diǎn)擊的狀態(tài),更新head,tail和hidden的內(nèi)容
????openBlock?=?(type,?index)?=>?{
????????const?copyOfLG?=?this.state.lineGroup.slice();
????????const?targetGroup?=?copyOfLG[index];
????????const?{?head,?tail,?hidden?}?=?targetGroup.content;
????????if?(type?===?'head')?{
????????????//??如果是點(diǎn)擊向上的箭頭,對head和hidden部分的內(nèi)容進(jìn)行更新
????????????targetGroup.content.head?=?head.concat(hidden.slice(0,?BLOCK_LENGTH));
????????????targetGroup.content.hidden?=?hidden.slice(BLOCK_LENGTH);
????????}?else?if?(type?===?'tail')?{
????????????//??如果是點(diǎn)擊向下的箭頭,對tail和hidden的部分進(jìn)行更新
????????????const?hLenght?=?hidden.length;
????????????targetGroup.content.tail?=?hidden.slice(hLenght?-?BLOCK_LENGTH).concat(tail);
????????????targetGroup.content.hidden?=?hidden.slice(0,?hLenght?-?BLOCK_LENGTH);
????????}?else?{
????????????//??如果是雙向箭頭,展開所有的內(nèi)容到head
????????????targetGroup.content.head?=?head.concat(hidden);
????????????targetGroup.content.hidden?=?[];
????????}
????????copyOfLG[index]?=?targetGroup;
????????this.setState({
????????????lineGroup:?copyOfLG
????????});
????}
????//??渲染隱藏的部分
????getHiddenBtn?=?(hidden,?index)?=>?{
????????//??如果隱藏的內(nèi)容過少,則顯示雙向箭頭
????????const?isSingle?=?hidden.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)}>{`當(dāng)前隱藏內(nèi)容:${hidden.length}行`}div>div>
????????div>
????}
}
這里直接搬運(yùn)了git官網(wǎng)的svg箭頭圖片,查看更多的交互一共有三種,折疊內(nèi)容多于10行的,分別顯示上下箭頭,每點(diǎn)擊一次多展示5行內(nèi)容,一旦隱藏內(nèi)容少于10行,顯示雙向箭頭,此時(shí)點(diǎn)擊將展示所有的折疊內(nèi)容。這一部分的核心邏輯是可復(fù)用的,splited和unified內(nèi)容皆可以使用,只是在UI的處理上需要有一定的差別。
UI細(xì)節(jié)
在編碼過程中遇到一個問題,diff庫處理之后的value是包含空格的,類似于這樣 const isSingle = true;但是在展示時(shí)div標(biāo)簽?zāi)J(rèn)是會合并(trim)掉開頭的空格的,這里有兩種方法:
使用
標(biāo)簽包裹內(nèi)容:使用這個標(biāo)簽包裹的內(nèi)容將會展示其內(nèi)部的真實(shí)內(nèi)容,不會有其他邏輯,不過這個標(biāo)簽同于div,在字體樣式等方面會有微小的差異(chrome下如此,其他瀏覽器未確認(rèn))在div樣式添加
white-space: pre-wrap;這樣也可以避免內(nèi)部內(nèi)容部分的空格被合并成一個。
分享前端好文,點(diǎn)亮?在看?
