Skip to main content

富文本编辑器选型与实现

富文本是前端最难的领域之一:光标、选区、撤销栈、协同,每一项都能单独做成一个库。

一、contentEditable、自绘与混合​

第一条是 contentEditable(浏览器原生编辑能力,行为由浏览器决定,各浏览器不一致);第二条是自绘文档模型(自己维护一棵文档树与选区状态,只把 contentEditable 当输入通道);第三条是完全自绘(连光标与排版都自己算,排版可控但交互全自研)。

第二条是主流选择,因为它同时拿到「可控的数据模型」与「原生输入体验」:输入、选区、中文输入法这些交给浏览器原生的 contentEditable 处理,而文档长什么样、存成什么结构由你自己定义的模型说了算。完全自绘的控制力最强(像专业设计工具那样精准),但光标、换行、输入法每一个交互都要自己实现,成本极高,除非做排版要求特殊的专项产品,否则不划算。contentEditable 直接用则踩坑最多,下面一节单独说。

① 直接用 contentEditableDOM 就是真相上手最快格式一乱就收不住② 受控 + 自绘模型文档模型才是真相ProseMirror / Lexical主流选择,成本中等③ 完全自绘自己算光标与排版控制力最强成本极高,极少有必要
图:三种实现路径——控制力与成本同向增长,多数项目停在中间那档

二、裸写的代价​

裸写的问题不在「能不能输入」,而在同样一次回车,不同浏览器产出的 DOM 完全不同:有的插 <div>,有的插 <br>,有的复制一段样式。累积下来,文档里充满了浏览器自造的脏标签,序列化出来的 HTML 无法稳定解析,撤销重做与协同更是无从下手。

脏 DOM 带来的连锁后果远不止「看着乱」:你的撤销栈要能理解这些不一致的节点、协同时要能在不同客户端之间对同一个文档结构达成共识、存储时要能稳定地再渲染出来。只要文档结构由浏览器随机决定,上面三件事每一条都建在流沙上。所以问题从来不是「能不能输入」,而是「输入之后产生的结构你控不控得住」。

三、按内容模型选​

按四个维度对比,而不是按「功能清单谁长」:

  • 数据模型可靠性:文档是不是一棵稳定、可序列化的树?这是一切的基础,模型不稳,协同和迁移都免谈。
  • 扩展性:自定义节点(卡片、提及、公式)好不好加?插件机制是否清晰?
  • 协同支持:是否原生支持 CRDT / OT,还是得自己接?
  • 体积:包多大、首屏影响多少?移动端尤其敏感。

结构化文档模型长这样:节点带语义,渲染时再转成 HTML,而不是反过来直接存 HTML。

// 结构化文档模型:节点带语义,序列化稳定、可协同
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'strong' }], text: '重点' }],
},
],
}
// 渲染时再转成 HTML,而非直接存 HTML

主流方案里,Lexical、ProseMirror 系(含 Tiptap)、Slate 各有侧重,截至 2026 年 10 月它们都仍在活跃维护。选型的原则是「按项目对协同与结构化的要求选」:要强协同、复杂嵌套结构,往 ProseMirror / Lexical 靠;轻量评论、简单富文本,Tiptap 或直接上 Markdown 也够。别选停止维护的,富文本生态迭代快,踩坑时没社区兜底很痛苦。

四、光标、选区与撤销栈​

无论选哪个编辑器,这几件事都绕不开:

  • 粘贴清洗:外部内容(网页、Word)会带样式、脚本与嵌套结构,必须按白名单重建节点,而不是原样塞进文档。
  • 图片上传与占位:粘贴 / 拖入图片先插占位,上传完再替换,失败要给重试。
  • XSS 消毒:只要最终渲染的是 HTML,就必须消毒——哪怕内容「只来自自己人」,存储和渲染之间隔了太多环节。
  • 撤销重做与协同冲突:模型稳了才好做,否则撤销栈和协同补丁都无从对齐。
// 粘贴时按白名单重建节点,而不是信任原 HTML
function sanitizePaste(html) {
const tpl = document.createElement('template')
tpl.innerHTML = html
tpl.content.querySelectorAll('*').forEach((el) => {
if (!ALLOWED_TAGS.has(el.tagName.toLowerCase())) {
el.replaceWith(...el.childNodes) // 不在白名单:unwrap,保留文本
}
for (const attr of [...el.attributes]) {
if (!ALLOWED_ATTRS.has(attr.name)) el.removeAttribute(attr.name)
}
})
return tpl.content
}
import DOMPurify from 'dompurify'
// 渲染用户存储的 HTML 前必须消毒,哪怕来源是「自己人」
const clean = DOMPurify.sanitize(dirtyHtml, {
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onload'],
})

存 HTML 就必须消毒,这是红线——富文本最常见的事故就是一段被存进来的 <img onerror> 在别人页面上执行。文档模型才是该存的真相,HTML 只是它的一种渲染产物。

富文本里高度不定,虚拟滚动很难做,常见退路是按块懒挂载:

// 只挂载视口附近的块,其余用等高的占位撑住滚动条
const visible = blocks.filter(
(b) => b.top < scrollTop + viewportH + 600 && b.top + b.height > scrollTop - 600
)

// 高度必须先估算、渲染后回写真实值,否则滚动条会跳
// 这也是「先有文档模型」才做得动的原因:没有块的概念就切不开

五、大文档下的性能​

性能问题不止在超大文档:光标计算、重排、频繁的状态同步在中等文档上就能吃掉帧率。每次按键全量序列化、全量 diff、全量通知协同,开销会随文档增长非线性上升。

优化方向:输入防抖地序列化,不要每次按键都全量转换;大文档做分区渲染或虚拟滚动,只渲染视口附近的节点;输入法组合(composition)期间不要打断模型更新,否则中文输入会跳动。中等文档就能吃掉帧率,所以性能要当成一等公民设计,而不是等「文档写到一万字」再救火。

撤销栈也是一道坎:浏览器的原生撤销和业务操作对不上:

// 原生撤销会把一次「批量替换」拆成几十步,用户按一下 Ctrl+Z 只退一个字
// 结构化编辑器里,把一次操作声明为一个事务,撤销才符合预期
editor.dispatch({
changes: [{ from: start, to: end, insert: text }],
annotations: [Transaction.userEvent.of('replace')],
})

六、改改 contentEditable 远远不够​

富文本只存 HTML 是不够的——HTML 表达不了意图(到底是「加粗」还是「强调」),也难以做协同与迁移,结构化文档模型更稳。

粘贴内容也不是清洗一下就行:外部内容会带样式、脚本与嵌套结构,必须按白名单重建节点。

性能问题更不只出现在超大文档上——光标计算、重排与频繁的状态同步,在中等长度的文档上就能吃掉帧率。

选编辑器的关键不是功能清单,而是它的文档模型能不能被你稳定地序列化、迁移和协同。

想要可控的文档模型,ProseMirror 的设计思路值得读一遍,很多编辑器的坑它早就正面处理过。