前言
最近需求上想用一个查看代码 Diff 的组件(inline diff 模式),试了 Monaco Editor
、CodeMirror
、Diff2Html
效果都一般,虽然各有各的优点,但总那么一些不太符合我想要的地方。
- MonacoEditor: 不能折叠,看长文本的情况下不太方便一眼看出那里有更改,而且有点重。
- Diff2Html:有折叠有高亮,看似不错,但折叠不能展开,且样式上看起来不够精致。
咋办呢?我调研了一下 Diff2Html
这个库,其实它是提供了一些 API,可以自定义样式、规则等能力。但如果要基于 Diff2Html
修改成我想要的样子,改动成本很高。
所以,干脆还是自己实现一个吧,看起来也不算很复杂。
那么,开搞!话不多说,先看效果!

这是基于 Gitlab 的样式和折叠机制写的一个 Vue
的组件,支持行内 Diff,未改动代码折叠和展开,方便更直观的一下看到整个文件的改动。
实现思路
整体的实现思路来说主要分为以下几步:
Diff 原文本和现文本 -> 处理两份文本的变化 -> 高亮关键字 -> 渲染结果
如果这一整个流程完全都自己做,还是很复杂的,所以就找了 jsdiff
和 highlight.js
分别来实现「Diff」和「高亮」这两个部分。
基于这个思路,完整流程如下:
jsdiff 处理原文本和现文本 -> 解析 Diff 结果 -> 生成多行视图数据 -> 根据 Diff 结果高亮行内文本 -> 折叠未变化的文本 -> 高亮关键字 -> 渲染结果
具体实现
首先,我把每行的视图渲染数据结构定义为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| export interface ILineModel { type: string; }
export interface CodeLineModel extends ILineModel { type: "add" | "delete" | "normal"; oldLineNumber: string; nowLineNumber: string; content: string; highlightStartIndex?: number[]; highlightLength?: number[]; }
export interface CollapsedLineModel extends ILineModel { type: "collapsed";
lines: CodeLineModel[];
lineCount: number;
isCollapsed: boolean; }
|
- CodeLineModel: 一行内容,
type
为那一行的变更类型,可以是「新增」(add)、「移除」(delete)或「未变更」(normal),其余字段可顾名思义。
- CollapsedLineModel: 一行可折叠的内容,
lines
代表这个折叠行所折叠的内容。在展开的时候,就把 lines 中的内容渲染至视图。
jsdiff 处理原文本和现文本
在 Diff 操作上,jsdiff
提供了很多接口以供使用,这里使用和 git diff 同样的策略 line diff
1
| const result = Diff.diffLines(this.lastContent, this.content);
|
解析 Diff 结果并生成视图数据
上文 result
就是 Diff 的结果了,结果是一个 Change[]
类型,接下来,就要一条一条的解析结果了。
1 2 3 4 5 6 7 8
| export function parseLineChanges(diffChanges: Change[]): CodeLineModel[] { init(); diffChanges.forEach((change) => { processNext(change); }); attachInlineHighlight(); return [...lineModels]; }
|
这里我会将变更信息全部转为 CodeLineModel
,代表一行一行的内容。(折叠在最后处理)
核心实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| function processNext(change: Change) { if (change.added) { for (let i = 0; i < change.count!; i++) { const content = change.value.split("\n")[i]; currentNowNumber++; setLineMapping("now", currentNowNumber, content); currentLine = createLine("add", "", currentNowNumber.toString(), content); emitCodeModel(); } } else if (change.removed) { for (let i = 0; i < change.count!; i++) { const content = change.value.split("\n")[i]; currentOldNumber++; setLineMapping("old", currentOldNumber, content); currentLine = createLine( "delete", currentOldNumber.toString(), "", content ); emitCodeModel(); } } else { for (let i = 0; i < change.count!; i++) { const content = change.value.split("\n")[i]; currentNowNumber++; currentOldNumber++; setLineMapping("old", currentOldNumber, content); setLineMapping("now", currentNowNumber, content); currentLine = createLine( "normal", currentOldNumber.toString(), currentNowNumber.toString(), content ); emitCodeModel(); } } }
|
在这一步处理之后,就会生成 CodeLineModel[]
视图数据了,现在的 CodeLineModel
已经可以直接拿去渲染了。但还没有行内高亮和折叠的功能,所以接下来就基于 CodeLineModel[]
做进一步的处理。
根据 Diff 结果高亮行内文本
这一步会将每一行的内容做处理,生成行内高亮数据,也就是 highlightStartIndex
和 highlightLength
部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function attachInlineHighlight() { lineModels.forEach((lineModel: CodeLineModel) => { if (lineModel.type === "add") { const mapping = lineMapping[Number(lineModel.nowLineNumber)]; const { index, length } = diffLine("add", mapping.old, mapping.now); lineModel.highlightStartIndex = index; lineModel.highlightLength = length; } else if (lineModel.type === "delete") { const mapping = lineMapping[Number(lineModel.oldLineNumber)]; const { index, length } = diffLine("delete", mapping.old, mapping.now); lineModel.highlightStartIndex = index; lineModel.highlightLength = length; } }); }
|
折叠未变化的文本
上文处理完后,已经完成了 Diff 组件的大半部分了,接下来就要对一些未改变的行进行折叠。
那么,如果知道哪些行要折叠呢?
经过之前的数据处理,可以得出,CodeLineModel
中,type = normal
的行,即为未变更的行,那么就可以用一个二维数组,将所有连续未变化的行,做一个分组,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| private processCollapsed<T extends ILineModel>(lineData: T[]): T[] { let lastIsNormal = true; let arrs: Array<T[]> = []; let currentArr: T[] = []; for (let i = 0; i < lineData.length; i++) { const model = lineData[i]; if (model.type === "add" || model.type === "delete") { if (lastIsNormal) { arrs.push(currentArr); currentArr = []; } currentArr.push(model); lastIsNormal = false; } else if (model.type === "normal") { if (!lastIsNormal) { arrs.push(currentArr); currentArr = []; } currentArr.push(model); lastIsNormal = true; } } arrs.push(currentArr); const result = collapsedCodeGroup(arrs, this.contextLineCount); return [...result]; }
|
currentArr
- 连续未变更行组成的数组
arrs
- 多个 currentArr
组成数组
处理完成后,就要把 arrs
中的分组数据做一个过滤和筛选,例如折叠行数 > 10 (contextLineCount
) 的才折叠等,这部分的代码就是 collapsedCodeGroup
中所处理的逻辑。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
|
function collapsedCodeGroup<T extends ILineModel>( arrs: Array<T[]>, context: number ): T[] { const result: T[] = []; const collapseLimit = context * 2 + 1; arrs.forEach((group, index) => { const isNormalGroup = group.length > 0 && group[0].type === "normal"; const isLastGroup = index === arrs.length - 1; const isFirstGroup = index === 0; if (group.length < collapseLimit || !isNormalGroup) { result.push(...group); return; } const collapsedLineModel: any = { type: "collapsed", lines: [], isCollapsed: true, lineCount: 0, }; let lines; let deleteCount; if (isFirstGroup) { deleteCount = group.length - context * 1; lines = group.splice(0, deleteCount, collapsedLineModel); } else if (isLastGroup) { deleteCount = group.length - context * 1; lines = group.splice(context, deleteCount, collapsedLineModel); } else { deleteCount = group.length - context * 2; lines = group.splice(context, deleteCount, collapsedLineModel); } collapsedLineModel.lines = lines; collapsedLineModel.lineCount = lines.length; result.push(...group); }); return result; }
|
context
为折叠代码的上下文,由外部传入。例如 context = 3
,那么至少有 3*2 + 1 = 7
行代码时才折叠,少于 7 行就不折叠。且在文本首尾要做特殊处理。
高亮关键字并渲染结果
高亮关键字由于使用的是 highlight.js
库,它需要我们在结果渲染之后再高亮,所以仅需如下代码即可:
1 2 3 4 5
| this.$nextTick(() => { this.root.querySelectorAll("code").forEach((el) => { hljs.highlightElement(el as any); }); });
|
这里将我们每一行的 <code>
标签找到后交给 highlight.js
处理即可。
结果渲染:
template 模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <template> <span class="line"> <span class="line-number" :class="{ add: code.type === 'add', delete: code.type === 'delete' }" > {{ code.oldLineNumber }}</span > <span class="line-number" :class="{ add: code.type === 'add', delete: code.type === 'delete' }" > {{ code.nowLineNumber }}</span > <code-content class="line-content" :class="{ add: code.type === 'add', delete: code.type === 'delete' }" :code="code" /> </span> </template>
|
CodeContent
组件代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| @Component export default class CodeContent extends Vue { @Prop({ type: Object, required: true, }) code!: CodeLineModel;
render() { if (this.code.type === "add" || this.code.type === "delete") { return this.segmentCodeByIndex(); } else { return this.getCodeTemplate(this.code.content); } }
private getCodeTemplate(content: string, className?: string) { return <code class={className ?? ""}>{content}</code>; }
private segmentCodeByIndex() { const content = this.code.content; const items: any[] = []; if (!this.code.highlightStartIndex) { return this.getCodeTemplate(content); } let lastStartIndex = 0; this.code.highlightStartIndex!.forEach((startIndex, index) => { const length = this.code.highlightLength![index]; items.push(content.substring(lastStartIndex, startIndex)); items.push( this.getCodeTemplate(content.substr(startIndex, length), "hl") ); lastStartIndex = startIndex + length; }); items.push(content.substring(lastStartIndex, content.length)); return <code>{items}</code>; } }
|
CodeContent
部分主要对 add
和 delete
类型的行内 Diff 做了高亮处理。
整体来说,渲染结果这块并不复杂,主要是根据类型调整样式,还有高亮操作。
最后
….