把 CodeMirror 6 调教成 Markdown 编辑器:扩展:装饰与门面

作者:袖梨 2026-07-21

CodeMirror 6 的设计哲学是「什么都不给你,但什么都能加」。minimalSetup 只有最基本的编辑能力,行号、括号配对、代码高亮、自动补全全都要自己装。这个起点很低,但换来的是每一层行为都可控。

把 CodeMirror 6 调教成 Markdown 编辑器:扩展、装饰与门面

MarkView (markview.art)的编辑器就建在 minimalSetup 上,加了十来个扩展。本文讲其中几个有代表性的:多文档状态缓存、base64 图片折叠、链接补全、自定义搜索面板,以及一个「预留了却始终没用上」的 Compartment。

一、Vue 组件里没有一行 CodeMirror 代码

先说边界。整个 EditorPane.vue 不 import 任何 @codemirror/*,只提供挂载点:

 复制代码onMounted(() => {
    layout.editorPanelRef.value = panelRef.value
    workspace.attachEditor(hostRef.value)
})onBeforeUnmount(() => {
    workspace.detachEditor()
    layout.editorPanelRef.value = null
})

所有编辑命令都经由一个命令式门面:

 复制代码/**
 * 编辑器唯一门面:Vue 组件只提供挂载点,所有编辑命令/查询都经由这里,
 * 内部走 CodeMirror 原生 API(dispatch / lineBlockAt / scrollIntoView / requestMeasure)。
 *
 * 多文档:为每个 documentId 保存独立的 EditorState(含各自 undo 历史),
 * 切换文档用 view.setState() 而非整段替换,避免跨文档撤销栈污染。
 */

甚至连样式都搬走了——组件里唯一涉及 CodeMirror 的 CSS 是 .editor-surface :deep(.cm-editor) { height: 100% },其余全部进了 EditorView.theme

二、多文档:换 state,不换编辑器

多文档编辑最容易踩的坑是撤销栈串味:文档 A 写了几百字,切到文档 B 按 Ctrl+Z,把 A 的内容撤回来了。

CodeMirror 6 的 EditorState 是不可变对象,天然适合做快照。所以这里给每个文档存一份 state,切换时 setState

 复制代码// 切换活动文档:先暂存当前文档状态,再恢复/新建目标文档状态并 setState。
setActiveDocument(id, text = '') {
    if (id === currentDocId) return
    if (currentDocId != null) states.set(currentDocId, view.state)
    currentDocId = id
    view.setState(states.get(id) ?? buildState(text))
},

关键在第三行:Map 里存的是切走那一刻的快照,不是活引用,所以必须在切换前回写。

缓存要有出口,否则删掉几十个文档后 Map 还留着全部历史:

 复制代码// 文档被删除时丢弃其保存的状态,避免注册表无限增长。
forgetDocument(id) {
    if (id !== currentDocId) states.delete(id)
},

id !== currentDocId 这个守卫值得一提:当前文档的状态挂在 view 上而不在 Map 里,删它没有意义。

forgetDocument 还有个不太直观的调用场景——多标签页同步。另一个标签页改了某篇后台文档,本页的文档数据更新了,但那篇文档的 EditorState 缓存还是旧内容,用户切过去会看到过期版本。所以协调完要顺手丢缓存(这套同步机制见多标签页同步篇)。

三、Compartment:预留了,然后发现不需要

CodeMirror 6 官方推荐用 Compartment 做运行时重配置,典型场景就是主题热切换。MarkView 也老老实实预留了:

 复制代码// 组装编辑器全部扩展。主题放进 Compartment,为将来的暗色主题热切换预留 reconfigure 能力。
const themeCompartment = new Compartment()
 复制代码// 预留暗色主题热切换:controller.setTheme(darkTheme)
setTheme: (themeExtension = editorTheme) => {
    view.dispatch({ effects: themeCompartment.reconfigure(themeExtension) })
},

然后 setTheme 至今没有任何调用方

原因是主题走了另一条路:EditorView.theme 里一个硬编码颜色都没有,全是 CSS 变量。

 复制代码// CodeMirror 主题:把原先散落在组件 scoped :deep(.cm-*) 里的样式统一为 EditorView.theme。
