富文本编辑器选型指南:从 contenteditable 到 TipTap
约 1 分钟· 660 字 68 次阅读
做博客后台时被问到最多的一个问题是:"编辑器用哪个?"这篇把主流方案摆开讲清楚,并给出我选 TipTap 的理由。
先泼冷水:别从零写
contenteditable 看起来简单——给 div 加个属性就能打字。但真要做出可用产品,要处理的问题多到劝退:
- 光标位置管理(跨浏览器不一致)
- 撤销/重做栈
- 剪贴板格式清理
- 输入法(IME)兼容
- 嵌套列表、表格、图片的 DOM 结构
结论:除非是教学实验,否则不要从零写编辑器。
主流方案对比
| 方案 | 定位 | 上手难度 | 定制能力 | 生态 |
|---|---|---|---|---|
| contenteditable | 原生 | 低 | 极高(都要自己做) | 无 |
| Quill | 经典富文本 | 中 | 中 | 成熟但慢 |
| TipTap | 无头编辑器 | 中 | 高(ProseMirror 内核) | 活跃 |
| Lexical | Meta 出品 | 中高 | 高 | 较新 |
为什么选 TipTap
无头(headless) 是关键——它只管编辑逻辑和结构,不管样式。这意味着:
- 样式完全跟着项目设计系统走,不会"编辑器是编辑器,网站是网站";
- 基于 ProseMirror 的稳定内核,光标、撤销、IME 这些地狱级问题已经解决;
- 扩展机制清晰,一个扩展就是一个包:
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
const editor = useEditor({
extensions: [
StarterKit, // 加粗/斜体/列表/代码块等基础
Image, // 图片
Placeholder.configure({ placeholder: "开始写点什么吧…" }),
],
immediatelyRender: false, // SSR 必设
});
自写扩展:给编辑器加"上传图片"
TipTap 的扩展自定义能力体现在这里——工具栏加按钮,接上传接口:
async function addImage() {
// 用户选文件 → 上传 → 拿到 URL → 插入编辑器
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd });
const { url } = await res.json();
editor.chain().focus().setImage({ src: url }).run();
}
与后端存储的解耦
编辑器产出 HTML,但内容库可以是 Markdown。用 turndown 做单向转换:
import TurndownService from "turndown";
const td = new TurndownService({ headingStyle: "atx" });
const markdown = td.turndown(editor.getHTML());
这样编辑器只是"可视化外壳",数据永远是最可移植的 Markdown。
三个常见坑
| 坑 | 解法 |
|---|---|
| SSR 报 hydration 错 | immediatelyRender: false |
| 图片 URL 存了绝对地址 | 存相对路径,渲染统一拼 |
| 工具栏按钮没响应 | onMouseDown 里 preventDefault,否则编辑器失焦 |
小结
选型建议一句话:
- 快速交付 + 样式无要求 → Quill;
- 深度定制 + 设计系统统一 → TipTap;
- 团队想深度掌控内核 → Lexical;
- 千万别从 contenteditable 裸写。
编辑器是"看起来简单、做起来地狱"的典型——站在 ProseMirror 肩膀上,才是性价比最高的选择。