Vue3开发中如何快速集成并深度定制CodeMirror6代码编辑器?
说起前端开发里的代码编辑场景,不管是在线代码教学平台、API文档的代码示例区,还是自己做的本地代码预览工具、配置管理后台里的JSON/YAML编辑器,CodeMirror都是绕不开的选择,尤其是最近这两年,Vue3成了国内主流前端框架,CodeMirror6也完成了从面向对象到函数式架构的重构,性能和扩展性都提了好几个档次,但很多人还是对Vue3和CodeMirror6的适配摸不着头脑:比如官方没有现成的Vue3组件包?旧版@vue-codemirror的5.x分支和6.x怎么选?主题切换、代码补全、格式化这种高频需求怎么实现得顺手?别慌,今天就把这些踩过坑、摸过源码才搞明白的内容,用最接地气的方式说清楚。
第一步:搞懂Vue3和CodeMirror6的适配基础——要不要用第三方封装?
很多新手上来第一反应就是找现成的Vue3+CodeMirror6的npm包,毕竟能省不少事,但搜搜就会发现,第三方封装要么更新慢跟不上CodeMirror6的新特性,要么功能太固定满足不了定制需求,要么文档写得像天书,还不如自己轻量集成。
这里先明确一个核心逻辑:CodeMirror6本身就是完全独立于框架的,它的架构分为“编辑器状态(State)”“视图(View)”和“扩展(Extensions)”三大块,每个部分都是纯函数或纯数据结构,和Vue3的响应式系统结合起来其实很简单——只要把编辑器的State或者View里需要响应式更新的部分,比如当前文本、光标位置、选中的主题,和Vue3的ref/reactive绑定起来就行。
如果你的需求非常基础,比如只是想显示一段带高亮的代码,不需要太多交互,那也可以试试目前维护相对稳定的第三方包,vue-codemirror/v6,但记住,一旦你需要做深度定制,比如加个自定义补全插件、修改特定语法的高亮规则、把编辑器和Vuex/Pinia结合起来做全局状态同步,自己轻量集成的自由度会高太多,后续维护也更可控,不会因为第三方包作者停更就抓瞎。
第二步:从零开始轻量集成CodeMirror6到Vue3
那具体怎么从零开始集成呢?别着急,先跟着我一步步来,先把最基础的能显示、能输入、带JavaScript语法高亮的编辑器做出来,后面再慢慢加功能。
准备工作:安装必要的依赖
你得在现有的Vue3项目里安装CodeMirror6的核心依赖,以及我们马上要用到的基础扩展:
npm install @codemirror/view @codemirror/state @codemirror/language @codemirror/lang-javascript @codemirror/theme-one-dark
这里解释下每个依赖是干嘛的:
- @codemirror/view:负责渲染编辑器视图,处理用户输入、鼠标点击这些交互,是核心中的核心;
- @codemirror/state:定义了编辑器的状态结构,比如当前文本、光标、选区、文档历史,还有状态更新的机制;
- @codemirror/language:提供了语法高亮、缩进、折叠这些和语言相关的基础工具;
- @codemirror/lang-javascript:专门针对JavaScript(包括ES6+、TypeScript、JSX/TSX,但这里先装基础JS就行,后面想加其他语言再加对应的包)的语言包;
- @codemirror/theme-one-dark:One Dark主题,这是最常用的深色代码编辑器主题之一,用来做演示。
集成到Vue3单文件组件(SFC)
我们在Vue3的SFC里写代码,首先是template部分,只需要一个div用来挂载CodeMirror6的视图就行,给它加个id或者ref,方便后面获取DOM元素:
<template>
<div class="code-editor-container">
<div ref="editorRef" class="code-editor"></div>
</div>
</template>
然后是script部分,先引入刚才安装的依赖,再用setup组合式API写逻辑——毕竟Vue3的setup和组合式API和CodeMirror6的函数式架构最搭:
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { EditorView, basicSetup } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
// 定义编辑器的挂载DOM ref
const editorRef = ref(null)
// 定义编辑器的当前文本,用ref绑定,方便父组件传值或者子组件向父组件emit
const code = ref(`// 欢迎使用Vue3 + CodeMirror6!
console.log("Hello, Vue3 CodeMirror6!");
function add(a, b) {
return a + b;
}
add(1, 2);
`)
// 定义编辑器的视图实例,后面会用到,比如更新配置、获取光标位置
let editorView = null
// 初始化编辑器的函数
const initEditor = () => {
if (!editorRef.value) return
// 定义编辑器的基础状态
const state = EditorState.create({
// 初始文本
doc: code.value,
// 扩展,这里是最核心的部分,所有功能都是通过扩展实现的
extensions: [
// basicSetup是CodeMirror6官方提供的基础扩展集合,包含了历史记录、行号、自动换行、光标闪烁、搜索替换(基础版)这些常用功能,不需要自己一个个去加
basicSetup,
// JavaScript语言包,提供语法高亮和缩进
javascript(),
// One Dark主题
oneDark,
// 监听文档变化的扩展,把编辑器里的文本同步到Vue3的code ref里
EditorView.updateListener.of(update => {
if (update.docChanged) {
code.value = update.state.doc.toString()
}
})
]
})
// 渲染编辑器视图
editorView = new EditorView({
state,
// 挂载到指定DOM上
parent: editorRef.value
})
}
// 监听Vue3的code ref变化,把新文本同步到编辑器里(比如父组件传了新的代码进来)
watch(code, (newCode) => {
if (!editorView) return
// 只有当编辑器里的文本和新文本不一样的时候才更新,避免死循环
if (editorView.state.doc.toString() !== newCode) {
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: newCode
}
})
}
})
// 组件挂载完成后初始化编辑器
onMounted(() => {
initEditor()
})
// 组件卸载前销毁编辑器视图,释放内存
onBeforeUnmount(() => {
if (editorView) {
editorView.destroy()
}
})
</script>
style部分,给编辑器容器和编辑器本身加个样式,比如设置宽度、高度、边框、圆角,让它看起来好看一点:
<style scoped>
.code-editor-container {
width: 100%;
max-width: 1200px;
margin: 20px auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
/* 注意:basicSetup里已经给编辑器的DOM加了默认的overflow: auto,所以不用再单独加了 */
/* 这里的.code-editor不用设置overflow,只要设置高度就行,不然行号可能会有问题 */
.code-editor {
height: 500px;
}
</style>
写到这里,一个最基础的Vue3 + CodeMirror6编辑器就做好了!你可以把这段代码复制到你的Vue3项目里运行试试,应该能看到一个带One Dark主题、行号、自动换行、语法高亮的JavaScript编辑器,输入文字后Vue3的code ref会同步更新,修改code ref编辑器里的文本也会同步变化。
第三步:高频需求的深度定制
基础编辑器做好了,接下来就是大家最关心的高频需求的深度定制了:比如主题切换、支持更多编程语言、自定义代码补全、代码格式化、只读模式、行高亮、光标位置和选区显示、全局状态同步(Pinia/Vuex)。
主题切换
主题切换其实很简单,主要就是动态更新编辑器的扩展,你需要安装更多的主题包,codemirror/theme-one-light、@codemirror/theme-monokai,或者你也可以自己写主题——不过先试试官方提供的吧,稳定又好看。
安装更多主题包:
npm install @codemirror/theme-one-light @codemirror/theme-monokai
修改script部分:
- 定义一个ref用来存储当前选中的主题;
- 定义一个主题映射对象,把主题名称和对应的主题扩展关联起来;
- 修改initEditor函数,让它从主题映射对象里取当前选中的主题;
- 定义一个切换主题的函数,动态更新编辑器的扩展。
修改后的script部分核心代码如下(完整代码可以自己整合):
<script setup>
// 引入新安装的主题
import { oneLight } from '@codemirror/theme-one-light'
import { monokai } from '@codemirror/theme-monokai'
// 定义当前选中的主题
const currentTheme = ref('oneDark')
// 定义主题映射对象
const themeMap = {
oneDark,
oneLight,
monokai
}
// 定义主题选项,用来渲染主题切换的下拉框
const themeOptions = [
{ label: 'One Dark', value: 'oneDark' },
{ label: 'One Light', value: 'oneLight' },
{ label: 'Monokai', value: 'monokai' }
]
// 初始化编辑器的函数
const initEditor = () => {
if (!editorRef.value) return
const state = EditorState.create({
doc: code.value,
extensions: [
basicSetup,
javascript(),
// 从主题映射对象里取当前选中的主题
themeMap[currentTheme.value],
EditorView.updateListener.of(update => {
if (update.docChanged) {
code.value = update.state.doc.toString()
}
})
]
})
editorView = new EditorView({
state,
parent: editorRef.value
})
}
// 切换主题的函数
const changeTheme = (themeValue) => {
currentTheme.value = themeValue
if (!editorView) return
// 动态更新编辑器的扩展:保留除了主题之外的所有扩展,然后加上新的主题
editorView.dispatch({
effects: EditorView.reconfigure.of({
// filter掉旧的主题扩展——这里注意,basicSetup里可能也有一些和主题相关的配置,但我们直接用官方提供的主题扩展覆盖就行,不过更稳妥的方式是把主题单独放在一个数组里,切换的时候只替换这个数组
// 为了稳妥,我们重新组织一下扩展的结构:把主题、语言、监听、基础扩展分开
// 等下,刚才的initEditor函数里的扩展结构不太好,我们重新改一下,把主题、语言、自定义扩展分开,方便后续的动态更新
})
})
}
// 重新组织扩展结构的initEditor函数和changeTheme函数(这里才是更稳妥的做法)
let baseExtensions = [basicSetup]
let languageExtensions = [javascript()]
let customExtensions = [
EditorView.updateListener.of(update => {
if (update.docChanged) {
code.value = update.state.doc.toString()
}
})
]
let themeExtension = [themeMap[currentTheme.value]]
const initEditor = () => {
if (!editorRef.value) return
const state = EditorState.create({
doc: code.value,
extensions: [...baseExtensions, ...languageExtensions, ...customExtensions, ...themeExtension]
})
editorView = new EditorView({
state,
parent: editorRef.value
})
}
const changeTheme = (themeValue) => {
currentTheme.value = themeValue
themeExtension = [themeMap[themeValue]]
if (!editorView) return
editorView.dispatch({
effects: EditorView.reconfigure.of([
...baseExtensions,
...languageExtensions,
...customExtensions,
...themeExtension
])
})
}
// 监听currentTheme变化,自动切换主题(刚才的changeTheme函数其实已经处理了,但如果是通过其他方式修改currentTheme,比如父组件传值,用watch会更稳妥)
watch(currentTheme, changeTheme)
</script>
在template部分加一个主题切换的下拉框:
<template>
<div class="code-editor-container">
<div class="toolbar">
<label for="theme-select">选择主题:</label>
<select id="theme-select" v-model="currentTheme">
<option v-for="option in themeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div ref="editorRef" class="code-editor"></div>
</div>
</template>
给toolbar加个样式:
<style scoped>
.toolbar {
padding: 10px 20px;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 10px;
}
.toolbar label {
font-size: 14px;
color: #333;
}
.toolbar select {
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
</style>
你应该能看到一个带主题切换下拉框的编辑器了,切换下拉框的选项,编辑器的主题会马上变化!
支持更多编程语言
支持更多编程语言的方法和主题切换类似,也是动态更新扩展,你需要安装对应的语言包,codemirror/lang-html、@codemirror/lang-css、@codemirror/lang-python、@codemirror/lang-json、@codemirror/lang-yaml,这些都是官方提供的,直接安装就行。
安装几个常用的语言包:
npm install @codemirror/lang-html @codemirror/lang-css @codemirror/lang-python @codemirror/lang-json
修改script部分:
- 引入新安装的语言包;
- 定义一个ref用来存储当前选中的语言;
- 定义一个语言映射对象;
- 定义语言选项;
- 重新组织扩展结构,把语言扩展单独放在一个数组里;
- 定义切换语言的函数,动态更新编辑器的扩展;
- 监听当前选中的语言变化,自动切换语言。
这里的逻辑和主题切换几乎一模一样,就不重复写完整代码了,只说几个注意点:
- 有些语言包会有多个版本,codemirror/lang-javascript有jsx()和tsx()的配置,用来支持JSX/TSX;
- 如果有些语言官方没有提供语言包,比如Vue SFC,你可以去npm上找第三方的,codemirror/lang-vue,这个是社区维护的,相对稳定;
- 切换语言的时候,最好能清空一下编辑器的历史记录,不然可能会有奇怪的问题——清空历史记录可以用EditorState.history的配置,或者在切换语言的时候重新创建一个EditorState(不过重新创建会稍微影响一点性能,但对于大多数场景来说完全没问题)。
自定义代码补全
自定义代码补全是深度定制里最常用的功能之一,也是最容易让人头疼的功能之一——不过别担心,CodeMirror6的代码补全机制其实很清晰,只要搞懂几个核心概念就行:CompletionContext、CompletionResult、CompletionItem。
我们来做一个最简单的自定义补全:比如当你输入“vue3”的时候,编辑器会自动补全“Vue3 + CodeMirror6”的相关代码片段。
修改script部分,引入@codemirror/autocomplete的依赖——虽然basicSetup里已经包含了基础的自动补全,但我们要写自定义补全的话,还是需要显式引入:
npm install @codemirror/autocomplete
在script部分引入:
import { autocompletion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'
定义自定义补全的函数:
const customCompletions = (context: CompletionContext): CompletionResult | null => {
// 定义补全的触发词,比如当输入“vue3”的时候触发
const word = context.matchBefore(/vue3/)
// 如果没有匹配到触发词,或者光标不在触发词后面,返回null
if (!word) return null
// 定义补全项
const items = [
{
label: 'vue3-codemirror-basic',
type: 'snippet',
// 补全的内容,${1:editorRef}表示第一个占位符,${2:code}表示第二个占位符,按下Tab键可以切换占位符
info: 'Vue3 + CodeMirror6基础模板',
apply: 'import { ref, onMounted, onBeforeUnmount, watch } from "vue"\nimport { EditorView, basicSetup } from "@codemirror/view"\nimport { EditorState } from "@codemirror/state"\nimport { javascript } from "@codemirror/lang-javascript"\nimport { oneDark } from "@codemirror/theme-one-dark"\n\nconst ${1:editorRef} = ref(null)\nconst ${2:code} = ref(`// 欢迎使用Vue3 + CodeMirror6!\nconsole.log("Hello, Vue3 CodeMirror6!");\n`)\nlet editorView = null\n\nconst initEditor = () => {\n if (!${1:editorRef}.value) return\n const state = EditorState.create({\n doc: ${2:code}.value,\n extensions: [basicSetup, javascript(), oneDark, EditorView.updateListener.of(update => { if (update.docChanged) ${2:code}.value = update.state.doc.toString() })]\n })\n editorView = new EditorView({ state, parent: ${1:editorRef}.value })\n}\n\nwatch(${2:code}, (newCode) => {\n if (!editorView) return\n if (editorView.state.doc.toString() !== newCode) editorView.dispatch({ changes: { from: 0, to: editorView.state.doc.length, insert: newCode } })\n})\n\nonMounted(() => initEditor())\nonBeforeUnmount(() => editorView && editorView.destroy())'
},
{
label: 'console.log',
type: 'function',
info: '打印日志到控制台',
apply: 'console.log(${1:message})'
},
{
label: 'console.error',
type: 'function',
info: '打印错误到控制台',
apply: 'console.error(${1:error})'
}
]
// 返回补全结果
return {
from: word.from,
options: items,
// 是否在匹配到触发词的时候自动显示补全列表,而不是需要按Ctrl+Space
filter: false
}
}
修改customExtensions数组,把自定义补全的扩展加进去:
let customExtensions = [
EditorView.updateListener.of(update => {
if (update.docChanged) {
code.value = update.state.doc.toString()
}
}),
// 自定义补全扩展
autocompletion({
override: [customCompletions]
})
]
你可以在编辑器里输入“vue3”试试,应该能看到自动弹出的补全列表,选择“vue3-codemirror-basic”,编辑器会自动补全整个基础模板,按下Tab键可以切换占位符!
这里再补充几个自定义代码补全的进阶技巧:
- 可以根据当前光标所在的上下文来显示不同的补全项,比如如果在HTML的
code前端网