ProseMirror 富文本编辑器核心原理与实战踩坑经验分享

萌新.普涵 交互 阅读 1,199
赞 31 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

折腾 ProseMirror 有段时间了,从一开始照着文档抄,到后来被各种诡异行为折磨得睡不着觉,踩过不少坑。现在总算摸出一套自己用着顺手的写法。先说最核心的一点:别在 EditorView 初始化之后直接操作 DOM,也别在 dispatchTransaction 里搞副作用。我一开始图省事,在 dispatchTransaction 里直接调接口、改状态,结果文档内容和 UI 状态经常对不上,特别是协作编辑时,简直灾难。

ProseMirror 富文本编辑器核心原理与实战踩坑经验分享

我现在基本都这么写:

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.emptycanReplace

还有一个经典坑:自定义 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.jsmentionCommand.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 命令链的标准。别在命令里直接 alertconsole.error,应该由调用方处理失败情况。

最后提一句 schema 设计。别一上来就搞太复杂的嵌套,先从 flat 结构开始。比如 blockquote 里套 list,list 里又套 blockquote,这种深度嵌套的解析和渲染特别容易出 bug。我吃过亏,现在能扁平化就扁平化,实在需要嵌套再加,而且要写足测试用例。

结尾

以上是我总结的最佳实践,有更好的方案欢迎评论区交流。ProseMirror 确实强大,但学习曲线陡峭,文档又偏理论。很多问题没有标准答案,得自己试、自己调。比如我现在用的自动保存策略,还是会有极少数情况下丢几个字,但频率低到可以接受。有时候,够用就行,别追求完美。这个技巧的拓展用法还有很多,后续会继续分享这类博客。希望这些踩坑经验能帮你少走点弯路。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论