结果图
背景
某天要给管理后台的数据配置添加一个审核功能,为了更好的审核,就需要看到数据差异,于是就要开发一款对比差异的页面
调研
- 调研有没有现成的组件,可以适合
vue3,可以实现字符级的精度差异 - 评估自研成本(有AI的加入,成本大幅度降低)
调研结果
主要还是先通过AI粗略得到一些结果,然后进行分析
- 现成组件:vue-diff,这个组件本质用的是diff-match-patch和highlight.js,也是用
vue3语法写的 - 自研:diff、diff-match-patch,这俩个依赖可以实现差异对比
技术栈
vue3、unocss、diff、diff-match-patch
diff实现差异对比组件
用diff实现差异对比,可以更好的兼容vue2、vue3
这里有个关键点关于diff,他有三个级别的差异精度
char,精度最高,为实现字符级别的差异line,精度一般,为行之间的差异word,进度较高,为单词之间的差异
这里我会分别展示字符级和行级的代码
diffChars实现字符级精度对比
核心思路:重组原来的行数据,并得到每个字符的新增、删除特征
子组件-Diff.vue
<script lang="ts" setup>
import type { Change } from 'diff'
import { diffChars } from 'diff'
const props = defineProps<{
oldData: unknown
newData: unknown
}>()
const oldStr = computed(() => safeStringify(props.oldData))
const newStr = computed(() => safeStringify(props.newData))
const diffCharList = computed<Change[]>(() => diffChars(oldStr.value, newStr.value))
function safeStringify(value: unknown): string {
const seen = new WeakSet()
try {
return JSON.stringify(
value,
function (_key, val) {
// stringify无法处理函数、symbol、循环引用等情况
if (typeof val === 'function') return val.toString() // 看业务需求, 可返回'[Function]'
if (typeof val === 'symbol') return val.toString() // 直接调用toString更快,因为他不是字符串
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return `[Circular -> ${_key}]` // 避免循环引用
seen.add(val)
}
return val
},
2
)
} catch {
return typeof value === 'object' ? '[Object parse error]' : String(value)
}
}
interface Segment {
text: string
added?: boolean
removed?: boolean
}
function buildLines() {
const oldLines: { segments: Segment[]; changed: boolean }[] = []
const newLines: { segments: Segment[]; changed: boolean }[] = []
const oldCurrent: Segment[] = []
const newCurrent: Segment[] = []
const pushLine = () => {
oldLines.push({
segments: [...oldCurrent],
changed: oldCurrent.some((s) => s.removed),
})
newLines.push({
segments: [...newCurrent],
changed: newCurrent.some((s) => s.added),
})
oldCurrent.length = 0
newCurrent.length = 0
}
diffCharList.value.forEach((part) => {
const lines = part.value.split(/(?<=\n)/)
lines.forEach((line, i) => {
const isNewLine = line.endsWith('\n')
if (part.added) {
newCurrent.push({ text: line, added: true })
if (isNewLine) pushLine()
} else if (part.removed) {
oldCurrent.push({ text: line, removed: true })
if (isNewLine) pushLine()
} else {
oldCurrent.push({ text: line })
newCurrent.push({ text: line })
if (isNewLine) pushLine()
}
})
})
if (oldCurrent.length || newCurrent.length) pushLine()
return { oldLines, newLines }
}
const lineDiff = computed(() => buildLines()) // 不解构,保留响应
</script>
<template>
<div class="font-mono text-14 lh-32 flex overflow-auto b b-gray-300 bg-white">
<!-- 旧数据 -->
<div class="flex-1 p-4 b-r b-gray-300 overflow-x-auto">
<div
v-for="(line, index) in lineDiff.oldLines"
:key="'old-' + index"
:class="{
'bg-red-100': line.changed,
}"
class="whitespace-pre-wrap"
>
<template v-for="(segment, i) in line.segments" :key="i">
<span
:class="{
'bg-red-300 line-through': segment.removed,
}"
>
{{ segment.text }}
</span>
</template>
</div>
</div>
<!-- 新数据 -->
<div class="flex-1 p-4 overflow-x-auto">
<div
v-for="(line, index) in lineDiff.newLines"
:key="'new-' + index"
:class="{
'bg-green-100': line.changed,
}"
class="whitespace-pre-wrap"
>
<template v-for="(segment, i) in line.segments" :key="i">
<span :class="segment.added ? 'bg-green-300' : ''">
{{ segment.text }}
</span>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>
父组件
<template>
<Diff :old-data="diffObj.oldData" :new-data="diffObj.newData" />
</template>
<script lang="ts" setup>
const diffObj = ref({
oldData: {
name: 'Alice',
age: 25,
address: {
city: 'New York',
zip: '10001',
},
tags: ['developer', 'frontend', new Date().getTime(), Math.random()],
text: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂了发啥地方撒的',
text2: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂的发啥地方撒的',
nam1e: 'Alice',
age1: 25,
address1: {
city: 'New York',
zip: '10001',
},
tags1: ['developer', 'frontend', new Date().getTime(), Math.random()],
text11: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂了发啥地方撒的',
text112: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂的发啥地方撒的',
},
newData: {
name: 'Alicia', // 修改
age: 26, // 修改
address: {
city: 'San Francisco', // 修改
zip: '94105', // 修改
},
tags: ['developer', 'fullstack', new Date().getTime(), Math.random()], // 修改数组项
text: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂了发啥地方撒的',
text2: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂了发啥地方撒的',
nam1e: 'Alice',
ag1e: 25,
ad1dress: {
city: 'New York',
zip: '10001',
},
ta1gs: ['developer', 'frontend', new Date().getTime(), Math.random()],
te1xt: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂了发啥地方撒的',
tex1t2: 'hello world fsdkafjs撒放假就疯狂拉萨大家疯狂的发啥地方撒的',
active: true, // 新增字段
},
})
</script>
diffLines实现行级的精度
结果图
可以看到只有行级别的对比差异,没有字符级的对比差异
diffLines可以得到行差异的列表,可以直接渲染,通过remove和add进行区分删除或新增行,如果2者都为false,说明是公共数据,也要渲染出来
可以和diffChars的实现进行区分
子组件-Diff.vue
<script setup lang="ts">
import type { Change } from 'diff'
import { diffLines } from 'diff'
const props = defineProps<{
oldData: unknown
newData: unknown
}>()
const oldStr = computed(() => `${JSON.stringify(props.oldData, null, 2)}\n`)
const newStr = computed(() => `${JSON.stringify(props.newData, null, 2)}\n`)
const diffs = computed<Change[]>(() => diffLines(oldStr.value, newStr.value))
</script>
<template>
<div class="diff-container">
<div class="diff-column">
<div
v-for="(part, index) in diffs"
:key="index"
class="diff-line"
:class="{
removed: part.removed,
unchanged: !part.added && !part.removed,
}"
>
<pre v-if="!part.added">{{ part.value }}</pre>
</div>
</div>
<div class="diff-column">
<div
v-for="(part, index) in diffs"
:key="index"
class="diff-line"
:class="{
added: part.added,
unchanged: !part.added && !part.removed,
}"
>
<pre v-if="!part.removed">{{ part.value }}</pre>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.diff-container {
display: flex;
overflow: auto;
border: 1px solid #e1e4e8;
background: #fff;
font-family: monospace;
}
.diff-column {
overflow-x: auto;
flex: 1;
padding: 8px;
border-right: 1px solid #e1e4e8;
}
.diff-column:last-child {
border-right: none;
}
.diff-line {
word-break: break-word;
white-space: pre-wrap;
}
.diff-line.removed {
background-color: #ffeef0;
}
.diff-line.added {
background-color: #e6ffed;
}
.diff-line.unchanged {
background-color: #fff;
}
</style>
相对而言,代码简单点
父组件
略,上文中已经提到过
diff-match-path实现
结果图
子组件-Diff.vue实现
<template>
<div class="diff-wrapper">
<div class="diff-panel">
<div class="diff-title">旧值</div>
<div class="diff-content" v-html="leftHtml" />
</div>
<div class="diff-panel">
<div class="diff-title">新值</div>
<div class="diff-content" v-html="rightHtml" />
</div>
</div>
</template>
<script lang="ts" setup>
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from 'diff-match-patch'
const props = defineProps<{
oldData: unknown
newData: unknown
}>()
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
// stringify safely
function safeStringify(value: unknown): string {
const seen = new WeakSet()
try {
return JSON.stringify(
value,
function (_key, val) {
// stringify无法处理函数、symbol、循环引用等情况
if (typeof val === 'function') return val.toString() // 看业务需求, 可返回'[Function]'
if (typeof val === 'symbol') return val.toString() // 直接调用toString更快,因为他不是字符串
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return `[Circular -> ${_key}]` // 避免循环引用
seen.add(val)
}
return val
},
2
)
} catch {
return typeof value === 'object' ? '[Object parse error]' : String(value)
}
}
const dmp = new diff_match_patch()
const oldStr = computed(() => safeStringify(props.oldData))
const newStr = computed(() => safeStringify(props.newData))
const diffs = computed(() => {
const diff = dmp.diff_main(oldStr.value, newStr.value)
dmp.diff_cleanupSemantic(diff)
return diff
})
// 生成左右栏 HTML
const leftHtml = computed(() =>
diffs.value
.map(([op, data]) => {
const safe = escapeHtml(data)
if (op === DIFF_EQUAL) return `<span>${safe}</span>`
if (op === DIFF_DELETE) return `<del class="deleted">${safe}</del>`
return `<span class="empty"></span>` // insert 时左边为空
})
.join('')
)
const rightHtml = computed(() =>
diffs.value
.map(([op, data]) => {
const safe = escapeHtml(data)
if (op === DIFF_EQUAL) return `<span>${safe}</span>`
if (op === DIFF_INSERT) return `<ins class="inserted">${safe}</ins>`
return `<span class="empty"></span>` // delete 时右边为空
})
.join('')
)
</script>
<style lang="scss">
// 不能加scoped,样式会失效
.diff-wrapper {
display: flex;
border: 1px solid #ddd;
font-family: monospace;
font-size: 13px;
}
.diff-panel {
flex: 1;
padding: 10px;
border-right: 1px solid #eee;
word-break: break-word;
white-space: pre-wrap;
}
.diff-panel:last-child {
border-right: none;
}
.diff-title {
margin-bottom: 6px;
font-weight: bold;
}
.deleted {
background-color: #ffecec;
text-decoration: line-through;
color: #c00;
}
.inserted {
background-color: #e6ffed;
color: #080;
}
.empty {
visibility: hidden;
}
</style>
vue-diff实现
结果图
下载
npm install vue-pdf
注册
import VueDiff from 'vue-diff';
import 'vue-diff/dist/index.css';
app.use(VueDiff, {
componentName: 'VueDiff',
});
使用
<template>
<VueDiff
theme="light"
:prev="safeStringify(diffObj.oldData)"
:current="safeStringify(diffObj.newData)"
/>
</template>
<script lang="ts" setup>
function safeStringify(value: unknown): string {
const seen = new WeakSet()
try {
return JSON.stringify(
value,
function (_key, val) {
// stringify无法处理函数、symbol、循环引用等情况
if (typeof val === 'function') return val.toString() // 看业务需求, 可返回'[Function]'
if (typeof val === 'symbol') return val.toString() // 直接调用toString更快,因为他不是字符串
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return `[Circular -> ${_key}]` // 避免循环引用
seen.add(val)
}
return val
},
2
)
} catch {
return typeof value === 'object' ? '[Object parse error]' : String(value)
}
}
</script>
总结
- 如果是vue3,想简单就
vue-diff - 如果想自研,可以使用
diff、diff-match-patch - 其次,最好避免v-html的使用,防止xss注入