zzxworld

使用 CodeMirror 打造 Markdown 编辑器

上次折腾了一下区块编辑器 Editor.js,心有戚戚。在深度实践了一下后,发现现阶段直接使用这个编辑器还存在一些问题。所以调整了一下方向,决定另找一个更加平滑的编辑器升级方案。目前的选择是 CodeMirror。

CodeMirror 是一个基于浏览器的开源在线编辑器。不同于普通在线编辑器以「所见即所得」的方式来编辑 HTML 内容,CodeMirror 主要用途是编辑代码,所以在应用场景上更加专业。来看看它的一些主要功能特性:

  • 支持超过 100 多种编程语言
  • 一个强大,可组合的编程语言模式系统
  • 支持输入时的自动完成
  • 支持代码折叠
  • 可配置键盘绑定
  • 提供内容搜索和替换的界面,以及功能
  • 支持编辑窗口的视图切分
  • 支持混合的字体大小和样式

当然还有行号输出,插件扩展等统统都没有问题。这就是一个可以用来做代码开发的在线编辑器。我用它来做 Markdown 编辑器的确是有点大才小用了。但目前对我来说,也的确没有其他更好的选择。而且我也发现,目前一些 Markdown 编辑器其实也是基于 CodeMirror 开发的。比如 SimpleMDE。

我选择 CodeMirror 的理由也很简单,就是输入体验太顺滑了。另外我也不太喜欢 SimpleMDE 这种包了一层后提供的那一排工具栏样式。只需要比我现阶段使用的 textara 文本框体验好就算目标达成了。

接下来就看看如何把 CodeMirror 折腾成一个满足我需求的 Markdown 编辑器。

基础环境

开始之前,需要先搭建好一个前端开发环境。我目前主要使用 React,所以就以 React 为基础环境。如果你还不熟悉 React 开发环境的搭建流程,可以参考下面这篇文章:

另外,相比上面文章中的使用 Webpack 繁琐的流程,我现在更推荐使用 Laravel Mix。使用 Laravel Mix 搭建前端开发环境可以参考这篇文章:

完成基础环境的搭建后,接下来就看看如何先把编辑器显示出来。

显示编辑器

首先通过 npm 命令安装一下依赖的 CodeMirror 组件:

npm i -D @codemirror/state
npm i -D @codemirror/view

创建一个单独的 js 文件,文件名为 markdown_editor.js,用来保存这个编辑器的组件代码。内容如下:

import React, {useEffect, useRef} from 'react';
import {EditorState} from '@codemirror/state';
import {EditorView} from '@codemirror/view';

export default () => {
    const editor = useRef()

    useEffect(()=>{
        const startState = EditorState.create()

        const view = new EditorView({
            state: startState,
            parent: editor.current,
        })

        return ()=>{
            view.destroy()
        }
    }, [])

    return (
        <div ref={editor}></div>
    )
}

这样一个基于 CodeMirror 的编辑器组件就写好了。接下来就可以在需要的页面中引入这个编辑器组件:

import React from 'react';
import Editor from './markdown_editor';

export default () => {
    return <Editor />
}

然后在浏览器查看这个页面渲染后的效果:

2022-03-04-13-24-17

貌似一片空白,什么都没有。在空白页面点击一下鼠标试试。

2022-03-04-13-25-26

这时就会发现出现了一个虚线框,里面还有一个光标在闪啊闪的。打几个字试试,文字输入毫无问题。

2022-03-04-13-27-27

这代表引入 CodeMirror 成功了。不过显然和 Textarea 没有太大区别,除了按回车会自动增加编辑器的高度。接下来就看看怎么引入 Markdown 扩展和语法高亮支持。

Markdown 和语法高亮

同样还是使用 npm 命令先安装相关依赖插件:

npm i -D @codemirror/lang-markdown
npm i -D @codemirror/highlight

然后把编辑器组件的代码调整为如下:

import React, {useEffect, useRef} from 'react';
import {EditorState} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {markdown} from '@codemirror/lang-markdown';
import {defaultHighlightStyle} from '@codemirror/highlight';

