做富文本编辑器这件事听起来像是个老掉牙的课题但真正动手从零写过一个可编辑节点组件预设的人应该都懂这不是抄一个现成库就完事的问题。市面上成熟编辑器一抓一大把为什么还要自研因为业务需求总会长成“编辑器”本尊都没想到过的形状——比如你要在正文里插入一条审批变量、一个关联工单的卡片、一段只能整体删掉不可逐字修改的占位块。React contentEditable 的组合在这种需求下比想象中更拧巴但也更有意思。这篇内容是我把“可编辑节点组件预设”拆开揉碎之后的完整记录适合那些不想被重型编辑器框架绑架、又希望正文能嵌入自定义交互组件的开发者看。1. 为什么说从零实现富文本编辑器不是灌水1.1 市面编辑器那么多自研到底图什么先说一个现实很多团队选编辑器时第一反应是“用现成的”。这没错但现成库也有它的代价。比如你只需要一个纯文本加粗、插入链接、带几个自定义实体的轻量输入框引入一个完整编辑器内核光是样式重置、事件拦截、快捷键适配就能折腾好几天。更麻烦的是当你需要把“可编辑节点”做成真正意义上的React组件能响应props变化、能交互、能发请求你会发现很多编辑器对自定义节点的支持停留在“渲染一段HTML字符串”的水平你想要的是一个真正的组件树挂在正文里。自研富文本编辑器的核心价值不在于重造轮子而在于把编辑器的“内容模型”掌握在自己手里。当业务里出现了“用户输入的文字与业务对象混合编排”这种需求时内容模型才是真正的关键。比如一个工单备注里可能含有“某经办人”“订单号 #ORD-2024-001”“一段仅可整块删除的协议声明”。这些都不是普通文本它们是带着数据语义的可编辑节点。只有自己的实现才能在数据语义和交互表现之间自由切换。1.2 可编辑节点的核心痛点contentEditable的“一半可控性”所有自研Web富文本编辑器都绕不开 contentEditable。它能让你把任意HTML元素变成编辑区浏览器自动处理输入、回车、粘贴。但这份“自动”恰恰是问题的根源浏览器对DOM树的操作是黑盒你不知道用户按一个退格键之后DOM会变成什么样更不知道光标在某个带交互的子组件内部时事件到底先被谁消费。自定义可编辑节点的痛点就在这里它需要内嵌交互组件但这些组件绝不能干扰文字的编辑流。假设你在正文中插入了一个“日期选择器”节点这个节点内部有点击、弹层、按钮如果它整体被设置成 contentEditablefalse那么它就像一个“原子”光标无法进入其内部只能整体选中、整体删除。这符合预期。但如果这个原子节点在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 用contentEditablefalse实现原子节点让节点变得“原子”最简单的方式就是在节点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 classNameeditor-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> SEO 优化官网定制响应式建站教育培训建站