Monaco Editor实现diff对比差异,vue3

969 阅读5分钟

背景

内部想实现一个可以浏览数据配置变化的审核平台,其中最关键的便是 查看差异对比 功能

经过搜索,这里打算使用 Monaco Editor 实现,可以在我的项目跑通

Monaco Editor 实现差异对比工具,理由是他是微软开源的,功能强大,并且他是为 VS Code 提供动力的代码编辑器。

image.png

相关case示例

可以先简单看下,再看本文

这里最关键的就是 diff 差异对比caseworker引入case

这里在搜索过程中也发现了另一个差异对比组件:git-diff-view,有兴趣大伙儿可以试试,这里就不赘述啦

image.png

代码封装

技术栈:vue3vite

注意点

  • worker需要引入,否则会有个toUrl的警告
  • 高度需要默认指定,所以最好设置alwaysConsumeMouseWheel: true,否则浏览器默认滚动行为会被禁止,比如说,你在这个编辑器滚动底部时,不会触发父容器滚动
  • 页面卸载时,需要释放编辑器实例
  • Monaco Editor 自带3个主题:vs(白色)、vs-dark(黑色)、hc-black(黑色 强调)

开始封装

worker引入

import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'

import { oneDarkProLightTheme } from '@/utils/monacoTheme'

// 设置 Monaco worker
self.MonacoEnvironment = {
  getWorker(_: string, label: string) {
    switch (label) {
      case 'json':
        return new jsonWorker()
      default:
        return new editorWorker()
    }
  },
}

因为我涉及到的 language 就一个 json ,所以引入 json 的 worker 即可

editorWorker 是 Monaco Editor 的核心 worker,负责所有语言共享的一些底层功能,所以也要引入,不能忽略

如果要要支持其他语言,可以参考 Monaco Editor 的 Worker 引入 官方case

image.png

关于最后一段代码的使用monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true); ,简单来说

  • 如果你只是展示代码、不使用智能功能,那么你可以删掉它,语言 worker 会按需加载内容。
  • 如果是你需要进行编辑,那么需要保留

编辑器配置项

interface Props {
  oldData: unknown
  newData: unknown
  language?: string
  height?: string
  theme?: string
}

const props = withDefaults(defineProps<Props>(), {
  language: 'json',
  height: '300px',
  theme: 'one-dark-pro-light',
})

interface EditorOptions extends monaco.editor.IDiffEditorConstructionOptions {
  theme: string
}

const editorOptions: EditorOptions = {
  theme: props.theme,
  automaticLayout: true, // 自动布局
  readOnly: true, // 是否只读
  renderSideBySide: true, // 是否内联比较
  scrollBeyondLastLine: false, // 是否允许滚动到最后一行之后
  hideUnchangedRegions: {
    enabled: true, // 启用折叠行
    revealLineCount: 40,
    minimumLineCount: 1,
    contextLineCount: 1,
  },
  scrollbar: {
    alwaysConsumeMouseWheel: false, // 允许鼠标滚轮滚动:具体表现为不阻碍浏览器默认行为,比如滚动到编辑器底部会自动让父容器自动向下滚动
  },
}

这里有几个配置可以关注下

是否内联比较:renderSideBySide

为true image.png 为false image.png

是否开启折叠行:hideUnchangedRegions

hideUnchangedRegions: { enabled: true }

为true image.png 打开折叠行 image.png 为false image.png

滚动事件:alwaysConsumeMouseWheel

scrollbar: { alwaysConsumeMouseWheel: true }

如果不开启这个事件,有一种场景会有问题:有多个编辑器竖向排列在一个弹窗里,但其中编辑器滚动到底部时,不会触发父容器向下滚动,非常影响体验;开启后就能解决这个问题。因为 Monaco Editor 阻止了 浏览器滚动的默认行为 image.png

设置主题

考虑到默认的vs、vs-dark 模式都不太好看,我这里选择参考 one-dark-pro 的样式,大伙儿可以跳过

