diff、diff-match-path、vue-diff简单实现字符级的差异对比组件,vue版

804 阅读2分钟

结果图

image.png

背景

某天要给管理后台的数据配置添加一个审核功能,为了更好的审核,就需要看到数据差异,于是就要开发一款对比差异的页面

调研

  1. 调研有没有现成的组件,可以适合vue3,可以实现字符级的精度差异
  2. 评估自研成本(有AI的加入,成本大幅度降低)

调研结果

主要还是先通过AI粗略得到一些结果,然后进行分析

  1. 现成组件:vue-diff,这个组件本质用的是diff-match-patchhighlight.js,也是用vue3语法写的
  2. 自研:diffdiff-match-patch,这俩个依赖可以实现差异对比

技术栈

vue3、unocss、diffdiff-match-patch

diff实现差异对比组件

diff实现差异对比,可以更好的兼容vue2vue3

这里有个关键点关于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实现行级的精度

结果图

image.png 可以看到只有行级别的对比差异,没有字符级的对比差异

diffLines可以得到行差异的列表,可以直接渲染,通过removeadd进行区分删除或新增行,如果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实现

参考文档

结果图

image.png

子组件-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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')
}

// 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实现

参考文档

结果图

image.png

下载

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
  • 如果想自研,可以使用diffdiff-match-patch
  • 其次,最好避免v-html的使用,防止xss注入