原文链接:Vue3+Monaco Editor封装及SQL编辑器实现 < Ping通途说
0. 前言
最近收到需求,老板想要在前端自定义SQL语句然后查询。安全性我强调了几次,仍然拗不过老板,那就干吧...只能在语句检查和权限上注意一下,例如严格的语句检查和创建一个仅有单表查权限的数据库用户执行语句。
项目其他地方都使用了Monaco Editor,所以在这里直接复用之前封装的组件,文章也会贴出封装的代码,有需要可以自取。
若你没有听过这个编辑器,那应该听过VSCode吧,Monaco就是VSCode的核心编辑器组件。
目前编辑器内置全套配件(语法检查、格式化等)的语言有:TypeScript、JavaScript、HTML和JSON,其他语言就要自己去配置。Monaco开放的API:Monaco Editor
1. 基础功能实现
有些教程(官方也这么教)会教直接用import * as monaco from 'monaco-editor';来将整个依赖库导入,这样的后果就是打包出来的Monaco整整占用4MB(左图),而我们按需导入后体积骤降2.5MB(右图)
因此如果你的项目(和服务器带宽)对加载速度有要求的,可以使用以下方式导入Monaco:
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';这样做的代价就是除了最基础的编辑框,所有的右键菜单、语言高亮提示、语法提示等所有扩展功能都需要自己手动导入。
直接来看最基础的Monaco Editor组件封装:
<template> <div ref="container" :class="'w-full min-h-[450px]'" :style="{ height: height }"></div> </template> <script setup> import 'monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js'; // 右键显示菜单 import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js'; // 折叠 import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions.js'; // 格式化代码 import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js'; // 代码联想提示 import 'monaco-editor/esm/vs/editor/contrib/tokenization/browser/tokenization.js'; // 代码联想提示 import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import JSONWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; import "monaco-editor/esm/vs/language/json/monaco.contribution"; // JSON代码高亮&提示 import { nextTick, onMounted, ref, toRaw, watch } from 'vue'; // 容器对象 const container = ref(null) // 编辑器对象 const editor = ref(null) // input 事件 const emit = defineEmits(['update:value']) const props = defineProps({ value: { type: String, default: '', }, language: { type: String, default: 'json', }, theme: { type: String, default: 'vs', }, readOnly: { type: Boolean, default: false, }, height: { type: String, default: '450px', }, wordWrap: { type: String, default: "on", }, }) self.MonacoEnvironment = { getWorker(workId, label) { if (label === 'json') { return new JSONWorker() } return new EditorWorker() }, getWorkerUrl(moduleId, label) { if (label === 'json') { return './json.worker.bundle.js' } return './editor.worker.bundle.js' }, } function updateValue(value) { // 更新值 提供给父组件使用 if (value) { const model = monaco.editor.getModels()[0] model.setValue(value.toString()) } } defineExpose({ updateValue, }) onMounted(() => { // 确保容器已经渲染完成后再创建编辑器 nextTick(() => { editor.value = monaco.editor.create(container.value, { value: props.value, language: props.language, scrollBeyondLastLine: false, theme: props.theme, wordWrap: props.wordWrap, automaticLayout: true, minimap: { enabled: false, }, readOnly: props.readOnly, }) editor.value.onDidChangeModelContent(() => { const value = toRaw(editor.value).getValue() emit('update:value', value) }) }) }) // 监听value prop变化,更新编辑器内容 watch( () => props.value, (newValue) => { if (editor.value) { const model = toRaw(editor.value).getModel() if (model && newValue !== toRaw(editor.value).getValue()) { model.setValue(newValue || '') } } }, ) watch( () => props.readOnly, (newValue) => { toRaw(editor.value).updateOptions({ readOnly: newValue, }) }, ) </script>封装的组件实现了以下功能:
- 支持JSON语言模式,可通过
language参数指定支持的编程语言 - JSON语法高亮、代码自动折叠、右键上下文菜单、JSON代码格式化、JSON智能代码提示和自动补全
- 支持传入配置:
- value:编辑器内容值,支持双向绑定
- language:编程语言类型(默认JSON),需要预先配置好对应语言的参数才能使用语言高亮和智能提示
- theme:编辑器主题,默认vs,暗黑模式就是vs-dark
- readonly:动态调整是否只读模式
- height:编辑框容器高度
- wordWrap:是否自动换行
- 数据同步:在编辑框输入和内部修改的内容由v-model进行双向绑定
要使用这个组件,先假设当前组件名称为“MonacoEditor.vue”,然后就能在需要使用中的组件导入,然后绑定值并传入参数。
需要注意的是:传入值必须为字符串,传回值也是字符串,这就意味着当前页面需要正常使用JSON就需要使用computed实时计算
<template> <div class="mx-auto w-full h-[450px]"> <MonacoEditor language="json" :value="formatRawText" v-model:value="editText" :theme="theme === 'dark' ? 'vs-dark' : 'vs'" :readOnly="readOnly" /> </div> </template> <script setup> import MonacoEditor from '@/components/editor/MonacoEditor.vue' import { computed, ref, watch } from 'vue' const rawText = ref({}) const editText = ref({}) const readOnly = ref(false) const theme = ref('dark') const formatRawText = computed(() => { try { if (typeof rawText === 'object') { return JSON.stringify(JSON.parse(rawText), null, 2) } return config } catch { return "{}" } }) watch(editText.value,()=>{ if(editText.value && editText.value != '{}'){ try { const parsedText = JSON.parse(editText.value) editText.value = parsedText } catch(e){ console.log("解析发生错误:",e) } } })另外,如果你仔细观察组件的导入,支持JSON功能的库有:
import JSONWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; import "monaco-editor/esm/vs/language/json/monaco.contribution"; // JSON代码高亮&提示文章一开始也提到,官方提供了4种语言的深度支持,如果你需要使用他们,可以模仿JSON引用库的方式来引用其他三项的依赖
2. MySQL语言支持
<template> <div ref="container" :class="'w-full min-h-[450px]'" :style="{ height: height }"></div> </template> <script setup> import 'monaco-editor/esm/vs/basic-languages/mysql/mysql.contribution.js'; import 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js'; import { language as mysqlLanguage } from 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js'; import 'monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js'; // 右键显示菜单 import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js'; // 折叠 import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions.js'; // 格式化代码 import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js'; // 代码联想提示 import 'monaco-editor/esm/vs/editor/contrib/tokenization/browser/tokenization.js'; // 代码联想提示 import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import JSONWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; import "monaco-editor/esm/vs/language/json/monaco.contribution"; // JSON代码高亮&提示 import { nextTick, onMounted, ref, toRaw, watch } from 'vue'; // 容器对象 const container = ref(null) // 编辑器对象 const editor = ref(null) // input 事件 const emit = defineEmits(['update:value']) const props = defineProps({ value: { type: String, default: '', }, language: { type: String, default: 'json', }, theme: { type: String, default: 'vs', }, readOnly: { type: Boolean, default: false, }, height: { type: String, default: '450px', }, wordWrap: { type: String, default: "on", }, }) self.MonacoEnvironment = { getWorker(workId, label) { if (label === 'json') { return new JSONWorker() } if (label === 'mysql') { return new MySQLWorker() } return new EditorWorker() }, getWorkerUrl(moduleId, label) { if (label === 'json') { return './json.worker.bundle.js' } if (label === 'mysql') { return './mysql.worker.bundle.js' } return './editor.worker.bundle.js' }, } function updateValue(value) { // 更新值 提供给父组件使用 if (value) { const model = monaco.editor.getModels()[0] model.setValue(value.toString()) } } defineExpose({ updateValue, }) onMounted(() => { // 确保容器已经渲染完成后再创建编辑器 nextTick(() => { editor.value = monaco.editor.create(container.value, { value: props.value, language: props.language, scrollBeyondLastLine: false, theme: props.theme, wordWrap: props.wordWrap, automaticLayout: true, minimap: { enabled: false, }, readOnly: props.readOnly, }) monaco.languages.register({ id: 'mysql' }) if (props.language == 'mysql') { //注册片段 const keywords = mysqlLanguage.keywords.map(item => ({ "label": item, "kind": monaco.languages.CompletionItemKind.Keyword, "insertText": item, "detail": "MySQL Keyword" })) monaco.languages.registerCompletionItemProvider('mysql', { // 设置触发自动补全的字符 triggerCharacters: [' ', '.', '(', '`'], provideCompletionItems: (model, position) => { // 获取当前行的所有文本 const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn }; // 返回所有关键词和自定义片段 return { suggestions: [{ label: 'queryUser', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'SELECT `${1:user_id}`,`${2:open_id}`,`${3:age}`,${4:params},`${5:created_at}` FROM `user`', insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, documentation: '查询用户信息', range: range }, ...keywords.map(keyword => ({ ...keyword, range: range }))] } }, }) } editor.value.onDidChangeModelContent(() => { const value = toRaw(editor.value).getValue() emit('update:value', value) }) }) }) // ... exist // 监听value prop变化,更新编辑器内容 watch( () => props.value, (newValue) => { if (editor.value) { const model = toRaw(editor.value).getModel() if (model && newValue !== toRaw(editor.value).getValue()) { model.setValue(newValue || '') } } }, ) watch( () => props.readOnly, (newValue) => { toRaw(editor.value).updateOptions({ readOnly: newValue, }) }, ) </script>通过代码高亮可以发现,我们导入了mysql的语法高亮支持,然后在111行注册语言。
在113行中遍历官方提供关键字列表并加入到编辑器中,因为直接导入是没有用的,打字没有提示。
第120行我们定义了在什么时候触发关键词提示,目前空格,括号,反引号等这些在MySQL语句一般接的就是关键词了。在这里你也可以将从服务器获取的表名也导入进去。
然后136行,我们自定义了一个片段,用于快捷输入我们常用的SQL片段,设置完成后我们可以通过输入queryUser来快捷使用SQL片段。片段中使用了${1:user_id}这种代表插槽,用户可按Tab键快速切换到下一个插槽中填数据,如果不填就使用插槽内部默认的字段,非常的方便