新建一个MonacoTheme的文件

import * as monaco from 'monaco-editor'

export const oneDarkProLightTheme: Parameters<typeof monaco.editor.defineTheme>[1] = {
  base: 'vs', // 基于浅色主题
  inherit: true,
  rules: [
    { token: '', foreground: '383A42', background: 'FAFAFA' },
    { token: 'comment', foreground: 'A0A1A7', fontStyle: 'italic' },
    { token: 'string', foreground: '50A14F' },
    { token: 'keyword', foreground: 'A626A4' },
    { token: 'number', foreground: '986801' },
    { token: 'delimiter', foreground: '383A42' },
    { token: 'type.identifier', foreground: 'C18401' },
    { token: 'variable', foreground: 'E45649' },
    { token: 'function', foreground: '4078F2' },
  ],
  colors: {
    'editor.background': '#FAFAFA',
    'editor.foreground': '#383A42',
    'editorLineNumber.foreground': '#A0A1A7',
    'editorCursor.foreground': '#526FFF',
    'editorIndentGuide.background': '#EDEDED',
    'editor.lineHighlightBackground': '#F0F0F0',
    'editor.selectionBackground': '#D7D4F0',
    'editor.inactiveSelectionBackground': '#E5E5E5',
    'editor.selectionHighlightBackground': '#E0E0E0',
    'editor.wordHighlightBackground': '#C9D8F1',
    'editor.findMatchHighlightBackground': '#FFE792',
  },
}

设置主题

function setEditorTheme() {
  const theme = props.theme
  if (theme === 'one-dark-pro-light') {
    monaco.editor.defineTheme('one-dark-pro-light', oneDarkProLightTheme)
    monaco.editor.setTheme('one-dark-pro-light')
  } else if (['vs', 'vs-dark', 'hc-black'].includes(theme)) {
    monaco.editor.setTheme(theme)
  } else {
    console.error(`不支持这个主题: ${theme}. 请检查是否存在该主题。回退使用 'vs'.`)
    monaco.editor.setTheme('vs')
  }
}

onMounted(() => {
  if (!diffEditorContainerRef.value) return

  setEditorTheme() // 初始化设置,要在createDiffEditor之前

  diffEditorInstance.value = monaco.editor.createDiffEditor(
    diffEditorContainerRef.value,
    editorOptions
  )
})

设置主题流程:defineTheme -> setTheme -> 初始化设置

处理新旧数据

新旧数据,把传入的新旧数据,处理成文本格式的

const oldDataStr = computed(() => simpleStringify(props.oldData))
const newDataStr = computed(() => simpleStringify(props.newData))

function simpleStringify(value: unknown): string {
  if (value === null || value === undefined) return ''

  const valueType = typeof value
  if (['number', 'boolean', 'string'].includes(valueType)) return String(value)

  // JSON.stringify 会跳过函数和 symbol;若存在循环引用则会抛错。
  // 这里不考虑循环引用情况,因为json配置不会有这种情况

  try {
    return JSON.stringify(value, null, 2)
  } catch {
    return typeof value === 'object' ? '[Object parse error]' : String(value)
  }
}

这里写了个简单的格式化函数

创建编辑器

<script setup lang="ts">
// 上文的worker\props\computed

function createDiffModels(original: string, modified: string, language: string) {
  const originalModel = monaco.editor.createModel(original, language)
  const modifiedModel = monaco.editor.createModel(modified, language)
  return { originalModel, modifiedModel }
}

const diffEditorContainerRef = ref<HTMLElement | null>(null)
const diffEditorInstance = shallowRef<monaco.editor.IStandaloneDiffEditor | null>(null)

watch(
  [oldDataStr, newDataStr, diffEditorInstance],
  ([oldDataStr, newDataStr, diffEditorInstance]) => {
    if (!diffEditorInstance) return

    const { originalModel, modifiedModel } = createDiffModels(
      oldDataStr,
      newDataStr,
      props.language
    )

    diffEditorInstance.setModel({ original: originalModel, modified: modifiedModel })
  },
  {
    immediate: true,
  }
)

