富文本编辑器架构
ContentEditable 陷阱、文档模型、为何应直接用 ProseMirror/TipTap 而非 contentEditable。
组件模式高级
为什么
在原始 contentEditable 上建富文本编辑器听起来简单,直到遇到光标跳、复制粘贴坏、HTML 不可序列化。文档模型编辑器(ProseMirror/TipTap/Lexical)给你 schema、事务、合理行为。
复合组件模式被 Radix UI、Headless UI 和 shadcn/ui 采用 — 这是 2024 年采用最多的三个无头库。相比扁平组件 API,它们减少 70% 的属性逐层传递。
何时使用
任何超单行输入的用真编辑器:带粗斜体的评论、文章、文档。代码用代码编辑器(Monaco/CodeMirror)。
如何做
1. 选框架 — TipTap(ProseMirror 包装,React 友好)或 Lexical(Meta,高性能)
2. schema — 定义允许的节点/标记(段落、标题、粗体、链接);拒绝其他
3. 工具栏 — 按钮调编辑器命令(toggleBold、setLink),命令不活跃时禁用
4. 粘贴 — 把 HTML 净化为你的 schema,去样式/类,仅留语义标签
5. 持久化 — 序列化为 JSON(非 HTML)存储;HTML 是渲染目标非模型
6. 协作 — yjs 或 ProseMirror collab 插件做实时多用户
7. 扩展 — 图片上传、提及、代码块作插件;绝不 fork 核心
示例
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
function RichEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) {
const editor = useEditor({
extensions: [StarterKit], // 段落、标题、粗体、斜体、列表、代码...
content: value,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
editorProps: {
attributes: { class: "prose min-h-32 focus:outline-none" },
},
});
return (
<div className="border rounded">
<div className="toolbar">
<button onClick={() => editor?.chain().focus().toggleBold().run()}
className={editor?.isActive("bold") ? "bg-gray-200" : ""}>B</button>
<button onClick={() => editor?.chain().focus().toggleItalic().run()}>I</button>
</div>
<EditorContent editor={editor} />
</div>
);
}
常见问题
- ⚠以 HTML 为真源 — 无法查询/转换,粘贴注入任意标签
- ⚠无 schema — 用户粘整 Word 文档带内联样式,布局崩
- ⚠重渲染时光标跳 — 每次父渲染重初始化编辑器,丢焦点
最佳实践
- ✓存 JSON、渲染 HTML — 文档模型可查询可转换;HTML 仅输出
- ✓定义严格 schema — 拒绝不支持的节点/标记,粘贴自动转换适配
- ✓稳定编辑器实例 — useEditor 配稳定配置,勿每次渲染重建