在 React 项目中使用 ProseMirror(如通过 Tiptap、Remirror 或自定义封装)时,由于 ProseMirror 的命令式 DOM 更新机制 与 React 的声明式虚拟 DOM 渲染机制存在根本差异,若处理不当,极易引发以下问题:
- 编辑器内容闪烁或重置
- 光标跳转/丢失
- 内存泄漏(未正确销毁 EditorView)
- 状态不同步(React state 与 ProseMirror state 不一致)
- 自定义节点(NodeView)渲染异常
✅ 核心原则:让 ProseMirror 完全控制编辑区域的 DOM
这是避免渲染冲突的根本前提。React 不应尝试渲染或更新 ProseMirror 所管理的 DOM 子树。
🛠️ 具体实践策略
1. 正确挂载和卸载 EditorView
确保在 useEffect 中创建,在清理函数中销毁,防止多次初始化或内存泄漏。
tsx
import { useEffect, useRef } from 'react';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from './schema';
export default function ProseMirrorEditor() {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (!editorRef.current) return;
const state = EditorState.create({ schema });
const view = new EditorView(editorRef.current, { state });
viewRef.current = view;
return () => {
view.destroy(); // 👈 关键:释放事件监听、定时器等资源
viewRef.current = null;
};
}, []);
return <div ref={editorRef} />;
}
❗ 避免在每次 render 时重建 EditorView(如放在组件顶层或依赖频繁变化的 deps)。
2. 不要用 React 控制编辑器内容
❌ 错误做法:
jsx
// 危险!React 会尝试 reconciliation,与 ProseMirror 冲突
<div contentEditable={true} dangerouslySetInnerHTML={{ __html: htmlFromState }} />
✅ 正确做法:
jsx
// 仅提供一个"容器",由 ProseMirror 接管其子 DOM
<div ref={editorRef} />
3. 避免将 ProseMirror State 同步到 React State(除非必要)
频繁将 state.doc 转为 JSON/HTML 并存入 useState,会导致不必要的 React 重渲染,甚至触发循环更新。
✅ 建议:
- 仅在需要时(如保存、预览)读取内容。
- 工具栏状态可通过 ProseMirror 插件 +
useCallback监听状态变化,而非全量同步文档。
示例:监听选区变化更新工具栏
ts
const updateToolbar = useCallback(() => {
const { state } = viewRef.current!;
setIsBold(state.schema.marks.strong.isInSet(state.selection.$from.marks()));
}, []);
useEffect(() => {
const view = viewRef.current!;
view.dispatch = (tr) => {
view.updateState(view.state.apply(tr));
updateToolbar(); // 在 dispatch 后同步 UI
};
}, [updateToolbar]);
4. 自定义 NodeView:谨慎集成 React 组件
若需在 ProseMirror 节点中渲染 React 组件(如嵌入图表、评论),必须:
- 使用
createRoot().render()(React 18+)或ReactDOM.render挂载 - 在
destroy()中正确卸载 - 避免在 React 组件内部修改 ProseMirror 状态(除非通过 dispatch)
ts
class ReactComponentNodeView {
dom: HTMLElement;
contentDOM?: HTMLElement;
constructor(node: Node, view: EditorView, getPos: () => number | false) {
this.dom = document.createElement('div');
const reactElement = <MyCustomWidget node={node} onUpdate={(attrs) => {
const pos = getPos();
if (pos !== false) {
const tr = view.state.tr.setNodeMarkup(pos, undefined, attrs);
view.dispatch(tr);
}
}} />;
createRoot(this.dom).render(reactElement);
}
destroy() {
unmountComponentAtNode(this.dom); // 或 root.unmount()
}
}
⚠️ 注意:
getPos()可能返回false(节点已删除),务必判空。
5. 避免 SSR / Hydration 冲突
ProseMirror 依赖浏览器 DOM API,不能在服务端渲染。
✅ 解决方案:
- 使用动态导入(
next/dynamic或React.lazy)禁用 SSR - 或在客户端
useEffect中延迟初始化
Next.js 示例:
tsx
import dynamic from 'next/dynamic';
const ProseMirrorEditor = dynamic(
() => import('../components/ProseMirrorEditor'),
{ ssr: false }
);
6. 不要手动操作编辑器 DOM
例如:
js
// ❌ 危险:直接修改 ProseMirror 管理的 DOM
document.querySelector('.ProseMirror').innerHTML = '...';
这会破坏 ProseMirror 的内部状态与 DOM 的一致性,导致崩溃或不可预测行为。
所有内容变更应通过 dispatch Transaction 完成。
7. 使用成熟封装库(推荐)
如非必要,建议使用经过验证的封装:
- Tiptap(最流行,React 友好)
- Remirror
- @nytimes/react-prosemirror
它们已处理大部分桥接细节,提供 useEditor、EditorContent 等 React-friendly API。
示例(Tiptap):
tsx
const editor = useEditor({ ... });
return (
<>
<MenuBar editor={editor} />
<EditorContent editor={editor} /> {/* 内部正确挂载 ProseMirror */}
</>
);
但仍需理解其底层机制,以便调试。
🔍 常见 BUG 排查清单
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 内容闪烁/重置 | 多次创建 EditorView | 确保 useEffect 依赖项稳定,只初始化一次 |
| 光标跳到开头 | React 强制更新容器 DOM | 确保容器 div 无 children,不被 React 控制 |
| 自定义组件不更新 | React 组件未响应 ProseMirror 状态 | 通过 props 传递最新 node 数据,或使用 context |
| 内存泄漏 | 未调用 view.destroy() |
在 useEffect 清理函数中销毁 |
| Hydration failed | SSR 渲染了编辑器 | 禁用 SSR |
总结:关键守则
- 编辑区域 DOM 归 ProseMirror,其他归 React
- 状态以 ProseMirror 为主,React 为辅
- 所有内容变更走 Transaction,绝不直接改 DOM
- 生命周期严格管理:init in effect, destroy on unmount
- 复杂节点用 NodeView + React Portal,注意卸载
遵循这些原则,可极大降低因渲染机制差异导致的 bug。
如你有具体场景(如"如何实现带 React 表单的嵌入节点"或"协作编辑中的状态同步"),我可以提供针对性代码示例。