背景
内部想实现一个可以浏览数据配置变化的审核平台,其中最关键的便是 查看差异对比 功能
经过搜索,这里打算使用 Monaco Editor 实现,可以在我的项目跑通
用 Monaco Editor 实现差异对比工具,理由是他是微软开源的,功能强大,并且他是为 VS Code 提供动力的代码编辑器。
相关case示例
- Monaco Editor Diff 差异对比 官方case
- Monaco Editor 初始化 官方case
- Monaco Editor 的 Worker 引入 官方case
- Vite 引入 Monaco Editor 的 Worker 的 issue
可以先简单看下,再看本文
这里最关键的就是 diff 差异对比case 和 worker引入case
这里在搜索过程中也发现了另一个差异对比组件:git-diff-view,有兴趣大伙儿可以试试,这里就不赘述啦
代码封装
技术栈:vue3、vite
注意点
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
关于最后一段代码的使用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
为false
是否开启折叠行:hideUnchangedRegions
hideUnchangedRegions: { enabled: true }
为true
打开折叠行
为false
滚动事件:alwaysConsumeMouseWheel
scrollbar: { alwaysConsumeMouseWheel: true }
如果不开启这个事件,有一种场景会有问题:有多个编辑器竖向排列在一个弹窗里,但其中编辑器滚动到底部时,不会触发父容器向下滚动,非常影响体验;开启后就能解决这个问题。因为 Monaco Editor 阻止了 浏览器滚动的默认行为
设置主题
考虑到默认的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>
- 初始化加载,创建差异编辑器实例:
monaco.editor.createDiffEditor
if (!diffEditorContainerRef.value) return
diffEditorInstance.value = monaco.editor.createDiffEditor( diffEditorContainerRef.value, editorOptions )
diffEditorInstance 是 shallRef 原因是:我只想记录保存,不想让他具备深层的响应,用于watch监听是否有值
const diffEditorInstance = shallowRef<monaco.editor.IStandaloneDiffEditor | null>(null)
- 当初始化加载时,触发
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" />