// 所有取值引用 CSS token 变量,视觉与旧版一致;响应式由 tokens.css 的媒体查询改变量实现。
export const editorTheme = EditorView.theme({
    '&': {
        height: '100%',
        background: 'var(--color-surface)',
        color: 'var(--color-text)',
        fontSize: 'var(--editor-font-size)'
    },
    '.cm-content': {
        minHeight: '100%',
        padding: 'var(--editor-content-padding-y) 0',
        caretColor: 'var(--color-accent)'
    },
    // ...

语法高亮同样如此——HighlightStyle.define 里的 color 也是 var(--color-syntax-comment) 这样的变量,而且没有传第二参 { themeType: 'dark' },因为根本不需要两套。

于是暗色切换的完整链条只有一步:document.documentElement.dataset.theme = 'dark'tokens.css:root[data-theme='dark'] 覆盖颜色变量,浏览器重算级联。编辑器零 dispatch、零重建、零闪烁

这是个挺有意思的反直觉结论:当主题完全建立在 CSS 自定义属性上时,Compartment 主题热切换是多余的。 Compartment 该用在真正需要换扩展实例的场景——切换语言、切换只读态、按需启用某个插件。换颜色不算。

四、围栏代码块的语言高亮:懒加载 + 一个补丁

@codemirror/lang-markdown 支持嵌套代码块高亮,接一个 LanguageDescription[] 即可:

 复制代码const markdownSupport = markdown({ codeLanguages: markdownCodeLanguages })

语言表全部懒加载,load() 是动态 import:

 复制代码const loadJavaScript = (config) => () =>
    import('@codemirror/lang-javascript').then(({ javascript }) => javascript(config))export const markdownCodeLanguages = [
    language('JavaScript', ['js', 'javascript', 'node'], ['js', 'mjs', 'cjs'], loadJavaScript()),
    language('JSX', ['jsx'], ['jsx'], loadJavaScript({ jsx: true })),
    language('TypeScript', ['ts', 'typescript'], ['ts', 'mts', 'cts'], loadJavaScript({ typescript: true })),
    language('TSX', ['tsx'], ['tsx'], loadJavaScript({ jsx: true, typescript: true })),
    // ...

loadJavaScript 柯里化的写法让四个变体复用同一个动态 import,Vite 只产出一个 chunk。没有 Lezer 语法的语言(如 Shell)走 legacy-modes + StreamLanguage

这里有个真实的坑值得记下来:codeLanguages 只挂载语言 parser,不会带上各语言包的 support extension,所以代码块里高亮有了、补全没了。补丁是单独在顶层注册 language data:

 复制代码// Markdown 的嵌套代码解析只挂载语言 parser,不会自动带上各语言包的 support extension。
// 在顶层注册对应 language-data facet,补全仍只会在光标所在的嵌套语言中生效。
export const languageCompletionExtensions = [
    javascript({ typescript: true }).support,
    cssLanguage.data.of({ autocomplete: cssCompletionSource }),
    htmlLanguage.data.of({ autocomplete: htmlCompletionSource }),
    pythonLanguage.data.of({ autocomplete: localCompletionSource }),
    pythonLanguage.data.of({ autocomplete: globalCompletion })
]

xxLanguage.data.of(...) 注册的是挂在那个 Language 对象上的 facet,autocompletion() 按光标所在的语法节点解析 language data——所以虽然写在顶层,只会在对应嵌套语言里激活。

语言别名解析可以直接单测,不用挂编辑器:

 复制代码it.each(aliases)('resolves and loads the %s fenced-code language', async (alias) => {
    const description = LanguageDescription.matchLanguageName(markdownCodeLanguages, alias, false)
    expect(description).not.toBeNull()
    await expect(description.load()).resolves.toBeInstanceOf(LanguageSupport)
})

第三个参数 false 表示不做模糊猜测——配套的负例断言 'unknown-language' 返回 null

五、把 base64 折叠掉:为什么必须是 StateField

MarkView 的图片是压缩后内联进文档的 base64,一张图动辄几万个字符。编辑器里直接显示这么一行,体验灾难。

折叠成占位符本身不难,Decoration.replace + WidgetType 就行。难的是选择在哪一层提供 decoration

 复制代码// 把内联图片的 base64 数据在编辑器里折叠成简短占位(⋯ 图片数据 12KB ⋯),改善阅读体验。
// 仅显示层折叠:文档实际内容不变,预览 / 导出 / 复制拿到的仍是完整 base64。
// base64 不可读且极长,故【始终折叠、不随光标展开】——展开成超长单行会触发编辑器渲染空白。
//
// 用 StateField(而非 ViewPlugin)提供 decoration:StateField 随事务在状态层原子更新,
// 插入的当帧就参与布局;ViewPlugin 的 decoration 在视图更新阶段计算、比文本插入慢一拍,
// 会导致刚粘贴的超长 base64 行以未折叠高度渲染、撑出大片空白,需再一次交互/滚动才收敛。

这段注释基本可以当 CodeMirror 教程读:StateField 的 decoration 与文本变更在同一个事务里原子生效,ViewPlugin 慢一拍。 对普通高亮无所谓,对「插入即需折叠」的场景就是可见的闪烁。

另一个决策是扫描范围。常见优化是只扫可视区,这里反其道而行:

 复制代码// 扫描整个文档而非仅可视区:base64 超长会跨多个视口,按视口切片会匹配不到完整 data url,
// 导致滚动到中段时折叠失效、露出原始字符。整文档匹配保证折叠始终完整。
const text = state.doc.toString()

字段本身很短,但 provide 里的第二项是必需的配套:

 复制代码export const imageDataUrlFold = StateField.define({
    create: (state) => buildDecorations(state),
    // decoration 覆盖整文档、与光标无关,只需在文档变化时重建。
    update: (value, tr) => (tr.docChanged ? buildDecorations(tr.state) : value),
    provide: (field) => [
        EditorView.decorations.from(field),
        // 折叠段作为原子区间:光标跳过、退格 / 选择按整体处理。
        EditorView.atomicRanges.of((view) => view.state.field(field, false) || Decoration.none)
    ]
})

没有 atomicRanges,方向键会一格一格走进那团看不见的 base64 里,用户会以为编辑器卡了。

六、粘贴图片:一个同步 handler 包一个异步流程

粘贴走的是 domEventHandlers,不是 inputHandler

 复制代码return EditorView.domEventHandlers({
    paste(event, view) {
        const files = imageFilesOf(event.clipboardData)
        if (!files.length) return false
        event.preventDefault()
        const selection = view.state.selection.main
        insertImages(view, files, { from: selection.from, to: selection.to })
        return true
    }
})

返回 false 放行给默认粘贴,返回 true 表示已处理。但 handler 必须同步返回,而压缩图片是异步的——于是产生了「异步期间文档已经变了」的问题:

 复制代码const insertImages = async (view, files, range) => {
    const insert = await compressImagesToMarkdown(files, { onNotify })
    if (!insert) return
    // 异步读取期间文档可能已改变,dispatch 前把区间钳制到当前文档长度,避免越界报错。
    const docLength = view.state.doc.length
    const from = Math.min(range.from, docLength)
    const to = Math.min(range.to, docLength)
    view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } })
    view.focus()
}

