做富文本编辑器这件事,听起来像是个老掉牙的课题,但真正动手从零写过一个可编辑节点组件预设的人应该都懂,这不是抄一个现成库就完事的问题。市面上成熟编辑器一抓一大把,为什么还要自研?因为业务需求总会长成“编辑器”本尊都没想到过的形状——比如你要在正文里插入一条审批变量、一个关联工单的卡片、一段只能整体删掉不可逐字修改的占位块。React + contentEditable 的组合,在这种需求下比想象中更拧巴,但也更有意思。这篇内容是我把“可编辑节点组件预设”拆开揉碎之后的完整记录,适合那些不想被重型编辑器框架绑架、又希望正文能嵌入自定义交互组件的开发者看。
1. 为什么说从零实现富文本编辑器不是灌水
1.1 市面编辑器那么多,自研到底图什么
先说一个现实:很多团队选编辑器时,第一反应是“用现成的”。这没错,但现成库也有它的代价。比如你只需要一个纯文本加粗、插入链接、带几个自定义实体的轻量输入框,引入一个完整编辑器内核,光是样式重置、事件拦截、快捷键适配就能折腾好几天。更麻烦的是,当你需要把“可编辑节点”做成真正意义上的React组件(能响应props变化、能交互、能发请求),你会发现很多编辑器对自定义节点的支持停留在“渲染一段HTML字符串”的水平,你想要的是一个真正的组件树挂在正文里。
自研富文本编辑器的核心价值,不在于重造轮子,而在于把编辑器的“内容模型”掌握在自己手里。当业务里出现了“用户输入的文字与业务对象混合编排”这种需求时,内容模型才是真正的关键。比如一个工单备注里,可能含有“@某经办人”“订单号 #ORD-2024-001”“一段仅可整块删除的协议声明”。这些都不是普通文本,它们是带着数据语义的可编辑节点。只有自己的实现,才能在数据语义和交互表现之间自由切换。
1.2 可编辑节点的核心痛点:contentEditable的“一半可控性”
所有自研Web富文本编辑器都绕不开 contentEditable。它能让你把任意HTML元素变成编辑区,浏览器自动处理输入、回车、粘贴。但这份“自动”恰恰是问题的根源:浏览器对DOM树的操作是黑盒,你不知道用户按一个退格键之后DOM会变成什么样,更不知道光标在某个带交互的子组件内部时,事件到底先被谁消费。
自定义可编辑节点的痛点就在这里:它需要内嵌交互组件,但这些组件绝不能干扰文字的编辑流。假设你在正文中插入了一个“日期选择器”节点,这个节点内部有点击、弹层、按钮,如果它整体被设置成 contentEditable=false,那么它就像一个“原子”,光标无法进入其内部,只能整体选中、整体删除。这符合预期。但如果这个原子节点在React里还需要接收状态更新、响应外部数据变化,你就得处理“React虚拟DOM”和“浏览器真实DOM”之间的同步问题。这一步做得不好,就会出现:节点内容变了但界面上不更新,或者一setState光标就跳回开头。
自研的价值,就是把这些“一半可控”的角落一个个敲实。
2. 技术底座:contentEditable与选区模型的正确理解
2.1 最小架子:一个可编辑div加React state
从零起步,不需要一上来就引入复杂依赖。最简模型长这样:
function EditableArea({ defaultValue }) { const ref = useRef(null); const [value, setValue] = useState(defaultValue); return ( <div ref={ref} contentEditable suppressContentEditableWarning onInput={(e) => { setValue(ref.current.innerHTML); }} /> ); }这段代码能跑,但仅此而已。它是最初级的“非受控默认值 + input事件同步”方式,问题很多:每次输入都会setState,整个组件重新渲染,光标位置可能被重置;如果外部传入新的value,你也不知道该不该覆盖用户正在编辑的内容;而且当内部有自定义节点时,setValue拿到的是整个innerHTML,这串HTML里混合了业务数据与展示结构,后续解析非常痛苦。
这个架子真正的意义是验证“浏览器替我们承担了文本编辑的复杂性”。在这个基础上,我们要做的事情不是替代浏览器,而是接管“内容变化”和“自定义节点渲染”这两件事。
2.2 Selection与Range:所有自定义节点操作的基础
想插入一个自定义节点,你必须操作选区。Range是DOM规范里描述“文档片段连续范围”的对象,Selection则是用户当前选中区域的高层封装。理解它们就够了:
window.getSelection()拿当前选区,选区有anchorNode、focusNode、anchorOffset、focusOffset。selection.getRangeAt(0)拿活动范围,Range有startContainer、endContainer、startOffset、endOffset。- 插入节点时,最常用的操作是:拿到Range,定位到光标位置,
range.insertNode(node)把节点插进去,再把光标挪到节点后面。
举个例子,实现一个”插入当前时间戳“的按钮:
function insertNodeAtCursor(node) { const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(node); // 把光标移动到新节点的后面 range.setStartAfter(node); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); }这里面有一个很容易踩的细节:range.insertNode()是直接把这个节点安插到Range的start位置,但如果Range跨了多个节点,行为会变得很复杂。稳妥的做法是先deleteContents()清理选中内容,再插入。
2.3 为什么execCommand这套老方案不适合现代组件预设
老一辈开发者对document.execCommand一定有感情,它一行代码就能加粗、斜体、创建链接:
document.execCommand('bold');确实方便。但它是浏览器实现里最“难以定制”的部分之一,很多命令的行为在各大浏览器里都不一致,又伴随着它已从标准中移除的消息。更关键的是,它操作的是当前Selection所在的DOM,你没法控制一个“React组件”被插入后如何映射到数据模型。比如你要插入一个“联系人卡片节点”,execCommand根本不知道该怎么创建这个节点,你用execCommand('insertHTML')强行塞HTML进去,后续你又要从这堆HTML里反解析出业务数据,费时又脆弱。
我的建议是:把它当作遗留能力看待。新项目实现自定义节点,一律走Range + insertNode路线,自己控制节点创建与数据绑定。
3. 组件预设:把可编辑节点变成真正的“组件”
3.1 预设是什么:从一条@提到一个完整交互卡片
标题里提到的“组件预设”,我理解为一套可复用的“节点类型注册机制”。它让开发者可以声明:正文里可以出现哪些自定义节点,每个节点长什么样,携带什么数据,如何被序列化与回显。
最简单的预设是@提到(mention)。它的DOM结构可以是一个span,点击后显示个人名片,删除时整体消失。再复杂一点,可以做一个“订单卡片”:卡片里展示商品图、价格、签收按钮,整个卡片在编辑区内是一个不可拆分的块。它不再是传统意义上的“文本”,而是一个内嵌交互组件。
这类节点的共同要求:
- 编辑区内表现为原子节点,光标无法进入其内部
- 支持整体移动、整体删除
- 外部状态变化(如订单状态刷新)能实时反映到节点内容
- 提交时能序列化为业务数据,而非仅仅是一段HTML
我把每个预设抽象成一个对象:
interface NodePreset { type: string; // 'mention' | 'order-card' | 'date-picker' component: React.ComponentType; // 渲染组件 serialize: (data) => string; // 生成HTML或JSON parse: (node: HTMLElement) => data | null; // 从DOM还原数据 }有了这样的注册表,编辑器的内核就不需要关心具体节点长什么样,它只要负责“节点插入、删除、位置变化”这些通用行为。
3.2 用contentEditable=false实现原子节点
让节点变得“原子”最简单的方式,就是在节点DOM上加contentEditable={false}。这样浏览器会把这个节点当作一个不可编辑的整体,光标不会进入其内部,退格删除时也会作为一个整体删除。React中这样写:
<span contentEditable={false} >import { createRoot } from 'react-dom/client'; const root = createRoot(anchorElement); root.render(<MentionNode user={user} />);这里需要保证在React应用整体卸载时,把这些局部root也卸载,防止内存泄漏。这种方式的好处是:可编辑节点内部拥有完整React生命周期,同时不干扰编辑区主体的虚拟DOM结构。
4. 核心实操:在React中实现可编辑节点预设的完整过程
4.1 第一步:从零搭建一个最小可编辑容器
直接进入正题。我们来写一个具备基础编辑能力且留好自定义节点接口的RichEditor组件。
function RichEditor({ initialContent, presets }) { const editorRef = useRef(null); const [isReady, setIsReady] = useState(false); useEffect(() => { const editor = editorRef.current; editor.innerHTML = initialContent || ''; setIsReady(true); }, []); return ( <div ref={editorRef} contentEditable suppressContentEditableWarning className="editor-shell" onInput={handleInput} onKeyDown={handleKeyDown} /> ); }这里没有用value/onChange的严格受控模式,原因是:在contentEditable上做到真正的“受控”是一件极其复杂的事,它要求你能够在任意时刻用React的state还原出DOM树。如果只是为了内部状态一致,优先选择“非受控+外部同步”模式,让浏览器主管DOM变更,React只同步业务数据。
当然,如果你愿意折腾,也可以做全受控。全受控实现的底层思路是:自己实现文档树(类似Slate的model),所有输入事件都转化为对文档树的操作,再diff到真实DOM。这条路更稳,但工程量也大。
4.2 第二步:实现自定义节点的插入与可用数据写入
假设我们要做一个“@用户”预设。一个按钮触发插入,需要一个用户对象,然后把它变成一个带data属性的span。
function insertMention(user) { const editor = editorRef.current; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); range.deleteContents(); const node = document.createElement('span'); node.setAttribute('contentEditable', 'false'); node.setAttribute('data-node-type', 'mention'); node.setAttribute('data-id', user.id); node.className = 'mention-node'; node.textContent = `@${user.name}`; range.insertNode(node); // 在节点后面补一个零宽空格,否则部分浏览器光标无法落在节点右边 const spacer = document.createTextNode('\u200b'); node.after(spacer); // 光标移动到节点后面 const newRange = document.createRange(); newRange.setStartAfter(spacer); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); // 把节点注册到组件管理Map,后续React渲染用 registerPresetNode(node, { type: 'mention', data: user }); notifyChange(); }其中registerPresetNode内部会调用createRoot(node)去渲染对应的React组件,并把组件实例和数据更新函数存储在一个Map里。这样后续更新用户头像、昵称时,不用重建整个编辑器,直接更新这个局部实例即可。
4.3 第三步:序列化与回显:让节点数据能存能取
编辑器内容最终要存入后端,并在下次打开时原样恢复。序列化这里有两种常见策略。
第一种是HTML序列化:
function serialize(editor) { return editor.innerHTML; }保存的HTML里包含了带>function serializeToJSON(editor) { const walker = document.createTreeWalker(editor, NodeFilter.SHOW_ELEMENT); const blocks = []; // 遍历文本节点与自定义节点,按顺序拼装 ... }
JSON方案更利于业务数据处理,但代价是要自己维护形态丰富的文本结构,实现复杂度高一个量级。在没有特殊要求的情况下,我建议优先用HTML +>if (e.key === 'Backspace' && isCursorBeforeAtomicNode()) { e.preventDefault(); removeAtomicNode(); }
编写isCursorBeforeAtomicNode时,要同时判断光标是处于节点右侧还是节点内部(理论上不会进入内部,但以防万一),以及节点旁是否有零宽空格,有则一并删除。
5.3 中文输入法下插入节点导致组合文本残缺
用户输入一段中文时,如果在输入法组合状态下插入自定义节点,未结束的拼音字母会残留在节点前后,甚至会被range.deleteContents()提前清掉。这是IME(输入法)事件的经典坑。
规避方案是:在compositionstart事件里设置一个标志位,compositionend后再允许插入节点。插入节点如果发生在组合中,提示用户“请先完成输入”,或者干脆在beforeinput里把插入逻辑挂起,待组合结束后处理。
我建议的做法是:
let isComposing = false; editor.addEventListener('compositionstart', () => (isComposing = true)); editor.addEventListener('compositionend', () => (isComposing = false)); function insertMention(user) { if (isComposing) { // 暂存操作,等待compositionend后执行 pendingAction = () => insertMention(user); return; } // 正常插入逻辑 }5.4 每次setState都让光标跳回开头
早期的demo用onInput里setValue(editor.innerHTML),如果这个组件的重渲染影响了编辑区DOM,光标就会丢失。这类问题的原因是React在虚拟DOM对比时,可能认为编辑区的child结构发生了变化,于是做了重建。
我这里有两个实测有效的办法:
- 第一,编辑区组件永远用
React.memo包裹,并且不要传入频繁变化的props。 - 第二,自定义节点插入后,把锚点节点注册到独立Map,组件内部的局部状态变化只更新局部root,不要走编辑器顶层state。
- 第三,如果用
onInput同步外部数据,同步时不要修改任何影响编辑区DOM的props,把内容数据放到ref或外部store中。
5.5 粘贴内容带样式和脚本的清洗问题
contentEditable的最大安全风险在粘贴。用户从网页复制的内容可能带<script>、<iframe>、onerror事件。在将innerHTML存入后端或回显前,必须做清洗。回显时尤其要小心:不要直接拼接用户HTML到页面里。
最简单可靠的清洗是创建一个脱离文档的临时容器,只保留允许的标签:
function sanitize(html) { const temp = document.createElement('div'); temp.innerHTML = html; temp.querySelectorAll('script, iframe, style, link').forEach((el) => el.remove()); temp.querySelectorAll('[onerror], [onclick], [onload]').forEach((el) => { el.removeAttribute('onerror'); el.removeAttribute('onclick'); el.removeAttribute('onload'); }); return temp.innerHTML; }还要留意自定义节点本身携带的>const TODO_PRESET = { type: 'todo', component: TodoItem, serialize(data) { return `<span>