Code前端首页关于Code前端联系我们

Vue3开发中如何快速集成并深度定制CodeMirror6代码编辑器?

terry 1小时前 阅读数 17 #Vue

说起前端开发里的代码编辑场景,不管是在线代码教学平台、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部分:

  1. 定义一个ref用来存储当前选中的主题;
  2. 定义一个主题映射对象,把主题名称和对应的主题扩展关联起来;
  3. 修改initEditor函数,让它从主题映射对象里取当前选中的主题;
  4. 定义一个切换主题的函数,动态更新编辑器的扩展。

修改后的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部分:

  1. 引入新安装的语言包;
  2. 定义一个ref用来存储当前选中的语言;
  3. 定义一个语言映射对象;
  4. 定义语言选项;
  5. 重新组织扩展结构,把语言扩展单独放在一个数组里;
  6. 定义切换语言的函数,动态更新编辑器的扩展;
  7. 监听当前选中的语言变化,自动切换语言。

这里的逻辑和主题切换几乎一模一样,就不重复写完整代码了,只说几个注意点:

  • 有些语言包会有多个版本,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键可以切换占位符!

这里再补充几个自定义代码补全的进阶技巧:

  1. 可以根据当前光标所在的上下文来显示不同的补全项,比如如果在HTML的