export default () => {
    const editor = useRef()

    useEffect(()=>{
        const startState = EditorState.create({
            extensions: [
                markdown(),
                defaultHighlightStyle,
            ],
        })

        const view = new EditorView({
            state: startState,
            parent: editor.current,
        })

        return ()=>{
            view.destroy()
        }
    }, [])

    return (
        <div ref={editor}></div>
    )
}

再次在浏览器中查看渲染页面,然后尝试敲点 Markdown 格式的文字内容:

2022-03-04-13-40-58

可以看到,Markdown 格式和语法高亮起作用了。有了这些,在编辑体验上就甩开了 textarea 文本框一大截。对我来说,已经达到预期,可以满足需要了。

不过作为一个输入输出类的组件,要支持传值和取值才行,否则编辑和保存文章内容都是问题。

赋值和取值

根据赋值和取值的需求,给编辑器组件定义两个传参:

  • value: 用来接收编辑器加载时默认要显示的内容。
  • onChange: 定义的取值事件,当编辑器内容有变动时传出最新的编辑器内容。

加入这两个参数后的编辑器组件代码如下:

import React, {useEffect, useRef} from 'react';
import {EditorState} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {markdown} from '@codemirror/lang-markdown';
import {defaultHighlightStyle} from '@codemirror/highlight';

export default ({value, onChange=()=>{}}) => {
    const editor = useRef()

    const onUpdate = EditorView.updateListener.of(v=>{
        if (v.docChanged) {
            onChange(v.state.doc.toString())
        }
    })

    useEffect(()=>{
        const startState = EditorState.create({
            doc: value,
            extensions: [
                markdown(),
                defaultHighlightStyle,
                onUpdate,
            ],
        })

        const view = new EditorView({
            state: startState,
            parent: editor.current,
        })

        return ()=>{
            view.destroy()
        }
    }, [])

    return (
        <div ref={editor}></div>
    )
}

然后修改引入编辑器页面的代码为如下:

import React from 'react';
import Editor from './markdown_editor';

export default () => {
    return <Editor value="# default title" onChange={value=>{
            console.log(value)
        }} />
}

这回刷新一下浏览器,就会发现直接会显示在引用编辑器时定义的 value 参数内容。而且打开浏览器的控制台,尝试在编辑器中输入一些文字,会发现每次输入都会触发 onChange 事件,并返回最新的内容。

2022-03-04-14-11-54

延迟触发 onChange

通过上面控制台的输出会发现,当打字速度较快时,对 onChange 事件的触发会比较频繁。正常来说没必要每敲一下键盘就传递一下内容。一个比较合理的方案是当键盘敲击停止一段时间后再传递最新的内容,比如敲完一连串的文字后,如果停顿时间超过 1 秒再触发 onChange 事件。这样可以很大程度上优化 onChange 事件的输出频率。

所以接下来就给这个编辑器组件添加延迟触发的特性。调整后的编辑器组件代码如下:

import React, {useEffect, useRef} from 'react';
import {EditorState} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {markdown} from '@codemirror/lang-markdown';
import {defaultHighlightStyle} from '@codemirror/highlight';

export default ({value, onChange=()=>{}}) => {
    const editor = useRef()
    const delayTimer = useRef()

    const onUpdate = EditorView.updateListener.of(v=>{
        if (v.docChanged) {
            clearTimeout(delayTimer.current)

            delayTimer.current = setTimeout(()=>{
                onChange(v.state.doc.toString())
            }, 1000)
        }
    })

    useEffect(()=>{
        const startState = EditorState.create({
            doc: value,
            extensions: [
                markdown(),
                defaultHighlightStyle,
                onUpdate,
            ],
        })

        const view = new EditorView({
            state: startState,
            parent: editor.current,
        })

        return ()=>{
            view.destroy()
        }
    }, [])

    return (
        <div ref={editor}></div>
    )
}

调整代码位于 onUpdate 函数中,借助 clearTimeoutsetTimeout 来实现了相关功能。


受益于 CodeMirror 强大的功能和健全的扩展体系,不多的代码就能够实现一个非常好用的 Markdown 编辑器组件。这回我真的可以告别 textarea 文本框了。