☰
Vue3+CodeMirror 6实现公式编辑器:从字段高亮到语法校验全指南
2026/10/3 5:59:59 网站建设 项目流程

做后台管理系统时间长了,你一定会碰到这类需求:让用户在页面上配置规则引擎、定义计费口径、设置KPI指标,说白了就是让业务人员直接写公式。一开始我也想过用普通的input框,结果试运行阶段就收到一堆反馈:“公式写了一半不知道有哪些字段”“少写个括号找半天”“写错了也没提示”。没办法,只能老老实实做一个真正的公式编辑器。

调研一圈之后,我最终选了Vue3 + CodeMirror 6的组合。为什么不用Monaco,为什么不上CodeMirror 5,后面会详细说。这篇博文就围绕“如何用Vue3 + CodeMirror 6从零搭一个公式(规则)编辑器”这件事展开,从依赖安装、组件骨架、字段高亮、自动补全、语法校验,到跟Vue3生命周期和表单联动时踩过的那些坑,一次性讲清楚。

如果你是做后台管理系统,尤其是规则配置、评分计算、指标定义这类模块的前端同学,这篇文章应该能帮你少走很多弯路。代码基于Vue3的组合式API + TypeScript风格编写,但为了阅读方便,示例里我只保留了核心逻辑,你根据自己的项目情况微调即可。

1. 需求分析与方案选型

1.1 公式编辑器要解决的真实场景

先说清楚我们要做的到底是什么。这里说的公式编辑器,不是在页面上画一个可以输入数学表达式的输入框,而是面向业务场景的“规则表达式录入器”。它通常要满足这几点:

  • 支持插入业务字段,比如“{销售数量}”、“{销售金额}”、“{成本}”,而不是让用户凭空输入。
  • 支持函数,比如IF、SUM、ROUND、MAX、MIN这类语法。后台管理场景里,条件判断和汇总计算是最常见的。
  • 要能高亮区分字段、函数、数字、运算符,让用户一眼看出公式结构。
  • 自动补全,用户输入到一半,要给候选字段和函数。
  • 语法校验,括号没闭合、函数参数不对、表达式以运算符结尾,这些错误都要能标出来。
  • 跟表单集成,比如用户写错了,提交按钮要能感知并阻止提交。

一开始我也试图用textarea + 自绘高亮来解决。方案是底层透明textarea,上层盖一层带颜色的div,滚动同步、选区同步、光标同步,听着简单,做起来全是坑。中文输入法下的composition事件、换行缩进、选区高亮与光标重叠,任何一个细节都能耗掉你半天。所以,用一个成熟的代码编辑器内核是必然选择。

1.2 为什么是CodeMirror 6而不是Monaco或CodeMirror 5

市面上的代码编辑器内核,主要是Monaco和CodeMirror两大阵营。我拿它们做了对比:

对比项Monaco EditorCodeMirror 5CodeMirror 6
包体积大,需要额外配置worker,按需加载成本高中等,整体引入为主小,按需引入扩展包
自定义语言支持需要理解Monarch语法,配置略重支持StreamParser,但架构老旧支持StreamLanguage或Lezer,扩展点很清晰
与框架集成需要自己管生命周期,存在dom diff冲突风险集成方式偏命令式,文档较少视图与状态分离,适合封装成组件
中文输入法兼容一般,需要额外处理composition一般较好,内置composition处理
长期维护微软维护,很活跃官方停止新增功能,只维护bug官方主推版本,生态已在迁移

对我的场景来说,Monaco有点“杀鸡用牛刀”。我们只是写公式,不是写JavaScript,Monaco的很多能力用不上,反而增加打包体积和心智负担。CodeMirror 5虽然老牌稳定,但官方已经把重心放在6代,新项目再选5代等于给自己埋雷。

CodeMirror 6最打动我的是它的模块化设计——核心包只负责状态管理和渲染,高亮、补全、lint、历史记录、缩进这些能力全部通过扩展注入。这让它很适合封装成Vue3业务组件:我要哪些能力,就装哪些扩展,不要的功能不引入,打包体积可控。

还有一个细节,CodeMirror 6把EditorState(数据)和EditorView(视图)分开,这个概念跟Vue3的单向数据流非常契合。外部值变化时我更新state,编辑器内部输入时我派发事务给state,然后由view重绘。相比CM5那种命令式的DOM操作,CM6用起来清爽太多。