onMounted(() => {
  if (!diffEditorContainerRef.value) return

  diffEditorInstance.value = monaco.editor.createDiffEditor(
    diffEditorContainerRef.value,
    editorOptions
  )
})

onBeforeUnmount(() => {
  if (diffEditorInstance.value) {
    const model = diffEditorInstance.value.getModel()
    model?.original?.dispose()
    model?.modified?.dispose()
    diffEditorInstance.value.dispose()
    diffEditorInstance.value = null
  }
})
</script>

<template>
  <div ref="diffEditorContainerRef" class="monaco-diff-editor" :style="{ height: height }" />
</template>

<style lang="scss" scoped>
.monaco-diff-editor {
  width: 100%;
}
</style>

  1. 初始化加载,创建差异编辑器实例:monaco.editor.createDiffEditor
if (!diffEditorContainerRef.value) return

diffEditorInstance.value = monaco.editor.createDiffEditor( diffEditorContainerRef.value, editorOptions )

diffEditorInstanceshallRef 原因是:我只想记录保存,不想让他具备深层的响应,用于watch监听是否有值

const diffEditorInstance = shallowRef<monaco.editor.IStandaloneDiffEditor | null>(null)
  1. 当初始化加载时,触发watch后并且diffEditorInstance有值,则创建差异对比的models,并且设置models,具体参考Monaco Editor Diff 差异对比 官方case
const { originalModel, modifiedModel } = createDiffModels(
  oldDataStr,
  newDataStr,
  props.language
)

diffEditorInstance.setModel({ original: originalModel, modified: modifiedModel })

源代码

MonacoDiff.vue

<script setup lang="ts">
// ? worker引入官方示例: https://github.com/microsoft/monaco-editor/blob/main/samples/browser-esm-vite-react/src/userWorker.ts
// ? worker引入issue参考:https://github.com/vitejs/vite/discussions/1791
// ? monaco diff 官方示例:https://microsoft.github.io/monaco-editor/playground.html?source=v0.52.2#example-creating-the-diffeditor-hello-diff-world

import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'

import { oneDarkProLightTheme } from '@/utils/monacoTheme'

// 设置 Monaco worker
self.MonacoEnvironment = {
  getWorker(_: string, label: string) {
    switch (label) {
      case 'json':
        return new jsonWorker()
      default:
        return new editorWorker()
    }
  },
}

interface Props {
  oldData: unknown
  newData: unknown
  language?: string
  height?: string
  theme?: string
}

const props = withDefaults(defineProps<Props>(), {
  language: 'json',
  height: '300px',
  theme: 'one-dark-pro-light',
})

interface EditorOptions extends monaco.editor.IDiffEditorConstructionOptions {
  theme: string
}

const editorOptions: EditorOptions = {
  theme: props.theme,
  automaticLayout: true, // 自动布局
  readOnly: true, // 是否只读
  renderSideBySide: true, // 是否内联比较
  scrollBeyondLastLine: false, // 是否允许滚动到最后一行之后
  hideUnchangedRegions: {
    enabled: true, // 启用折叠行
    revealLineCount: 40,
    minimumLineCount: 1,
    contextLineCount: 1,
  },
  scrollbar: {
    alwaysConsumeMouseWheel: false, // 允许鼠标滚轮滚动:具体表现为不阻碍浏览器默认行为,比如滚动到编辑器底部会自动让父容器自动向下滚动
  },
}

const diffEditorContainerRef = ref<HTMLElement | null>(null)
const diffEditorInstance = shallowRef<monaco.editor.IStandaloneDiffEditor | null>(null)

const oldDataStr = computed(() => simpleStringify(props.oldData))
const newDataStr = computed(() => simpleStringify(props.newData))

