ProseMirror 富文本编辑器核心原理与实战踩坑经验分享
我的写法,亲测靠谱
折腾 ProseMirror 有段时间了,从一开始照着文档抄,到后来被各种诡异行为折磨得睡不着觉,踩过不少坑。现在总算摸出一套自己用着顺手的写法。先说最核心的一点:别在 EditorView 初始化之后直接操作 DOM,也别在 dispatchTransaction 里搞副作用。我一开始图省事,在 dispatchTransaction 里直接调接口、改状态,结果文档内容和 UI 状态经常对不上,特别是协作编辑时,简直灾难。
我现在基本都这么写:
const view = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({ schema, plugins: [/* ... */] }),
dispatchTransaction(tr) {
const newState = view.state.apply(tr);
view.updateState(newState);
// 只在这里同步状态,别干别的
if (tr.docChanged) {
// 把新文档 JSON 存到全局状态或发给父组件
const content = JSON.stringify(newState.doc.toJSON());
store.dispatch(setContent(content));
}
}
});
这样结构清晰,副作用隔离,调试起来也方便。文档变了就更新状态,其他逻辑(比如自动保存、协作同步)全放在外部监听器里处理,而不是塞进 dispatchTransaction。这种写法更靠谱,不容易出乱子。
这几种错误写法,别再踩坑了
下面这些是我见过(也自己写过)的典型反面案例,新手特别容易中招。
- 直接修改
state.doc:有人以为state.doc是个普通对象,直接赋值或者 push 内容进去。结果?ProseMirror 根本不会触发重绘,UI 僵死。文档是 immutable 的,必须通过 transaction 修改。 - 在 plugin 的
apply里 dispatch 新 transaction:比如想在某个节点插入后自动加个 class,就在 plugin 里判断tr类型,然后view.dispatch(newTr)。这会导致无限循环!因为新 transaction 又会触发 plugin,plugin 又 dispatch……浏览器直接卡死。正确做法是用appendTransaction或者在state更新后通过外部逻辑处理。 - 忽略
selection的边界情况:比如用户选中一段文字,你执行一个命令想包裹它,但没处理 selection 为空或跨节点的情况。结果就是命令静默失败,用户一脸懵。建议所有命令都先校验state.selection.empty和canReplace。
还有一个经典坑:自定义 nodeSpec 时,toDOM 返回的结构和 parseDOM 不匹配。比如 toDOM 输出 <div class="my-block">...</div>,但 parseDOM 却只匹配 .my-node。结果一粘贴或加载旧内容,节点就变成 paragraph 了。务必保证序列化和反序列化对称。
实际项目中的坑
在真实项目里,ProseMirror 的坑往往藏在细节里。比如协作编辑时,多个客户端同时修改同一段落,如果没处理好 OT(Operational Transformation)或 CRDT,光标会乱跳,内容错乱。我之前用 y-prosemirror,但初始化时如果没等 yjs 文档 ready 就创建 editor,会丢数据。现在我加了个 loading 状态,确保 ydoc 加载完成再初始化 view。
另一个头疼的是性能。文档一长(比如上万字),输入卡顿明显。排查发现是某些 plugin 的 apply 函数太重,每次 transaction 都遍历全文档。后来我把耗时逻辑改成只在 docChanged 且满足特定条件时才跑,并加了防抖。例如高亮关键词的功能,现在只在用户停手 300ms 后才触发:
let highlightTimeout;
const highlightPlugin = new Plugin({
state: {
init() { return null; },
apply(tr, prev) {
if (tr.docChanged) {
clearTimeout(highlightTimeout);
highlightTimeout = setTimeout(() => {
// 执行高亮逻辑
}, 300);
}
return prev;
}
}
});
还有个小细节:移动端的输入体验。ProseMirror 默认对移动端支持一般,特别是 iOS 的拼写建议和光标定位。我试过很多方案,最后发现关键是在 contenteditable 上加 autocorrect="off" autocapitalize="off" spellcheck="false",虽然牺牲了部分原生功能,但避免了光标乱跳。另外,别用 position: fixed 的 toolbar,iOS 软键盘弹出会把页面顶上去,toolbar 位置错乱。改用 position: sticky 或 JS 动态计算位置更稳。
对了,调试时一定要开 console.log(tr)。ProseMirror 的 transaction 信息非常全,能看出每一步操作的类型、文档变化、selection 移动。我好多次都是靠这个发现命令没生效是因为前置条件不满足。
插件和命令的组织技巧
项目大了之后,plugin 和 command 会越来越多。我现在的习惯是按功能拆文件,比如 imagePlugin.js、mentionCommand.js,每个文件导出 plugin 和对应的 command 函数。然后在入口统一组装:
// plugins/index.js
import { imagePlugin, insertImage } from './image';
import { mentionPlugin, insertMention } from './mention';
export const plugins = [imagePlugin, mentionPlugin];
export const commands = { insertImage, insertMention };
这样主文件清爽,也方便测试单个功能。命令函数尽量写成纯函数,只依赖 state 和参数,不依赖外部变量。比如:
function insertImage(state, dispatch, url) {
if (!url) return false;
const { schema } = state;
const image = schema.nodes.image.create({ src: url });
if (dispatch) {
dispatch(state.tr.replaceSelectionWith(image, false));
}
return true;
}
注意返回 true/false 表示命令是否可执行,这是 ProseMirror 命令链的标准。别在命令里直接 alert 或 console.error,应该由调用方处理失败情况。
最后提一句 schema 设计。别一上来就搞太复杂的嵌套,先从 flat 结构开始。比如 blockquote 里套 list,list 里又套 blockquote,这种深度嵌套的解析和渲染特别容易出 bug。我吃过亏,现在能扁平化就扁平化,实在需要嵌套再加,而且要写足测试用例。
结尾
以上是我总结的最佳实践,有更好的方案欢迎评论区交流。ProseMirror 确实强大,但学习曲线陡峭,文档又偏理论。很多问题没有标准答案,得自己试、自己调。比如我现在用的自动保存策略,还是会有极少数情况下丢几个字,但频率低到可以接受。有时候,够用就行,别追求完美。这个技巧的拓展用法还有很多,后续会继续分享这类博客。希望这些踩坑经验能帮你少走点弯路。

暂无评论