2. 搭建最小可运行的Vue3组件

2.1 安装依赖与包职责划分

先装依赖。我实际项目里用的是这套:

npm install codemirror @codemirror/state @codemirror/view @codemirror/commands @codemirror/language @codemirror/autocomplete @codemirror/lint @lezer/highlight

有几个包如果你只需要某一个主题,可以再加:

npm install @codemirror/theme-one-dark

这些包的关系,我简单理一下,不然新手容易懵:

  • @codemirror/state:编辑器状态核心,EditorState、StateEffect、RangeSetBuilder都在这里。
  • @codemirror/view:编辑器渲染层,EditorView、Decoration、ViewPlugin、keymap在这里。
  • @codemirror/commands:内置命令,比如历史记录、光标操作。history扩展和defaultKeymap都从这里来。
  • @codemirror/language:语言相关支持,括号匹配bracketMatching、多语言基础设施都在这里。
  • @codemirror/autocomplete:自动补全。
  • @codemirror/lint:lint校验和错误标记。

也就是说,如果我要做一个通用代码编辑器,需要把这些扩展组合起来。好消息是codemirror这个包已经帮你把常用扩展打包好了,但公式编辑器场景往往需要自定义语法和补全逻辑,所以我更建议显式引入各个子包,逻辑清楚,后面排查问题也容易。

2.2 Vue3组件骨架与EditorView初始化

先写一个最简组件:

<template> <div ref="editorRef" class="formula-editor"></div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import { EditorView, keymap, placeholder } from '@codemirror/view' import { EditorState } from '@codemirror/state' import { history, defaultKeymap, historyKeymap } from '@codemirror/commands' const props = defineProps({ modelValue: { type: String, default: '' } }) const emit = defineEmits(['update:modelValue']) const editorRef = ref(null) let view = null onMounted(() => { const state = EditorState.create({ doc: props.modelValue || '', extensions: [ history(), keymap.of([...defaultKeymap, ...historyKeymap]), placeholder('请输入公式,例如 {销售金额} * 0.2'), EditorView.lineWrapping, EditorView.updateListener.of((update) => { if (update.docChanged) { emit('update:modelValue', update.state.doc.toString()) } }) ] }) view = new EditorView({ state, parent: editorRef.value }) }) onBeforeUnmount(() => { view?.destroy() view = null }) </script>

这里有三个关键点必须说透。

第一,EditorState.create中的extensions是编辑器能力的核心配置。你可以把它理解成一个“插件列表”,CodeMirror 6的所有行为都由这些扩展驱动。updateListener监听文档变化,并把最新内容抛给父组件,这就是我们实现v-model的第一步。

第二,new EditorView接受一个parent,把编辑器挂载到指定DOM节点。这个节点必须已经存在,所以一定要放在onMounted里执行。

第三,组件卸载时一定要调用view.destroy()。这一步看似无关紧要,实际是排查内存泄漏和事件堆积的重灾区。尤其是后台管理系统里,编辑器可能天天创建销毁,不destroy会导致DOM节点残留和监听器泄漏。我见过同事项目里切页面后,控制台报错“Cannot read properties of undefined (reading 'state')”,多半就是没有destroy。

外层样式上,我习惯给容器一个最小高度:

.formula-editor { min-height: 120px; border: 1px solid #d9d9d9; border-radius: 6px; overflow: hidden; } .formula-editor .cm-editor { min-height: 120px; } .formula-editor .cm-editor.cm-focused { outline: none; }

注意.demo里我关掉了聚焦时的默认outline,不然那个蓝色边框和业务组件自带的边框叠在一起会很突兀。实际项目中,你很可能需要给编辑器外框的focus状态自己定义样式,这样才能和整个设计系统统一。

3. 公式语言支持:字段高亮与括号匹配

3.1 自定义公式语法结构

在写高亮之前,先定义公式的语法约定。不同的业务系统字段包法不一样,有的用${},有的用{{}},我用的是{},比如:

IF({销售金额} > 10000, {销售金额} * 0.1, 0)

这种写法的好处是,业务字段在字符串里一目了然,和函数、数字、运算符天然区分。

公式里的元素大概分这几类:

  • 字段:用{}包裹的业务字段名。
  • 函数:大写的标识符,如IF、SUM、ROUND。
  • 数字:整数、小数。
  • 运算符和符号:+、-、*、/、>、<、=、,、(、)、空格。

3.2 用ViewPlugin实现轻量级高亮

CodeMirror 6的高亮标准做法是基于语法树。但公式编辑器的语法往往比较简单,而且字段名可能是中文,直接用Lezer定义一套完整语法,前期成本和维护负担都不小。

我一开始走了Lezer的路线,后来发现业务字段名经常变,又要兼容历史公式里的各种写法,语法文件改起来非常痛苦。所以最后我用了另一个思路:不搞语法树,用ViewPlugin+Decoration对文档做“装饰”。

装饰是什么意思?你可以把编辑器渲染结果理解为一层一层的标记,Decoration.mark可以在不用修改文档内容的情况下,给某段文本加上CSS类名。这和浏览器里给文本加<mark>标签是同一个道理。

核心代码如下:

import { ViewPlugin, Decoration, EditorView } from '@codemirror/view' import { RangeSetBuilder } from '@codemirror/state' const FIELD_RE = /(\{[^{}]*\})|(\b(?:IF|SUM|ROUND|MAX|MIN|AVERAGE)\b)|(\b\d+(?:\.\d+)?\b)/g function buildDecorations(view) { const builder = new RangeSetBuilder() const text = view.state.doc.toString() FIELD_RE.lastIndex = 0 let match = null while ((match = FIELD_RE.exec(text))) { const from = match.index const to = from + match[0].length const type = match[1] ? 'field' : match[2] ? 'func' : 'number' const className = `cm-formula-${type}` builder.add(from, to, Decoration.mark({ class: className })) } return builder.finish() } const formulaHighlightPlugin = ViewPlugin.fromClass( class { constructor(view) { this.decorations = buildDecorations(view) } update(update) { if (update.docChanged || update.viewportChanged) { this.decorations = buildDecorations(update.view) } } }, { decorations: (v) => v.decorations } )

然后在extensions里加上:

formulaHighlightPlugin, EditorView.theme({ '.cm-formula-field': { color: '#c678dd' }, '.cm-formula-func': { color: '#61afef', fontWeight: '600' }, '.cm-formula-number': { color: '#d19a66' } })

这段代码里有几个坑,我实际操作时都踩过,值得单独说。

第一,正则里用了全局标志g,并且手动设置lastIndex = 0。如果不重置,正则对象会在多次调用之间共享上次匹配的位置,导致第二次高亮错乱。这是个非常隐蔽的bug,不熟悉正则状态机的同学很容易忽略。

第二,RangeSetBuilder要求添加的区间必须有序且不重叠。我在正则里用三个捕获组一次扫描,同一段文本只会命中一个组,从逻辑上保证了不会重叠。如果你分开写多个正则,然后分别add区间,很容易因为区间重叠抛出异常。这个教训我印象很深,第一次写的时候,字段里恰好包含数字,两个正则各自匹配了一段交叉区域,chrome控制台直接报Failed to execute 'add' on 'RangeSetBuilder'。

第三,update的触发条件是docChanged || viewportChanged。注意viewport变化,因为编辑器滚动时,可视区域会变化,装饰也需要重算,否则滚动到下面新区域的内容没有高亮。这一点在长公式场景下尤其明显。

3.3 括号匹配与光标移动体验

公式编辑器里,括号是否匹配是用户最容易出错的点。CM6的bracketMatching扩展可以直接帮我们做到光标靠近括号时高亮配对的括号。

import { bracketMatching } from '@codemirror/language' // extensions 中加入 bracketMatching()

默认它支持()[]{}这几种括号。如果你的业务字段用了${},也可以自定义:

bracketMatching({ brackets: '()[]{}${}' })

这里${}会被解析成$字符加花括号,在公式场景里,我们并不需要匹配$,所以一般保持默认即可。

再配合一个自动补全括号的快捷键体验会更好。我加了一个简单的keymap,输入(时自动补全),输入{时自动补全}:

import { keymap } from '@codemirror/view' const autoCloseBrackets = { '(': ')', '{': '}' } const bracketKeymap = keymap.of([ { key: '(', run: (view) => { view.dispatch({ changes: { from: view.state.selection.main.head, insert: '()' }, selection: { anchor: view.state.selection.main.head } }) return true } } ])

不过这个属于锦上添花,不建议第一次做就堆上去。先把基础功能跑通,后续再按体验迭代。

4. 自动补全与字段插入

4.1 补全数据源设计

公式编辑器相比纯代码编辑器,最核心的体验差异在于:字段列表不是靠猜,而是来自业务的明确配置。所以补全数据源必须是动态的。你可以把字段数组传给组件:

const props = defineProps({ modelValue: { type: String, default: '' }, fields: { type: Array, default: () => [] } })

fields里每一项就是一个业务字段:

const fields = [ { name: '销售数量', type: 'number', desc: '订单明细里的销售数量' }, { name: '销售金额', type: 'number', desc: '订单明细里的含税金额' }, { name: '成本金额', type: 'number', desc: '订单明细里的成本金额' } ]

有了这个基础,我们可以组装补全选项。CodeMirror 6的自动补全选项结构比较复杂,我常用的字段有:

  • label:候选列表里显示的名称。
  • type:类型标识,CM6内置支持variable、function、number等。
  • detail:右侧的辅助说明。
  • apply:选中后实际插入文档的文本,支持模板占位符${},插入后光标会自动停留在占位符位置。

再补充函数列表:

const functionOptions = [ { label: 'IF', type: 'function', detail: '条件判断', apply: 'IF(${条件}, ${真值}, ${假值})' }, { label: 'SUM', type: 'function', detail: '求和', apply: 'SUM(${字段})' }, { label: 'ROUND', type: 'function', detail: '四舍五入', apply: 'ROUND(${数值}, ${小数位})' }, { label: 'MAX', type: 'function', detail: '取最大', apply: 'MAX(${字段1}, ${字段2})' }, { label: 'MIN', type: 'function', detail: '取最小', apply: 'MIN(${字段1}, ${字段2})' } ]

4.2 注册autocompletion并处理中文字段名

自动补全的扩展比较简单:

import { autocompletion } from '@codemirror/autocomplete' autocompletion({ override: [formulaCompletions] })

formulaCompletions是一个返回补全结果对象的函数。这里最麻烦的是中文字段名和{}包裹符的处理。

CodeMirror默认的补全触发是基于代码上下文推断的,比如输入字符后触发。对于公式场景,我简单实现了一个策略:只要用户输入了非空白字符就触发,并且把候选列表从光标往前推。

import { CompletionContext } from '@codemirror/autocomplete' function formulaCompletions(context) { const word = context.matchBefore(/[\w{}$]*/) if (!word) return null const options = [ ...props.fields.map((f) => ({ label: f.name, type: 'variable', detail: '字段', apply: `{${f.name}}` })), ...functionOptions ] return { from: word.from, options, validFor: /^[\w{}$]*$/ } }

注意几个细节:

  • context.matchBefore拿到的是光标前的文本片段。这里用[\w{}$]*,是为了把{、$也纳入匹配范围,否则用户输入{销时,匹配结果只有销,补全返回的from位置会在{之后,插入时就会变成{ 销售数量 },不满足我们的格式要求。
  • apply里给中文字段名包裹了{},这是整个编辑器与业务约定一致的关键。很多第一次写的人只传了字段名,忘记包花括号,生成的公式语法上就是错的。
  • validFor用于判断当前候选是否仍然有效。这个字段如果不写,CM6会根据默认规则判断,可能出现候选列表还没关闭就已经失效的情况。

但这里有一个性能隐患:props.fields直接作为闭包变量被读取。如果父组件传入的fields是个很长的数组,用户每次输入都会重新遍历组装一次,这在字段数几百个的时候也能接受,但如果上千,建议把组装好的options缓存一下。我项目里是拿字段名做了个Map,补全时只筛选前缀匹配的,性能好很多。

4.3 通过按钮事件插入字段

后台管理系统的常规交互里,自动补全是一方面,但有些用户就是不习惯自己敲字段名。更友好的方式是编辑区旁边放一个“插入字段”按钮,点击某个字段,直接把它插到光标位置。

实现这个功能的核心是操作编辑器视图派发事务:

function insertTextAtCursor(text) { if (!view) return const { from } = view.state.selection.main view.dispatch({ changes: { from, insert: text }, selection: { anchor: from + text.length }, scrollIntoView: true }) view.focus() }

这个方法不仅适用于插入字段,也适用于插入函数模板。我在实际组件里,给props.fields渲染了一个下拉面板,支持搜索,选中项后调用insertTextAtCursor('{销售数量}')插入。

有个体验细节:点击按钮后,一定要调用view.focus()。否则用户点击字段列表,编辑器会失焦,下次再输入就得重新点一下编辑区。从交互上讲,插完字段保持焦点,用户可以直接继续敲运算符,这个顺滑度是明显不一样的。

还有一点,如果编辑区是在弹窗或者折叠面板里,插入字段前最好检查一下view是否存在。比如弹窗刚打开时用户快速点击插入,onMounted可能还没执行完,这时候view还是null。为了稳妥,我在组件内部加了一个ready状态,按钮的点击事件里会先判断:

function handleInsertField(fieldName) { if (!view) return insertTextAtCursor(`{${fieldName}}`) }

5. 语法校验与错误提示

5.1 一个够用的linter方案

自动补全解决的是“怎么写”的问题,lint解决的是“写错了怎么办”的问题。CM6的lint扩展会把诊断结果显示在编辑器底部的小红点和错误区块上,鼠标悬停还能看到错误信息。

先引入:

import { linter, lintGutter } from '@codemirror/lint'

然后写一个不用语法树、纯字符串扫描的linter:

function formulaLinter(view) { const text = view.state.doc.toString() const diagnostics = [] const pairMap = { '(': ')', '{': '}' } const closeToOpen = { ')': '(', '}': '{' } const stack = [] for (let i = 0; i < text.length; i++) { const ch = text[i] if (pairMap[ch]) { stack.push({ char: ch, pos: i }) } else if (closeToOpen[ch]) { const top = stack.pop() if (!top || top.char !== closeToOpen[ch]) { diagnostics.push({ from: i, to: i + 1, severity: 'error', message: `多余的右括号 ${ch}` }) } } } while (stack.length) { const top = stack.pop() diagnostics.push({ from: top.pos, to: top.pos + 1, severity: 'error', message: `${top.char} 没有闭合` }) } // 简单检查是否以运算符结尾 if (/[+\-*/]$/.test(text)) { const from = text.length - 1 diagnostics.push({ from, to: from + 1, severity: 'warning', message: '表达式不能以运算符结尾' }) } return diagnostics }

然后在扩展中加入:

linter(formulaLinter), lintGutter()

lintGutter()会在编辑器左侧显示错误标记,linter配置的是实际校验逻辑。注意,这里的linter是纯同步函数,CM6在每次文档变化后会自动重跑一遍,对公式这种短文本来说,完全不存在性能问题。

括号配对这个实现,核心是栈匹配。遇到左括号入栈,遇到右括号弹栈并比对类型。如果栈为空或者类型不对,说明右括号多了;如果遍历结束栈里还有残留,说明左括号没闭合。

5.2 校验结果如何反馈给业务

lint扩展的UI反馈是有了,但业务侧往往还需要知道“当前公式是否合法”,以此控制提交按钮的可用状态。

我踩过一个坑:直接尝试从EditorView里读取lint诊断结果。CM6的lint状态并不像补全那样容易通过公开API获取,它的诊断信息由内部linter扩展管理,需要额外的state字段才能读到,而且lint结果可能是异步派发的,时机不好控制。

所以我最后用了更稳妥的方案:把校验逻辑抽成一个独立的纯函数validateFormula(text),lint扩展和外部提交时都调用这个函数。这样内外共用一个校验源,lint负责可视化提示,业务侧提交时再跑一遍无痕校验。

function validateFormula(text) { if (!text.trim()) { return [{ from: 0, to: 0, severity: 'error', message: '公式不能为空' }] } const diagnostics = [] // ... 括号配对、非法字符、运算符结尾等逻辑 return diagnostics } // lint扩展内部 function formulaLinter(view) { return validateFormula(view.state.doc.toString()) } // 父组件提交时 function handleSubmit() { const errors = validateFormula(editorText.value) if (errors.length > 0) { message.error(errors[0].message) return } // 继续提交逻辑 }

这个设计我一直沿用到现在,好处是逻辑单一,测试也好写。你如果以后要支持更多校验规则,也只需要改validateFormula一个地方。

6. 与Vue3场景的深度集成

6.1 v-model双向绑定的正确姿势

前面最简组件里已经实现了单向的update:modelValue,但还缺一个反向流程:父组件改了modelValue,编辑器内容要跟着变。否则你做一个“重置表单”功能时,编辑器内容纹丝不动,用户会以为组件坏了。

反向同步需要watch:

import { watch } from 'vue' watch( () => props.modelValue, (newVal) => { if (!view) return const current = view.state.doc.toString() if (current === newVal) return view.dispatch({ changes: { from: 0, to: current.length, insert: newVal || '' } }) } )

这里有两个坑。

第一个坑是死循环。用户输入时触发了updateListener的emit,进而父组件更新了modelValue,watch又触发,然后我们会先检查current === newVal,如果相等就直接返回。等等,是不是真的相等?实际情况可能不完全相等,因为父组件可能对值做了trim等处理。为了保险,我在updateListener里设置了一个isInternalChange标志位:

let isInternalChange = false EditorView.updateListener.of((update) => { if (update.docChanged) { isInternalChange = true emit('update:modelValue', update.state.doc.toString()) requestAnimationFrame(() => { isInternalChange = false }) } }) watch(() => props.modelValue, (newVal) => { if (!view || isInternalChange) return const current = view.state.doc.toString() if (current === newVal) return view.dispatch({ changes: { from: 0, to: current.length, insert: newVal || '' } }) })

第二个坑更隐蔽:如果用数组作为modelValue类型,引用变化每次都会触发watch,而编辑器内部比较时比较的是字符串。所以一定要用字符串作为v-model的类型。

6.2 异步字段数据更新

后台管理系统的字段列表往往来自接口,比如用户先选择某个数据源,然后接口返回该数据源的字段列表。这意味着我们的补全选项会有“初始为空、数据到达后要更新”的需求。

前面写的formulaCompletions因为闭包直接读了props.fields,Vue3的props是响应式对象,所以函数内访问props.fields时能拿到最新值。但是,补全配置本身是EditorState.create时构建的,如果你把options缓存起来,刷新时机就要同步。

我在4.2里提到的优化方案是缓存options数组,这时候就需要手动刷新补全配置。CM6推荐的方式是使用Compartment:

import { Compartment } from '@codemirror/state' const completionCompartment = new Compartment() // extensions 里 completionCompartment.of( autocompletion({ override: [formulaCompletions] }) )

然后在字段数据更新后:

watch(() => props.fields, () => { if (!view) return view.dispatch({ effects: completionCompartment.reconfigure( autocompletion({ override: [formulaCompletions] }) ) }) }, { deep: true })

Compartment是CM6里专门用来动态替换扩展的机制,它允许你在运行时重新配置某个扩展,而不需要重建整个编辑器。不过对于大多数项目,不缓存options也够用,我这边是因为字段数量最多会有两三千个,每次输入都重新组装选项会有明显卡顿,才引入了刷新机制。

6.3 弹窗、表格与多实例场景

后台里公式编辑器经常出现在弹窗里。这里有一个常见问题:弹窗用v-if控制,组件挂载时弹窗动画还没结束,尺寸可能为0。CM6在初始化时如果容器高度为0,会得到一块空白的编辑区,滚动和点击都会异常。

我处理这个问题的思路是:

  • 弹窗结构里,等弹窗完全打开后再渲染组件。一些组件库提供了afterOpen事件,没有的话就在v-if变为true后加一个setTimeout或nextTick再挂载。
  • 给.formula-editor设置min-height,让编辑器容器在内容为空时也有一块固有高度。

用nextTick通常不够保险,因为弹窗动画的时长不确定。我自己是用Element Plus时,会把编辑器放在弹窗内容区,弹窗的destroy-on-close配合组件内部的onMounted,在第一次打开时初始化。实测下来,给容器固定min-height后,即使弹窗尺寸在动画中,编辑器也不会出现高度为0的情况。

多实例场景下要注意的是:每个编辑器都有自己的EditorState和EditorView,绝对不能在模块级共享一个view。我在团队里看到过有一版代码,把view放在了模块作用域,结果打开两个编辑器弹窗,第二个弹窗操作时第一个也跟着变了。这种情况用组件实例内部的let view就可以解决,不要让多个编辑器实例共用同一个变量。

6.4 生命周期与销毁时机

这是老生常谈,但每次写都要强调:onBeforeUnmount里必须销毁编辑器。

onBeforeUnmount(() => { view?.destroy() view = null })

为什么说这个值得单独拿出来讲?因为CM6内部会监听DOM事件、IntersectionObserver、MutationObserver等。不销毁,这些监听会一直挂着,页面切走再切回来,可能报各种奇怪的错。

还有一个点是HMR热更新。开发环境用Vite时,改了组件代码会触发热替换,组件可能不会走完整的卸载流程,导致编辑器实例被挂在一个已被替换的DOM节点上。视觉表现就是编辑器区域空白或者重复渲染。这时候你可以手动刷新页面,也可以在组件里配合import.meta.hot处理一下,不过开发环境忍忍就好,生产环境一般不会碰到。

7. 常见问题与排查实录速查表

这部分我把自己和团队碰到的真实问题整理成表,你照着排查可以省很多时间。

问题表现可能原因解决思路
编辑器区域空白,高度为0容器在弹窗/折叠面板内,初始化时不可见或不占位给容器设置min-height;等动画结束后再挂载;检查onMounted是否晚于容器渲染
输入中文拼音时自动补全频繁弹出CM6把拼音字母当作有效代码触发补全在formulaCompletions里判断context.state.selection.main.head位置的上下文,中文输入法中拼音首字母一般不触发补全
字段补全后花括号位置不对matchBefore的正则没把{纳入匹配改成/[\w{}$]*/并正确设置validFor
外部值更新后光标跳动到末尾watch里直接changes替换全文先比较现有内容和新内容,相同则不dispatch;必要时记录原光标位置再恢复
两个弹窗编辑器互相干扰模块级共享同一个view变量改为组件实例内部变量,每个实例独立创建和销毁
滚动编辑器时新内容没有高亮ViewPlugin只监听docChanged补上viewportChanged触发重算
RangeSetBuilder报错多个正则生成的装饰区间重叠用单次正则扫描,用捕获组区分类型,保证区间有序不重叠
切换到下一个表单项时Tab被吃掉CM6默认Tab用于缩进自定义keymap里接管Tab,触发原生焦点切换
提交时拿不到lint结果lint诊断不直接暴露给业务把校验抽成独立函数,lint和提交时共用

Tab键冲突这个问题值得展开说。后台系统里用户习惯了按Tab切换焦点的逻辑,而CM6默认把Tab当成缩进键。在公式编辑器场景,公式很短,几乎不需要手动缩进,所以应该让Tab跳出编辑器。

我实现过一个处理方案:在keymap里接管Tab并主动触发原生tab行为。

import { keymap } from '@codemirror/view' const tabOverride = keymap.of([ { key: 'Tab', run: (view) => { // 手动触发下一个元素聚焦,避免编辑器吃掉Tab const el = view.contentDOM const focusable = el.closest('form')?.querySelectorAll('input, textarea, button, select') // 在focusable列表里找到当前元素的下一个元素并focus // 或者更粗暴地:el.blur() el.blur() return true } } ])

这个方案有一个缺点:直接blur()后,浏览器的默认Tab行为不会继续走,可能真的就停在编辑器上,用户再按一次Tab才有效。我最终用了更稳妥的方案:在keymap里先判断当前是否有选区或光标,如果当前编辑区内容为空,干脆不接管Tab,让浏览器的原生行为来触发焦点切换。这样处理起来代码要多写几行,但交互体验好很多。这个留给你们按需求取舍。

关于中文输入法导致补全误触的问题,我再补充一下。CM6本身以及官方autocomplete扩展,对composition输入是有处理的,但如果你自己写了输入监听,就要格外小心。我之前在updateListener里为了统计字数,直接拿view.state.doc去算,结果中文输入过程中拼音字母和候选词也会被计入,用户还没选字,字数统计就已经变了。最后的解法是判断update.transactions里是否有composition相关的状态变化,避免在输入法组合期间触发业务逻辑。

最后分享一个我个人比较受益的经验:CodeMirror 6的学习曲线比CM5陡不少,尤其刚开始接触EditorState、Transaction、ViewPlugin这些概念时,会觉得绕。但只要记住一个核心原则——所有内容变化都通过view.dispatch派发事务,所有视觉附加效果都通过扩展和装饰实现——后面写起来会越来越顺。

我也建议你在第一个版本里不要急着引入Lezer做完整语法解析。先用ViewPlugin装饰和简单正则扫描,把字段高亮、补全、校验跑通,跑稳了,再去考虑把语法解析升级成Lezer。公式编辑器这种业务组件,优先解决“让业务人员能快速、正确地写出公式”这个核心问题,后面的工程化升级是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询