个人博客的编辑器怎么选:CodeMirror、Milkdown、Tiptap 与 md-editor-v3
这个站的文章都是在网页后台里写的,编辑器从第一天起用的就是 CodeMirror 6。最近回头看了一遍市面上的编辑器,想确认这个选择是不是还对。结论是:对我的需求来说,它仍然是最合适的。下面把理由摊开讲。
先说需求
编辑器没有「最好」,只有「最适合某组需求」。我的需求有四条:
- 在线编辑。 站部署在 Cloudflare 上,没有文件系统,内容存在数据库里。所以不能像很多静态博客那样本地写好
.md文件再推上去,编辑器必须在浏览器里。 - 存的是 Markdown。 我喜欢写 Markdown:语法简单,写起来快,而且哪里都认,想把文章搬去别的平台,复制过去就行。
- 图片进自己的图床。 粘贴或选择图片后,要能上传到我自己的 R2 存储桶,而不是编辑器厂商的服务。
- 编辑时看到的,就是发布后的样子。 字体、字号、行宽都要一致,最好能并排实时预览。
第 2 条最关键,它决定了后面筛掉的大部分选项。
编辑器的三类
浏览器里的文本编辑器大致分三类:
- 写源码的。 你写的就是 Markdown 原文,编辑器负责语法高亮、快捷键。代表是 CodeMirror 和 Monaco。
- 以 Markdown 为底的所见即所得。 像 Typora:输入
##就直接变成标题,但底层存的还是 Markdown。代表是 Milkdown 和 Vditor。 - 富文本或块编辑器。 像 Notion:内部是一棵结构化的文档树(通常存成 JSON),Markdown 只是导入导出的格式之一。代表是 Tiptap 和 BlockNote。
逐个筛
Monaco:太重。 它是 VS Code 的编辑器内核,写代码无可挑剔,但体积是几 MB 级别的。Sourcegraph 从 Monaco 迁到 CodeMirror 时算过,光 Monaco 相关的包 gzip 后就有 5MB。它在手机上的体验也基本不能用。写博客用不到它的那些能力。
Tiptap:内部不是 Markdown。 Tiptap 基于 ProseMirror,文档存成 JSON。它现在有了官方的 Markdown 扩展,可以双向转换,但转换就意味着可能丢东西。比如这个 issue:代码块里含反引号时,转回 Markdown 生成的围栏不安全,再解析一遍就坏了。如果 Markdown 是你内容的本体,就不该让它经过一个有损的中间格式。
Milkdown 和 Vditor:会改写你的 Markdown。 这两个最接近我想要的:底层是 Markdown,编辑体验像 Typora。Milkdown 用 ProseMirror 编辑、用 remark 解析和生成 Markdown;它的现成版本 Crepe 还自带了图片上传插件。Vditor 有所见即所得、即时渲染、分屏预览三种模式,8 月刚发了 4.0。
问题在于「生成」这一步。编辑时文档是一棵树,保存时再从树重新写出 Markdown。你原来用 * 还是 - 做列表、在哪里空行、哪些字符转了义,都会被改成它的规范写法。存进数据库的已经不完全是你亲手写的原文了。对大多数人这不是问题,但对「Markdown 就是本体」的我来说,这是一个实打实的代价。
md-editor-v3:最值得认真比的一个。 md-editor-v3 是一个现成的 Vue 3 Markdown 编辑器。它的编辑核心就是 CodeMirror 6,还自带工具栏、分屏预览、深色主题,图片上传也留好了钩子 onUploadImg,接上自己的接口就行。它几乎就是我手搓的那个编辑器的成品版。
我没选它,原因有两个:
- 预览和发布会不一致。 它的预览用 markdown-it 渲染,而我的站在服务端用 marked。两个渲染器在细节上不一样,比如某些边界情况下的列表、表格、HTML 片段,预览里看到的就不一定等于发布后的样子。
- 外观是它的。 我要编辑区和文章用同一套排版,就又要回到在别人的默认样式上一层层覆盖的老路。这和我从 Nuxt UI 迁到 shadcn-vue 的理由一模一样。
CodeMirror 6:留下来的那个。 它不是一个现成的编辑器,而是一套组装编辑器的零件:视图、状态、快捷键、语法解析,都是可以单独装卸的扩展。你想要什么自己拼,不想要的不装。
在 CodeMirror 上具体做了什么
排版和发布后一致。 编辑区用的是和文章正文完全相同的字体、17px 字号、1.85 倍行高、同样的行宽。你在编辑器里看到一行在哪里折行,发布后就在哪里折行。当前行的高亮、行号都关掉了:写散文不需要它们。
实时预览用同一个渲染器。 宽屏时左边写、右边预览。预览调用的就是服务端渲染文章用的那个函数(shared/markdown.ts),所以预览和发布后是同一份 HTML,不存在「两个渲染器的差异」。
粘贴图片直接上传,顺便压缩。 截图粘贴进编辑器,会先在光标处插一个 ![Uploading...]() 占位,然后在浏览器里用 canvas 把图片缩到长边不超过 2000px、转成 WebP,再上传到 R2,最后把占位换成真正的链接。
为什么压缩放在浏览器里做?上传接口跑在 Cloudflare Workers 上,而常用的图片处理库 sharp 依赖原生模块,在 Workers 上装不起来。好在这个编辑器只有我一个人用,浏览器完全担得起。GIF(会被拍平成第一帧)和 SVG(矢量图,栅格化是降级)原样上传。
草稿自动保存。 还没发布的文章,停止输入 2 秒后自动保存。已经发布的不自动保存:改到一半的中间状态不该被推给读者。
后台是纯客户端渲染。 CodeMirror 需要真实的 DOM,和 SSR 不兼容,所以后台的几个页面都关掉了 SSR。这也有好处:编辑器的代码只打进后台的包里,读者打开文章时一个字节都不用下载。
一个自己挖的坑:用 !important 压 CodeMirror 的样式
写这篇的时候,我顺手修了编辑器里的一个老问题。
CodeMirror 的样式不在 CSS 文件里,而是运行时注入页面的。之前我在组件的 <style> 里去覆盖它,总有几条压不过,于是一路加 !important,最后加了 17 处:
.codemirror-editor :deep(.cm-content) {
padding: 20px !important;
caret-color: var(--brand) !important;
}
.codemirror-editor :deep(.cm-activeLine) {
background-color: transparent !important;
}
其实 CodeMirror 自己有一套主题机制。basicSetup 自带的是「基础主题」,而用 EditorView.theme() 写的主题,按设计就排在基础主题之上。在这里写,就不用和它比优先级:
const editorTheme = EditorView.theme({
'&.cm-focused': { outline: 'none' },
'.cm-content': {
fontSize: 'var(--text-lg)',
lineHeight: '1.85',
padding: '24px 0',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--brand)' },
'.cm-activeLine': { backgroundColor: 'transparent' },
})
颜色依旧用 CSS 变量,深浅色主题自动跟随。
改的过程中还发现了一个一直没生效的设置:原来用 caret-color 想把光标染成品牌色,但 CodeMirror 的光标是它自己画的一个元素(.cm-cursor),不是浏览器原生的光标,所以那一行从来没起过作用。现在改成设置 .cm-cursor 的边框颜色,光标才真的变成了蓝色。
体积
CodeMirror 常被说成是轻量的,但要看你装了什么。我用 esbuild 按项目里的实际用法各打包了一次(压缩加 gzip):
| 组合 | gzip 后 |
|---|---|
minimalSetup |
89 KB |
basicSetup |
120 KB |
basicSetup + markdown() |
199 KB |
加上 Markdown 支持,体积多了将近 80KB,比我预想的多。原因是 Markdown 里可以直接写 HTML,所以 @codemirror/lang-markdown 会顺带引入 HTML 的解析器,而 HTML 又带进来 CSS 和 JavaScript 的解析器:光 JavaScript 解析器压缩后就有 76KB(gzip 前)。
这些都只在后台加载。对一个只有作者自己用的页面来说,200KB 完全可以接受。
怎么选
- 想要 Typora 那样的体验,不介意 Markdown 被规范化: Milkdown(Crepe 开箱即用)或者 Vditor。
- 想要一个现成的 Markdown 编辑器,接受它的外观和渲染器: md-editor-v3,底层同样是 CodeMirror 6,能省下大量代码。
- 内容以富文本为主、要做协作或者块编辑: Tiptap。
- 在网页里写代码: Monaco。
- Markdown 是本体,要预览和发布完全一致,要自己决定每一处外观: CodeMirror 6。
我属于最后一种。要付的代价是:工具栏、图片上传、自动保存这些,都得自己写。好在这些都是一次性的工作,写完之后编辑器完全按我的方式工作,而存下来的每一篇文章,都是我亲手写的 Markdown。