function createDiffModels(original: string, modified: string, language: string) {
  const originalModel = monaco.editor.createModel(original, language)
  const modifiedModel = monaco.editor.createModel(modified, language)
  return { originalModel, modifiedModel }
}

function setEditorTheme() {
  const theme = props.theme
  if (theme === 'one-dark-pro-light') {
    monaco.editor.defineTheme('one-dark-pro-light', oneDarkProLightTheme)
    monaco.editor.setTheme('one-dark-pro-light')
  } else if (['vs', 'vs-dark', 'hc-black'].includes(theme)) {
    monaco.editor.setTheme(theme)
  } else {
    console.error(`不支持这个主题: ${theme}. 请检查是否存在该主题。回退使用 'vs'.`)
    monaco.editor.setTheme('vs')
  }
}

function simpleStringify(value: unknown): string {
  if (value === null || value === undefined) return ''

  const valueType = typeof value
  if (['number', 'boolean', 'string'].includes(valueType)) return String(value)

  // JSON.stringify 会跳过函数和 symbol;若存在循环引用则会抛错。
  // 这里不考虑循环引用情况,因为json配置不会有这种情况

  try {
    return JSON.stringify(value, null, 2)
  } catch {
    return typeof value === 'object' ? '[Object parse error]' : String(value)
  }
}

watch(
  [oldDataStr, newDataStr, diffEditorInstance],
  ([oldDataStr, newDataStr, diffEditorInstance]) => {
    if (!diffEditorInstance) return

    const { originalModel, modifiedModel } = createDiffModels(
      oldDataStr,
      newDataStr,
      props.language
    )

    diffEditorInstance.setModel({ original: originalModel, modified: modifiedModel })
  },
  {
    immediate: true,
  }
)

onMounted(() => {
  if (!diffEditorContainerRef.value) return

  setEditorTheme()

  diffEditorInstance.value = monaco.editor.createDiffEditor(
    diffEditorContainerRef.value,
    editorOptions
  )
})

onBeforeUnmount(() => {
  if (diffEditorInstance.value) {
    const model = diffEditorInstance.value.getModel()
    model?.original?.dispose()
    model?.modified?.dispose()
    diffEditorInstance.value.dispose()
    diffEditorInstance.value = null
  }
})
</script>

<template>
  <div ref="diffEditorContainerRef" class="monaco-diff-editor" :style="{ height: height }" />
</template>

<style lang="scss" scoped>
.monaco-diff-editor {
  width: 100%;
}
</style>

one-dark-pro-light

import * as monaco from 'monaco-editor'

export const oneDarkProLightTheme: Parameters<typeof monaco.editor.defineTheme>[1] = {
  base: 'vs', // 基于浅色主题
  inherit: true,
  rules: [
    { token: '', foreground: '383A42', background: 'FAFAFA' },
    { token: 'comment', foreground: 'A0A1A7', fontStyle: 'italic' },
    { token: 'string', foreground: '50A14F' },
    { token: 'keyword', foreground: 'A626A4' },
    { token: 'number', foreground: '986801' },
    { token: 'delimiter', foreground: '383A42' },
    { token: 'type.identifier', foreground: 'C18401' },
    { token: 'variable', foreground: 'E45649' },
    { token: 'function', foreground: '4078F2' },
  ],
  colors: {
    'editor.background': '#FAFAFA',
    'editor.foreground': '#383A42',
    'editorLineNumber.foreground': '#A0A1A7',
    'editorCursor.foreground': '#526FFF',
    'editorIndentGuide.background': '#EDEDED',
    'editor.lineHighlightBackground': '#F0F0F0',
    'editor.selectionBackground': '#D7D4F0',
    'editor.inactiveSelectionBackground': '#E5E5E5',
    'editor.selectionHighlightBackground': '#E0E0E0',
    'editor.wordHighlightBackground': '#C9D8F1',
    'editor.findMatchHighlightBackground': '#FFE792',
  },
}

父组件使用

<MonacoDiff new-data="'1'" old-data="'2'" height="50px" />