ProseMirror踩坑实录:那些文档没写的坑,我帮你踩完了

上一篇聊了技术选型,这篇聊聊ProseMirror。
选型的时候我说ProseMirror是”最接近Typora思路”的方案。思路对不对?对。但这条路走起来,坑比我想的多得多。
ProseMirror的文档有个特点:它把每个概念都解释得很清楚,但从来不告诉你实际用的时候会遇到什么问题。就像一份完美的理论说明书,少了一章叫”常见翻车现场”。
这篇就是那个缺失的章节。
这是我踩的第一个坑,花了至少两个小时。
ProseMirror解析Markdown用的是prosemirror-markdown库。它的原理是:markdown-it把Markdown文本解析成一系列token,然后prosemirror-markdown根据你定义的token spec把这些token映射成ProseMirror的节点(Node)或标记(Mark)。
映射关系是通过token spec里的block、node、mark三个字段来定义的。比如:
// 这个是对的fence: { block: 'code_block', getAttrs(token) { return { language: token.info || '' } }}// 这个会炸fence: { block: 'code_block_open', getAttrs(token) { return { language: token.info || '' } }}
为什么?因为markdown-it在解析代码块的时候,会生成三个token:fence_open、fence、fence_close。而prosemirror-markdown的内部机制是自动帮你处理_open和_close后缀。
如果你手动写了code_block_open作为key,prosemirror-markdown会再给它加一次,变成code_block_open_open。你猜对了,它永远匹配不上。
两个小时。就卡在一个后缀上。
教训:看prosemirror-markdown的源码,不要只看文档。
坑二:commonmark模式不支持GFM表格
这个坑更隐蔽。
markdown-it初始化的时候可以传参数。我一开始传了{ commonmark: true },觉得用标准模式比较安全。
结果GFM表格全部解析失败。打开浏览器调试一看,markdown-it把表格的那几行当成了普通文本,连token都没生成。
翻文档才搞明白:commonmark: true会启用严格的CommonMark解析模式,这个模式不支持任何GFM扩展。表格、任务列表、删除线,全废。
改成默认模式就行:
const md = markdownIt({ html: false, linkify: true }) .use(taskLists) // - [ ] / - [x] .use(footnote) // [^1]
教训:Markdown解析器的”标准模式”未必是你想要的。
坑三:所见即所得的”源码模式”切换
Typora有一个很有意思的设计:光标在某个块级元素里的时候,这个元素会从渲染模式切换成源码模式。比如一个标题,你看到的是大号加粗的”标题文字”,但光标移进去之后,它会变成## 标题文字。
我一开始以为这个功能很复杂,要用什么特殊的视图切换机制。后来想明白了:用Decoration就行了。
ProseMirror的Decoration系统可以在不修改文档模型的情况下改变DOM的渲染方式。具体做法是:
-
1. 创建一个Plugin,监听selection的变化 -
2. 找到当前光标所在的最外层块级节点 -
3. 给这个节点添加一个CSS class(比如 wf-source-mode) -
4. 其他节点添加另一个class(比如 wf-render-mode) -
5. 用CSS控制显示——源码模式下隐藏渲染内容,显示Markdown源文本;渲染模式下反过来
实际上,DOM里同时存在渲染结果和源文本,只是通过CSS来切换可见性。ProseMirror的文档模型完全不变。
核心代码大概是这样的:
// wysiwygPlugin的核心逻辑decorations(state) { const { activePos } = this.getState(state) state.doc.forEach((node, offset) => { if (offset === activePos) { // 当前编辑的块:源码模式 decos.push(Decoration.node(offset, offset + node.nodeSize, { class: 'wf-source-mode' })) } else { // 其他块:渲染模式 decos.push(Decoration.node(offset, offset + node.nodeSize, { class: 'wf-render-mode' })) } }) return DecorationSet.create(state.doc, decos)}
这个方案简洁得让我有点意外。没有复杂的视图切换,没有文档模型的修改,纯CSS + Decoration。
坑四:数学公式的自定义token
ProseMirror和markdown-it之间有一层”翻译”:markdown-it把$E=mc^2$解析成token,prosemirror-markdown把这个token映射成ProseMirror节点。
但markdown-it原生不支持数学公式。它不认识$...$和$$...$$语法。
所以你得自己写markdown-it插件来生成自定义token。
markdown-it的插件系统允许你往inline ruler和block ruler里插入自定义的解析规则。行内公式($...$)插在inline ruler里,块级公式($$...$$)插在block ruler里:
// 行内公式:$E=mc^2$md.inline.ruler.after('escape', 'math_inline', (state, silent) => { // 找到$符号,匹配到下一个$,生成token const token = state.push('math_inline', 'math', 0) token.content = state.src.slice(start + 1, pos)})// 块级公式:$$E=mc^2$$md.block.ruler.after('blockquote', 'math_block', (state, startLine) => { // 找到$$,匹配到下一个$$,生成token})
然后在prosemirror-markdown的token spec里映射到自定义的math_inline和math_block节点。
这里也有一个细节:行内公式和块级公式的解析顺序很重要。 如果你先解析块级公式,$...$可能会被误当成块级公式的开始符号。所以行内公式的ruler一定要在遇到两个连续的$时提前返回。
坑五:任务列表的checkbox交互
Markdown的任务列表- [x] 已完成和- [ ] 未完成,看起来简单,实现起来有个坑。
checkbox是一个HTML input元素,它天然会抢焦点。在ProseMirror里,一旦焦点离开编辑器,你就没法用键盘操作了。
解决方案:在checkbox的mousedown事件里阻止默认行为,阻止它抢焦点:
checkbox.addEventListener('mousedown', (e) => e.preventDefault())
但阻止了mousedown之后,click事件还是能触发的,所以checkbox的切换逻辑正常工作。
还有一个问题:checkbox的状态改变需要通过ProseMirror的事务(transaction)来更新文档模型,不能直接操作DOM。我在toDOM里创建checkbox的时候绑定了click事件,但具体的transaction逻辑要交给ProseMirror的状态管理来处理。
Schema就是编辑器的灵魂
踩完这些坑之后,我对ProseMirror的理解深了一层。
ProseMirror的核心不是EditorView,不是Plugin,而是Schema。
Schema定义了你的文档能有什么节点、什么标记、它们的结构约束是什么。所有的解析、渲染、输入规则、键盘快捷键,都是围绕Schema来运转的。
在WriteFlow里,我定义了15种节点类型(段落、标题、引用、代码块、有序列表、无序列表、列表项、行内公式、块级公式、脚注、图片、分割线、表格等)和5种标记类型(加粗、斜体、行内代码、链接、删除线)。
每加一种节点类型,就要考虑三件事:解析(Markdown怎么变成这个节点)、渲染(这个节点在DOM里长什么样)、序列化(这个节点怎么变回Markdown)。
这三件事做对了,编辑器就能正确地读写Markdown文件。做错了任何一个,就会出现”打开文件后格式变了”或者”保存后Markdown丢了内容”的bug。
ProseMirror的学习曲线确实陡,但这个陡峭是值得的。搞明白Schema之后,后面加功能就是体力活了。
GitHub:
https://github.com/jaqen6688/WriteFlow
百度网盘(国内下载):https://pan.baidu.com/s/1m8Tad11Itl0Yw9D5kqBTpA?pwd=rlb2
下一篇聊聊Electron打包的那些白屏。
我在「全栈生涯」等你,关注就能找到。
夜雨聆风