实现了一个查看代码 Diff 的组件

前言

最近需求上想用一个查看代码 Diff 的组件(inline diff 模式),试了 Monaco EditorCodeMirrorDiff2Html 效果都一般,虽然各有各的优点,但总那么一些不太符合我想要的地方。

  • MonacoEditor: 不能折叠,看长文本的情况下不太方便一眼看出那里有更改,而且有点重。
  • Diff2Html:有折叠有高亮,看似不错,但折叠不能展开,且样式上看起来不够精致。

咋办呢?我调研了一下 Diff2Html 这个库,其实它是提供了一些 API,可以自定义样式、规则等能力。但如果要基于 Diff2Html 修改成我想要的样子,改动成本很高。
所以,干脆还是自己实现一个吧,看起来也不算很复杂。
那么,开搞!话不多说,先看效果!

这是基于 Gitlab 的样式和折叠机制写的一个 Vue 的组件,支持行内 Diff,未改动代码折叠和展开,方便更直观的一下看到整个文件的改动。

实现思路

整体的实现思路来说主要分为以下几步:

Diff 原文本和现文本 -> 处理两份文本的变化 -> 高亮关键字 -> 渲染结果

如果这一整个流程完全都自己做,还是很复杂的,所以就找了 jsdiffhighlight.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 结果高亮行内文本

这一步会将每一行的内容做处理,生成行内高亮数据,也就是 highlightStartIndexhighlightLength 部分。

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
/**
* 折叠超过最大行数限制的普通代码
* @param arrs 分组后的代码数据
*/
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 部分主要对 adddelete 类型的行内 Diff 做了高亮处理。
整体来说,渲染结果这块并不复杂,主要是根据类型调整样式,还有高亮操作。

最后

….