诚实地说,这里是「钳制」而非用 tr.changes.mapPos 精确追踪位置——够用,但不完美。

压缩参数本身也有几个不那么显然的取舍:

 复制代码const MAX_DIMENSION = 2048
const QUALITY = 0.92
// 只对超过此大小的图片压缩:多数截图 / 图片在 1MB 以内,直接内联原图以保真,
// 避免文字 / UI 截图被有损编码糊边;只有真正的大图才压。
const COMPRESS_MIN_BYTES = 1024 * 1024

以及四条降级:GIF 直通(canvas 只能取一帧)、小图直通、压完反而更大就回退原图、decode 异常回退原图。

还有两道体积闸门,注释解释了为什么需要两道:

 复制代码// 两道闸:
//  1) 输入上限——挡住 decode 超大文件的内存峰值(TIFF 等格式浏览器甚至无法 decode)。
//  2) 输出上限——压完仍过大(浏览器压不动的 TIFF 会回退成原图)就拒绝内联,
//     否则几十 MB 的单行 base64 会撑爆 marked 解析(Maximum call stack size exceeded)。

失败原因还要分桶计数,否则提示会误导:「按原因分别计数,提示才能精确——否则『压不动被拦』会被误报成『超过 30MB』」。

七、]( 触发的链接补全

Markdown 写链接时补全文档路径和标题锚点,实现是一个 completion source。有意思的是 validFor 的用法:

 复制代码const LINK_DESTINATION_PATTERN = /](([^)s]*)$/
