做了几年后台管理系统,最让我头疼的需求之一就是"表单里让用户填一条计算规则"。你说它是代码吧,用户不认;你说它是纯文本吧,业务方又不满意,说没有提示、写错了也不知道。直到我尝试用 Vue3 加 CodeMirror 6 自研了一个公式(规则)编辑器,才算把这摊事彻底理顺。这篇文章就当一次完整复盘,从选型到核心概念,再到具体实现和踩坑记录,一次性给你讲透。
CodeMirror 6 和上一个版本完全是两个物种,装包方式、API 风格、扩展机制全都变了。很多同学卡在第一步,打开官方文档完全不知道从哪看起,或者照着 v5 的旧习惯写了一堆代码,结果一堆报错。这篇文章就以一个可运行的 Vue3 组件为线索,把 CM6 的几个关键概念先讲明白,再带你一步步搭出带语法高亮、函数提示、字段补全、括号匹配、语法校验的规则编辑器。不管你是做低代码平台、规则引擎还是消息模板,这套思路都能直接复用。
1. 为什么这个需求我选了 CodeMirror 6 而不是 Monaco 或 Ace
1.1 公式编辑器到底是个什么东西
先别急着写代码,把需求看清楚。所谓的"公式(规则)编辑器",本质上是给非程序员的用户提供一个能安全描述业务逻辑的输入框。比如:
IF (订单金额 > 1000 AND 用户等级 == "VIP") THEN 折扣率 = 0.85或者:
SUM(订单明细.含税金额) * 税率这类输入有两个特点:有一定语法结构,但又比编程语言简单得多;用户需要实时反馈——函数名有没有写错、括号有没有闭合、字段名是否存在。你不可能让用户干瞪着一行红波浪线去猜问题,所以编辑器的提示能力、校验能力、补全能力比外观重要得多。
1.2 三个编辑器的取舍对比
我最初考虑过三条路子:Monaco Editor、Ace 和 CodeMirror。三者都是市面成熟的开源编辑器,但特性差异很大。
| 对比项 | Monaco Editor | Ace | CodeMirror 6 |
|---|---|---|---|
| 体积 | 较大,即使按需加载也有一定负担 | 中等 | 模块化,按需引入后相对轻量 |
| Vue3 集成 | 需要额外封装,有现成库但更新不稳 | 封装很多 | 官方没有绑定库,但设计干净,自己封装简单 |
| 自定义语言 | 需要学习 Monarch/JSON 定义 | 需要学习模式定义 | 自定义语言合理,基于 Lezer 解析器 |
| 补全组件 | 自带补全 UI,但定制侵入性高 | 自带补全,样式老旧 | 补全接口灵活,UI 可完全重写 |
| 移动端/多实例 | 重 | 轻一些 | 多实例性能好 |
我最终选 CM6,核心原因有三个:
- 场景匹配。公式编辑器不是写代码,不需要 IntelliSense 全家桶,但需要"准"。CM6 的 state 管理是单向数据流,和 Vue 的响应式理念天然合拍,数据同步不容易绕晕。
- 体积可控。后台系统通常已经很臃肿了,能省一点是一点。Monaco 一次加载下去,打包体积肉眼可见上涨。
- UI 定制自由度。CM6 把 DOM 结构完全开放给你,下拉提示、气泡提示、非法标记样式都可以按产品稿做,不用费劲去覆盖第三方样式。
1.3 CM6 版本选择的纠纷:npm 上 v5 和 v6 是不同项目
这里必须提醒一句:当你搜 CodeMirror 相关资料时,会看到大量codemirrornpm 包和@codemirror/*范围的包。前者是老版本包名,后者才是 CM6 的官方包。不要直接npm i codemirror了事,否则你拿到的是 v5,API 完全不同。
CM6 的核心包有几个:
codemirror:这是把基础功能打包好的"全家桶",引入它就能开箱即用。@codemirror/state:编辑器状态的承载者,负责文档内容、光标位置、选区、事务。@codemirror/view:渲染和交互层,负责 draw 视图、事件绑定、装饰。@codemirror/language:语言支持接口,比如语法树、缩进、自动补全的声明。@codemirror/autocomplete:补全功能。@codemirror/lint:校验功能。
实操时我建议只用一个入口:codemirror这个包,它把基础状态、基础视图、行号、历史记录、默认按键都组合好了,能省去你手动装配的大量功夫。其余功能包再按需添加。
2. 先搞懂 CM6 的几个核心概念:View、State、Transaction 和 Extension
2.1 EditorView 是"表",EditorState 是"里"
想快速上手 CM6,你只需要了解一个核心观点:编辑器分为"状态"和"视图"两层。
EditorState 存的是所有不依赖渲染的数据——文档内容、游标位置、选区范围、折叠状态、某些注解信息。它是一棵不可变的快照树,任何修改都生成一个新 state。
EditorView 负责把 state 画到屏幕上,处理滚动、输入事件、DOM 更新。每次 state 变化,view 会计算差异并精准更新对应 DOM,而不是整个重建。
这个设计对抗 Vue 响应式栈很友好。你不需要让编辑器内部再用一堆 reactive 数据,只需要在合适时机把变更后的 state 同步给外部即可。
import { EditorView } from "codemirror"; import { EditorState } from "@codemirror/state"; const state = EditorState.create({ doc: "SUM(1,2)", extensions: [] }); const view = new EditorView({ state, parent: document.body });这就是 CM6 最基础的启动方式。后续所有功能,都被塞进 extensions 数组里。
2.2 Transaction:数据单向流的核心
你可能在官方文档里见过 Transaction 这个词。它是 CM6 内部一切变更的载体。每次用户敲键盘、粘贴文本、程序里调用 dispatch,都会创建一个 Transaction。
可以这样理解:Transaction 是一个"变更说明书",声明了你把文档从哪一段改成哪一段、光标移动到哪、是否滚动到可见区域、附带哪些 metadata。
一个常见的操作:给编辑器插入一段文本。
// 获取当前选区的范围 const { from, to } = view.state.selection.main; // 在括号选中的位置生成一个事务并派发 view.dispatch({ changes: { from, to, insert: "SUM()" } });dispatch 之后,编辑器会生成新 state,然后自动触发视图重绘。这也是为什么说"视图最终只是状态的投影"。
理解这一点,后面做外部的"插入字段"按钮就非常简单——无非是通过 dispatch 往文档里塞字符串。
2.3 Extension:一切的装配方式
CM6 的扩展与 Vue3 的插件系统有点类似。指令、装饰、补全源、lint 源、键盘快捷键、高亮样式……全部统一为 Extension。
Extension 可以是单个扩展,也可以是数组嵌套扩展。你可以封装一个自定义指令,也可以把一组官方扩展组合起来:
import { basicSetup } from "codemirror"; import { keymap } from "@codemirror/view"; const myExtensions = [ basicSetup, keymap.of([/* 自定义按键 */]), // 自定义指示 ];basicSetup是官方提供的基础功能组合,包含行号、历史记录、括号匹配、默认撤销重做等。一个 90% 的项目通用配置。
拿生活里装修打个比方:EditorState 是毛坯房,extension 是地板、电线、水管的装配方案,Transaction 是每次改动水电线路的施工单。你只需要向房间说明"我要改成什么",剩下的刷新工作由房间自己完成。
3. 在 Vue3 里搭一个能跑的编辑器:生命周期处理是第一个坑
3.1 组件结构设计
CM6 并没有提供 Vue 专用的封装库,这意味着我们只需要简单的几步就能把编辑器封装成一个 Vue 组件。先看最简结构:
<template> <div ref="hostRef" class="formula-editor"></div> </template> <script setup> import { onBeforeUnmount, onMounted, ref, watch } from "vue"; import { EditorView, basicSetup } from "codemirror"; const hostRef = ref(null); let view = null; onMounted(() => { view = new EditorView({ parent: hostRef.value, extensions: [ basicSetup, // 后续扩展 ], }); }); onBeforeUnmount(() => { view?.destroy(); view = null; }); </script>就这么简单。EditorView的parent属性指定挂载容器,初始化后全部由 CM6 内部管理 DOM。
但这里有个容易踩的坑:组件卸载时 view 必须销毁。CM6 内部会绑定一堆全局事件和 ResizeObserver,如果不 destroy,会导致内存泄漏和重复绑定。尤其在一个列表页里面同时存在多个编辑器,不销毁的话页面会越来越卡,直到卡死。
3.2 初次加载:初始值如何设置
初始值需要给视图建立状态时传入,最简单的方式就是挂载前直接生成 state:
view = new EditorView({ parent: hostRef.value, state: EditorState.create({ doc: props.modelValue, extensions: extensions, }), });但如果你打算在 onMounted 之后再由外部异步加载初始内容,也可以换成先创建 view,再 dispatch 一个全文档替换的事务。不过那样会有一次肉眼可见的"空白闪现",数据量不大时没必要。
还有一种更稳妥的写法,是初始化时用一个空文档,随后 watch 外部值,但为了不覆盖用户的输入,判断"当前文档是否等于外部值"再决定是否 update。
3.3 给组件加上 v-model 效果
这是大家最关心的部分。我需要让编辑器的内容能够同步到 Vue 的数据流里,最好能直接v-model。
业务侧用法:
<FormulaEditor v-model="ruleText" :fields="fields" :functions="functions" />组件内部通过update:modelValue抛出变更事件。但必须注意:不要在每次按键时都同步整个字段。如果父组件拿到值又立刻计算一堆内容,很容易卡顿。
我的做法是监听更新事件,通过自定义事务注解或简单防抖,将编辑器内容同步到外部:
import { updateListener } from "@codemirror/view"; extensions: [ updateListener.of((update) => { if (update.docChanged) { const value = update.state.doc.toString(); emit("update:modelValue", value); } }), ]updateListener是一个非常重要的高阶扩展回调,它能监测到文档变化、窗口变化、selection 变化等。实际场景中,我更喜欢在 update 里同时判断update.selectionSet,这样父组件还可以拿到当前光标位置,从而给用户显示"当前在第几行"状态。
同时,组件内部需要 watch 外部值,处理外部重置场景:
watch( () => props.modelValue, (newVal) => { const current = view.state.doc.toString(); if (newVal !== current) { view.dispatch({ changes: { from: 0, to: current.length, insert: newVal || "" }, }); } } );这里必须加上"内容不相等才更新"的判断,否则用户输入时会出严重问题。用户敲一个字符,触发 updateListener,父组件更新 modelValue,然后 watch 又触发,发现新旧不同,又 dispatch 一次,导致光标跳动或进入死循环。必须用 if 卡住。
3.4 组件 props 设计
一个公式编辑器组件需要哪些对外输入?我按实际项目的经验列一下:
| 参数 | 类型 | 说明 |
|---|---|---|
| modelValue | string | 公式内容,v-model |
| fields | Field[] | 候选字段列表,如{ name: 'orderAmount', label: '订单金额', type: 'amount' } |
| funcs | Func[] | 候选函数列表,如{ name: 'SUM', args: '...', desc: '求和' } |
| readonly | boolean | 是否只读 |
| placeholder | string | 空状态提示 |
| validateMode | 'none' / 'onChange' / 'onBlur' | 校验时机 |
这样设计的好处是组件保持低耦合。它不关心业务数据从哪来,只负责"展示和编辑一段符合语法的文本"。
3.5 值得单独的初始配置封装
当所有扩展都要传入组件接收的 props 时,扩展数组应通过 computed 生成,避免每次渲染都重建:
const extensions = computed(() => [ basicSetup, placeholder(props.placeholder), formulaLanguage, formulaAutocomplete(props.fields, props.funcs), formulaLint(props.fields, props.funcs), readonly ? EditorState.readOnly.of(true) : [], updateListener.of((update) => /* ... */), ]);需要注意的是:extensions 不要每次组件更新重新生成,否则 CM6 内部会重新计算很多次,影响性能。如果确实依赖响应式数据,建议用 computed 或把可变部分拆成动态扩展。
4. 公式编辑器的核心体验:语法高亮、自动补全、括号匹配
4.1 先别急着造语言解析器,用 Tag 高亮就够了
很多人一听到"语法高亮"就想到要写 Lezer 语法文件、生成语法树。其实对于大多数业务公式编辑器,我建议你从"装饰器 + tag 高亮"入手,足够应对绝大多数场景。
CM6 支持通过Decoration给文档加高亮样式。你可以在文档中正则匹配出函数名、数字、字符串、运算符,然后给它们打上不同的 class。
但更"正统"的做法是使用 language data。CM6 里可以注册一组styleTags,配合 StreamLanguage 或者简单的 Lezer 语法。
如果从头写 Lezer parser 成本偏高,业界常用做法是使用 @codemirror/language 里的StreamLanguage。它允许你用正则流式匹配 token,适合"公式"这种规则简单的 DSL。
下面是一个极其精简的示例,只做了四类 token:字符串、数字、标识符、运算符:
import { StreamLanguage, LanguageSupport, indentNodeProp, foldNodeProp } from "@codemirror/language"; import { styleTags, tags as t } from "@lezer/highlight"; const formulaLang = StreamLanguage.define({ token(stream) { if (stream.match(/^"(?:[^"]|"")*"?/)) return "string"; if (stream.match(/^\d+(\.\d+)?/)) return "number"; if (stream.match(/^[A-Za-z_]\w*/)) return "variableName"; if (stream.match(/^==|^!=|^>=|^<=|^>|^<|^=|\+|-|\*|\/|\(|\)|,/)) return "operator"; stream.next(); return null; }, }); const formulaSupport = new LanguageSupport(formulaLang, [ styleTags({ "string": t.string, "number": t.number, "variableName": t.variableName, "operator": t.operator, }), ]);随后将formulaSupport加入 extensions,文档里的字符就会自动按类渲染样式。默认高亮主题会对这些 tag 渲染颜色,你也能通过.cm-content下的 class 覆盖。
4.2 括号匹配与自动补全括号
CM6 自带括号匹配。basicSetup里面已经包含了bracketMatching()和closeBrackets()。但如果你没用 basicSetup,需要手动引入:
import { bracketMatching } from "@codemirror/language"; import { closeBrackets } from "@codemirror/view"; extensions: [ bracketMatching(), closeBrackets(), ]括号匹配的效果:光标停在括号上时,另一个括号高亮;用户输入左括号时自动补全右括号。这对公式编辑器是刚需。配上一个括号高亮主题,具体操作体验基本接近代码编辑器了。
有些场景还需要"外部点击某个位置,光标跳转并插入公式片段"。这时需要用 dispatch 完成选区设置:
function insertAtCursor(view, text) { const { from, to } = view.state.selection.main; view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length }, }); view.focus(); }4.3 自动补全:补全函数、字段名、参数提示
自动补全是我认为 CM6 做得最顺手的功能。你只需要给编辑器提供一个autocomplete的 source 函数,返回候选列表,剩下的下拉框、高亮、按键交互都由框架处理。
先安装:
npm install @codemirror/autocomplete然后写一个补全源:
import { autocompletion, CompletionContext } from "@codemirror/autocomplete"; function formulaCompletions(fields, funcs) { return (context) => { // 获取光标前的单词 const word = context.matchBefore(/[\w$]*/); if (!word || word.from === word.to && !context.explicit) return null; const options = [ ...fields.map(f => ({ label: f.name, type: "field", detail: f.label, info: f.desc || "", })), ...funcs.map(f => ({ label: f.name + "()", type: "function", detail: f.desc, boost: 10, })), ]; return { from: word.from, options, }; }; } extensions: [ autocompletion({ override: [formulaCompletions(props.fields, props.funcs)], icons: false, // 不需要默认图标 }), ]matchBefore的关键是正则,它能决定你输入到哪一段触发补全。比如我用的[\w$]*,当用户输入 "SU" 时,word 的 from 会指向 S 之前,补全出来的内容会替换整个 "SU"。
这里有一个小细节:点击补全项里的"函数"时,我期望它直接生成SUM(),而且光标自动落在括号中间。CM6 的自动补全可以给选项带apply字段,支持重定义插入结果:
{ label: "SUM", type: "function", apply: "SUM()", // 光标不能直接设置,需要在 apply 后手动操作 }但 apply 没法直接指光标位置。我通常用一个小技巧:先 apply 成SUM(),然后通过 updateListener 检测到 apply 后主动移动一次光标:
updateListener.of((update) => { if (update.transactions.some(tr => tr.annotation(Transaction.userEvent) === "input.autocomplete")) { // 在刚才插入的文字后面往前移动一位 } })不过在大部分业务公式里,简单一点也行,补全后用户自己点一下括号中间。如果想做得更细,可以考虑对函数项做 execute 后的 selection 调整,我后面在踩坑篇章里再展开。
4.4 悬停提示:鼠标悬停到函数名上显示说明
做低代码平台时,产品总喜欢加一个"悬停显示函数说明"的效果。CM6 里叫hoverTooltip。
import { hoverTooltip } from "@codemirror/view"; function formulaHover(funcs) { return hoverTooltip((view, pos) => { const word = view.state.doc.lineAt(pos).text; // 简化:查找包含当前 pos 的单词 const { from, to, text } = view.state.wordAt(pos); if (!text) return null; const func = funcs.find(f => f.name === text); if (!func) return null; return { pos: from, end: to, above: true, create() { /* 返回 DOM */ } }; }); }为了让悬停内容好看,create里返回一个 DOM 节点,class 可以自己控制。这在模块配置中属于低成本高感知的功能,很值得做。
5. 让公式可校验、可上报:校验是这类组件的灵魂
5.1 为什么"能输入"不等于"能提交"
公式编辑器如果只做了高亮和补全,充其量是个好看的文本框。真正把它变成"规则编辑器"的,是校验体系:函数存不存在、字段存不存在、括号是否闭合、参数个数对不对、类型是否匹配。
CM6 的 Lint 机制可以做到像 ESLint 一样,在文档下方或者行内显示错误标记。渲染红波浪线、文档顶部显示错误数量,全都内置了。
安装:
npm install @codemirror/lint5.2 自定义 lint 源
lint 源是一个回调函数,接收 view 对象,返回诊断数组。诊断包含起始位置、结束位置、严重级别、消息文本。
我把规则拆成几类,非常实用:
import { linter, Diagnostic } from "@codemirror/lint"; function formulaLinter(fields, funcs) { return linter((view) => { const text = view.state.doc.toString(); const diagnostics = []; const fieldNames = new Set(fields.map(f => f.name)); const funcNames = new Set(funcs.map(f => f.name)); // 1. 括号配对 let stack = []; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === "(") { stack.push(i); } else if (ch === ")") { if (stack.length === 0) { diagnostics.push({ from: i, to: i + 1, severity: "error", message: "多余的右括号", }); } else { stack.pop(); } } } stack.forEach(pos => { diagnostics.push({ from: pos, to: pos + 1, severity: "error", message: "缺少右括号", }); }); // 2. 识别函数调用并检查函数是否存在 const funcPattern = /([A-Za-z_]\w*)\s*\(/g; let match; while ((match = funcPattern.exec(text))) { const name = match[1]; if (!funcNames.has(name)) { diagnostics.push({ from: match.index, to: match.index + name.length, severity: "error", message: `未定义的函数 ${name}`, }); } } // 3. 识别字段引用,检查字段是否存在(这里简化:只查双引号内部) const fieldPattern = /[A-Za-z_]\w*/g; while ((match = fieldPattern.exec(text))) { const name = match[1]; if (!fieldNames.has(name) && !funcNames.has(name)) { diagnostics.push({ from: match.index, to: match.index + name.length, severity: "warning", message: `未知的字段 ${name}`, }); } } return diagnostics; }); }linter函数会默认在每次文档变更后异步执行,并展示行内错误。你可以设置delay来防抖,例如delay: 300。
5.3 反馈给用户:错误标记与状态面板
Diagnostic 默认会在.cm-lintRange-error的 class 下加红色波浪线。你还可以自定义样式,加深颜色或加背景。
如果你还想在编辑器底部加一个错误统计面板,可以通过 lint 状态读取:
import { lintState } from "@codemirror/lint"; function readLintDiagnostics(view) { const state = view.state; const lnt = state.field(lintState, false); return lnt ? lnt.diagnostics : []; }我是在 updateListener 里读取诊断数量,然后 emit 给父组件,让提交按钮自动置灰或给出错误提示:
updateListener.of((update) => { if (update.docChanged || update.transactions.some(tr => tr.effects.some(e => e.is(lintState.updateEffect)))) { const diagnostics = readLintDiagnostics(update.view); emit("diagnostics-change", diagnostics); } })这就做到了实时校验、实时反馈的闭环。
5.4 校验时机:性能与体验的平衡
很多业务方说"要实时校验",但在大数据量场景下,每个字符都重新跑一遍语法分析,会有一定性能压力。我建议的策略是:
- 用户输入进行中(打字中间)只做轻量检查,如括号匹配、分词。
- 停止输入 500ms 后运行完整校验,包括函数/字段存在性和类型推导。
- 失焦时再强制运行一次完整校验并滚动到第一个错误位置。
CM6 的 linter 本身支持delay参数和异步返回,天然契合这些策略。
6. 踩坑实录与性能优化:从"能用"到"好用"
6.1 组件卸载/热更新销毁不彻底导致编辑器残留
这是我在实际项目里第一个踩的坑。当时在弹窗里嵌入编辑器,第一次打开正常,关闭弹窗后再打开,页面出现两个编辑器,或者旧实例仍然占用内存。
原因很简单:Vue 弹窗组件每次隐藏不销毁 DOM,但 CM6 的 view 还一直挂载在旧 DOM 上。想要彻底清理,必须在关闭前销毁。
经验做法是,在组件的 onBeforeUnmount 里调用view.destroy(),同时把宿主 DOM 的内容置空:
onBeforeUnmount(() => { if (view) { view.destroy(); view = null; } if (hostRef.value) { hostRef.value.innerHTML = ""; } });我还遇到过一种情况:页面用了keep-alive,组件被缓存了,onBeforeUnmount 不会被触发。这时要在onDeactivated里做类似的清理,或者在onActivated里惰性重建。不过公式编辑器一般是表单场景,我用 keep-alive 的频率低,但这种组合值得留意。
6.2 补全数据动态刷新不生效
另一个常见问题是:fields 数组通过异步接口拿到,编辑器加载后补全候选仍然是空的。
因为我把补全源写成闭包,props.fields 的变化不会影响已创建的 editor view,所以必须让补全源变成"可动态读取"的。
最简单的办法:用一个 ref 存储 fields,补全源函数内通过fields.value读取最新值,而不是把 fields 作为参数固定住:
const fieldCache = ref(props.fields); watch(() => props.fields, (val) => { fieldCache.value = val; }, { deep: true }); function formulaCompletions() { return (context) => { const fields = fieldCache.value; // ... }; }如果不想用 ref,也可以用 CM6 的 Facet 来管理动态数据。但业务上我建议 Vue 侧状态由 ref 统一管理,CM6 补全源就当成"查询函数",每次读取最新引用即可。
6.3 样式覆盖困难:Shadow DOM 与 CSS 作用域
CM6 默认的 DOM 结构里,编辑器主体类名是.cm-editor,包含.cm-scroller、.cm-content、.cm-line等。很多人用 scoped 样式时发现覆盖不生效,因为 scoped 会加><style scoped> .formula-editor :deep(.cm-editor) { font-size: 14px; border: 1px solid #ddd; border-radius: 4px; } .formula-editor :deep(.cm-content) { padding: 8px; } </style>
- 如果自定义内容很多,直接去掉 scoped,给 wrapper 加一个唯一的 class。
6.4 大数据量文本的渲染性能
公式编辑器通常处理短文本,但如果公式文本比较长(几百行),CM6 的虚拟渲染机制自然是没问题的。真正拖慢速度的是 lint 源里用正则反复扫描整个文档。
优化思路:只在旧诊断数据失效时重新扫描,利用view.state.doc的比较 hash。或者用简单的分段缓存:
let lastDocLength = -1; let cacheDiagnostics = []; linter((view) => { const { length } = view.state.doc; if (Math.abs(length - lastDocLength) < 50) { // 仅微调时可直接复用部分缓存 } lastDocLength = length; // ... })如果公式特别长,还可以把 lint 源改成异步的,先返回一部分诊断,再延时返回完整结果。
6.5 光标跳转和 Undo 栈的"脏"问题
每次通过外部按钮插入公式片段时,如果直接用简单的dispatch,默认它会被记录进历史记录。用户可能按一下 Ctrl+Z,把一整串插入撤销到空,体验不好。CM6 允许你在事务上加注解,跳过历史记录:
import { Transaction } from "@codemirror/state"; view.dispatch({ changes: { from, to, insert }, annotations: Transaction.addToHistory.of(false), });这种小技巧特别适合"点按钮插入字段"的场景。用户更期待 Ctrl+Z 只撤销自己打字的内容,而不是撤销一次外部操作。
6.6 空状态的占位符处理
CM6 默认没有 placeholder。官方有配套方案:通过placeholder()扩展。
import { placeholder } from "@codemirror/view"; extensions: [ placeholder("请输入公式,例如 SUM(订单金额) / 100"), ]配合.cm-placeholder样式,灰色的提示文字就会出现。千万不要用覆盖 doc 内容的方式做占位符,否则公式内容会直接被污染。
6.7 多个编辑器并存的内存与调度
有时候同一页面会出现多条规则,每条规则是一个编辑器实例。CMS 场景下很常见。CM6 每个实例会有自己的 state、view、lint 定时器、autocomplete 状态,实例多了以后,性能会明显下降。
我的建议是:
- 用虚拟滚动方式渲染多条规则,或者用展开编辑/弹窗编辑。
- 如果必须平铺展示且编辑器数量很多,可以考虑"编辑时才挂载真实 CM6,其余时间用普通文本展示"的懒加载方案。
总结与经验沉淀
做这个公式编辑器,核心收获不是"会用某个包",而是真正理解了 CM6"状态不可变、视图投影状态、扩展组合装配"的设计思路。模块化的代价是学习曲线陡一点,但换来的是几乎无限的可定制性。只要把 state、view、transaction、extension 这四个概念吃透,后面写高亮、补全、校验,本质上都是往 extensions 里加东西,思路非常统一。
从实际项目角度看,有一件事我建议提前就想清楚:编辑器底层送给用户的是"纯文本",但公式本身的语义元数据(字段名、函数签名、类型映射)必须由业务侧维护。编辑器负责的是"让这个文本更容易写对、更容易发现错误",而不是替你存业务数据。相当于编辑器只是一支更好用的笔,纸上写什么最终还得业务方去解释。
如果你正准备在自己的后台系统里集成公式编辑器,我建议直接把组件拆成一个独立文件夹,内部用 props 接收 fields、funcs、modelValue,再接上 lint、autocomplete、hoverTooltip 三件套。先把最基础的可输入、可补全、可校验跑通,再根据业务需求迭代。这也是我后续继续做嵌套规则、多语言公式导出时,能保持改动可控的底子。