const ANCHOR_VALID_FOR = /^#[^)s]*$/
// 不允许以 # 开头:一旦输入 #,需让文档补全结果失效并重新查询,切换到锚点补全
const DOCUMENT_VALID_FOR = /^(?:./)?(?!#)[^)s]*$/

validFor 是 CodeMirror 补全的性能开关:只要新输入仍匹配这个正则,就直接在现有候选上过滤,不重跑 source。这里刻意用负向先行 (?!#) 让「输入 #」跳出缓存、重跑 source——用一个正则实现了补全模式的切换

补全项本身用了 label / displayLabel / apply 三分离:

 复制代码const documentOptions = (documents) =>
    documents.map((document) => {
        const href = `./${encodeURI(document.name)}`
        return {
            label: href,
            displayLabel: document.name,
            detail: '工作区文档',
            apply: href,
            type: 'text'
        }
    })

label 参与匹配打分(含 ./ 前缀和 URI 编码),displayLabel 是列表里给人看的原始文件名,apply 是实际插入的文本。中文文档名 快速开始.md 显示原样、插入编码后的形式,两不耽误。

测试直接 new 一个 CompletionContext,不需要真实 view:

 复制代码const complete = (text, options = {}) => {
    const source = createMarkdownCompletionSource(options)
    const state = EditorState.create({ doc: text })
    return source(new CompletionContext(state, text.length, false))
}

八、快捷键:两套体系,以及从官方键位表里删条目

编辑器内的 keymap 和应用级快捷键是两套,冲突处理相当直接——从官方键位表里过滤掉冲突项

 复制代码// 查找替换:自定义面板(含命中计数 / 出入场动画),悬浮于编辑区右上。
// Mod-F 从键位表剔除,统一走全局快捷键(createWorkspace 监听):编辑器内外行为一致,
// 且避免编辑器聚焦时键位与全局监听双重触发;Mod-D 也剔除——minimalSetup 无多选支持,
// 该键退化为无用且与浏览器加书签冲突。保留 F3 / Mod-G 上下个等。
search({ createPanel: markviewSearchPanel }),
keymap.of(searchKeymap.filter((binding) => !['Mod-f', 'Mod-d'].includes(binding.key))),

searchKeymap.filter(...) 比整表重写维护成本低得多,官方后续加键位也能自动继承。

优先级则靠 Prec 和声明顺序控制:

 复制代码// closeBrackets 的 Backspace(成对删除)需盖过 minimalSetup 的默认 Backspace,故提到高优先级。
Prec.high(keymap.of(closeBracketsKeymap)),
// Tab 缩进:优先级低于补全键位,补全弹出时 Tab 接受候选,否则缩进当前行 / 选区。
keymap.of([indentWithTab]),

应用侧的键位表是单一数据源,键位、展示文案、事件判定同出一处:

 复制代码// 命令 id → 键位(单个绑定或绑定数组,数组首个为主键位、用于展示),mod(Ctrl/Cmd)一律隐含。
// 应用自定义命令统一 mod+Alt+字母(单修饰的 Ctrl+N/C 等被浏览器或系统占用);标准语义沿用浏览器习惯的单修饰。

判定要求修饰键精确匹配,这样 Ctrl+K(全局搜索)和 Ctrl+Alt+K(插入代码块)不会互相误吃:

 复制代码export const matchesShortcut = (event, id) => {
    if (!(event.ctrlKey || event.metaKey)) return false
    return bindingsOf(id).some(
        (binding) =>
            Boolean(binding.shift) === event.shiftKey &&
            Boolean(binding.alt) === event.altKey &&
            event.key.toLowerCase() === binding.key
    )
}

九、自定义搜索面板里的两个细节

@codemirror/search 允许用 createPanel 换掉面板 DOM,但命令仍复用官方实现。动机写得很清楚:「默认面板无法做『当前 x / 共 y』计数、内嵌开关、出入场动画等深度定制」。

两个坑值得说。

其一,update 里不能只在「查询变了」时重算计数:

 复制代码// 注意不能只在 !eq 时置位:commit() 先存 this.query 再 dispatch,
// 自己提交的查询到这里必然「相等」,但计数仍需随本次查询变化重算。
if (effect.is(setSearchQuery)) {
    queryChanged = true
    if (!effect.value.eq(this.query)) this.setQuery(effect.value)
}

其二,CodeMirror 的 panel 没有离场钩子。 想做淡出动画,只能克隆一个「幽灵」覆盖在原位:

 复制代码// 面板移除没有原生的离场机会:克隆一份「幽灵」覆盖在原位淡出,克隆完成后立刻移除。
const ghost = source.cloneNode(true)
// cloneNode 不复制输入框的当前值,手动带过去,避免淡出瞬间文字消失。
const sourceInputs = source.querySelectorAll('input')
ghost.querySelectorAll('input').forEach((input, index) => {
    input.value = sourceInputs[index]?.value ?? ''
})

而且还要兜底:「极端情况下动画事件不触发(标签页隐藏等)也要清掉幽灵节点」——setTimeout(dispose, 500)

结语

三条经验:

  1. StateField 与 ViewPlugin 不是风格选择,是时序选择——需要与文本变更同帧生效的 decoration 必须走 StateField,否则会看到「慢一拍」的闪烁。
  2. 扩展点比配置项好用——searchKeymap.filter() 删掉两个键位、language.data.of() 补一个补全源,都是在不 fork 官方代码的前提下改行为。
  3. 别为了用而用——Compartment 是主题热切换的标准答案,但当主题已经建立在 CSS 变量上时,正确的做法是承认它多余,而不是硬凑一个 setTheme 调用。

相关文章

精彩推荐