资讯动态

React可编辑节点与组件预设:自研富文本编辑器架构解析

发布时间:2026/10/24 4:32:03 来源:尧图企业网站定制
开头先交代个背景。前阵子我要做一个内部知识库的编辑模块需求不算复杂能写标题、段落、列表但要能把几种自定义的“卡片”——比如成员名片、状态标签、数据摘要——直接插进文档里跟文字混排还能被复制粘贴被序列化存库。我一开始想走捷径去评估了一圈现成的富文本库结论是重量级的太重轻量级的不给自定义节点留口子要在它们之上做“React可编辑节点”这事儿越绕越远。最后决定自己下场从零写一个文档模型加编辑器壳子把组件预设做成一个注册式的插件系统。这篇文章就是把整个实现过程和取舍讲透给想在编辑器领域自己做点东西的朋友当个参考。这个项目我给它命名为“某跨平台富文本编辑器”虽然名字带跨平台但核心就一块React 可编辑节点的组件预设机制。这套机制解决的是“自定义组件如何嵌入可编辑文档、如何被光标操作、如何序列化回存”这三个老大难问题。文章会从架构选型、文档模型、渲染管线、选区处理、组件注册、序列化方案、问题排查等多个维度层层拆开讲全部基于我实际跑通的代码路径尽量还原当时的思考和踩坑记录。1. 项目整体设计与技术选型1.1 为什么不用现成的富文本库市面上能打的富文本方案我基本都调研过先说结论如果你的业务形态是“简单文章排版 少量自定义块状元素”用社区库绝对省时间如果自定义节点的复杂度很高比如节点内部有独立交互、有校验逻辑、有异步数据源那现成库反而会成为瓶颈。举几个具体栗子方案针对自定义节点的主要问题A重量级框架自研文档模型学习曲线陡峭事件循环深扩展点藏在内部调试难度极高B主流开源编辑器自定义叶子节点可以做但要做“块级组件里的可编辑区域交互”常被模型约束卡住C底层工具库能力给得太裸所有选区、序列化、撤销重做都要自己重复造轮子我当时的判断是如果只是“标签”“高亮”“嵌入一块不可编辑内容”可以选 B但如果要同时满足“可编辑块级组件 组件内部有自己的数据形态 跨端复制粘贴 服务端HTML回渲染”自研模型的维护成本其实更低。我们的知识库卡片需要在编辑态展示成员和状态在只读态展示数据和跳转链接在导出态变成规整的 HTML 片段这种多态需求逼着我把“节点类型”做成一等公民。所以“从零实现”不是情怀是实打实的需求倒逼。你想要多灵活的组件预设就得拥抱多深的自研模型。1.2 技术选型contenteditable 联合受控文档模型很多从零实现编辑器的新手会把两个概念搞混用 contenteditable 当画布浏览器原生可编辑区域用户直接打字、选择、删除浏览器负责处理大部分编辑体验。用受控文档模型当唯一数据源真实的文档内容不在 DOM 里而在我们的 JSON 树里DOM 只是这颗树的一次渲染投影。这两者可以共存也能各干各的。我自己这套实现的最终形态底层用一个contenteditable的容器 div 作为交互画布所有用户输入键盘、粘贴、拖拽统一拦截转成对文档模型的操作文档模型变化后走 React 渲染管线生成最新的 DOM 子树渲染完成后在 DOM 里恢复光标位置。这样做最大的优势文档状态永远可预测。自定义组件节点本质上是一段不可分割的“void 原子块”它的内部自有状态只需要通过节点数据保存即可不受 DOM 同步的制约。你不需要去监听组件 DOM 的内部变化只把组件当成一个带数据和渲染函数的黑盒编辑器的核心逻辑就能和任意 React 组件解耦。这套设计和 React 的哲学也天然契合UI 是 state 的函数DOM 是文档树的函数组件高度声明式。坏处也很明显光标管理要自己抗输入法要自己抗性能基线要自己守。但这些都是可以解决的工程问题我会在后文把每一步的方案和代码路径梳理出来。2. 核心架构拆解文档模型与渲染管线2.1 节点类型体系与文档树设计我设计文档树的思路参考了叶子节点和块级节点分离的经典做法但不追求完全一致。核心就三类节点文本节点payload 是字符串没有子节点块级节点如 paragraph、headingchildren 里挂文本节点或行内节点组件节点如 memberCard、statusTag属于 void 节点不允许有 children所有内容数据放在 data 字段里。简化后的 TS 类型长这样type EditorNode | { type: text; text: string; id?: string; } | { type: paragraph | heading | quote; children: EditorNode[]; attrs?: Recordstring, unknown; id?: string; } | { type: component; componentType: string; data: Recordstring, unknown; id: string; meta?: { version?: number; locked?: boolean }; };为什么块级节点也挂id因为选区恢复、协同标注、服务端 diff 都需要一个稳定的节点标识。文本节点不挂 id 主要是为了避免频繁重新生成标识造成性能浪费它的坐标完全由父路径加偏移量确定。组件节点我特别强调三点id 必须全局唯一复制、粘贴、拖拽时要生成新 id否则撤消栈里同一个组件的前后快照会混淆data 字段是纯数据不能塞 React 实例或 DOM引用否则无法 JSON 序列化meta 字段负责版本兼容组件结构升级后旧数据可以先走迁移逻辑再渲染避免渲染崩掉。这套模型给我最大的收益是渲染、序列化、剪贴板处理全部可以递归写遇到组件节点就按注册表查 React 组件不用到处写 if/else。2.2 React 渲染管线从文档树到可编辑 DOM有了文档树下一步就是把树递归渲染成 DOM。这里有个关键认知不要直接把文档对象塞进 React 组件的 props 里然后渲染因为编辑操作要修改的是 DOM 的原生选区。我的渲染管线分四层定位层根据文档树计算出需要渲染的可见区域对超长文档按窗口虚拟滚动不是每篇都做渲染层递归把EditorNode[]映射成 React 元素树挂载层把渲染结果挂到contenteditable容器下同步层渲染完成后执行光标恢复函数把 DOM 上的原生 Range 恢复到正确位置。核心渲染代码简化如下function EditorNodeRenderer({ node, children }: { node: EditorNode; children?: React.ReactNode }) { if (node.type text) { return {node.text}/; } if (node.type component) { return VoidComponentSlot nodeId{node.id} componentType{node.componentType} data{node.data} /; } switch (node.type) { case paragraph: return p{children}/p; case heading: return h2{children}/h2; case quote: return blockquote{children}/blockquote; default: return div{children}/div; } }组件节点的特殊处在于VoidComponentSlot外层必须套一个不可编辑的容器节点并且给它几个关键属性function VoidComponentSlot({ nodeId, componentType, data }) { const { component: Component } useComponentPreset(componentType); return ( span >class PresetRegistry { private presets new Mapstring, ComponentPreset(); register(type: string, preset: ComponentPreset) { if (this.presets.has(type)) { console.warn([Editor] component preset ${type} overridden); } this.presets.set(type, preset); } get(type: string): ComponentPreset | undefined { return this.presets.get(type); } has(type: string): boolean { return this.presets.has(type); } } export const presetRegistry new PresetRegistry();每个预设的定义形如export const memberCardPreset: ComponentPreset { type: memberCard, defaultData: { name: , title: , avatarUrl: , email: }, component: MemberCard, toHtml: (data) div classmember-card.../div, toMarkdown: (data) **[${data.name}](${data.email})** — ${data.title}, validate: (data) typeof data.name string typeof data.email string, };这套注册表机制带来的自由度非常大业务方可以只写一个组件 三个适配函数就接入编辑器不必懂文档树细节。而且因为渲染组件只在VoidComponentSlot里被调用组件内部的 state、effect、动画都活在自己的生命周期里编辑器的输入拦截和 React 并发模式不会把它们打断。3. 可编辑核心机制选区、光标与输入同步3.1 原生 Range 与文档坐标的换算浏览器里光标的本质是锚点与焦点的组合。在纯文本场景DOM 的 Range 直接能对应到文本节点的偏移在带组件节点的场景光标要能停在一个“不可编辑块”的前后这时 DOM 的文本节点就被打断了。举个实际例子文档结构是paragraphtext: 你好component: memberCardtext: 请查看渲染到 DOM 后三个节点是相邻的p 你好 span>type DocPosition | { path: number[]; offset: number } | { path: number[]; side: before | after };换算过程的伪代码function rangeToDocPos(range: Range): DocPosition | null { const { startContainer, startOffset } range; // 找目标 Text 节点或组件容器的完整嵌套路径 const path findNodePath(startContainer); if (startContainer.nodeType Node.TEXT_NODE) { return { path, offset: startOffset }; } // 落在元素节点上判断是组件前还是后 const side getEdgeSide(range, startContainer); return { path, side }; }这套坐标系统一处理后文档树怎么变我都能在任意时刻把光标恢复回去而且组件节点的存在不会干扰坐标计算。这是整个编辑器稳定性最强的支撑层。3.2 输入拦截与用户操作的模型化转换有了可计算的坐标系统接下来要解决“用户编辑”这件事。我的选择是不做任何 DOM 层的文本直接修改所有输入事件都通过转换函数改文档树再重新渲染 DOM。对于普通打字我会拦截beforeinput事件editorContainer.addEventListener(beforeinput, (e) { e.preventDefault(); // 1. 获取当前光标文档坐标 const range selection.getRangeAt(0); const docPos rangeToDocPos(range); // 2. 根据输入类型生成对应的操作 const ops: EditorOp[] createOpsFromInput(e.inputType, docPos, e.data); // 3. 应用操作到文档树 applyOpsToDoc(ops); // 4. 渲染并恢复光标 renderEditor(); restoreCursor(); });createOpsFromInput是核心分发器处理常见的输入类型switch (e.inputType) { case insertText: return [insertTextOp(docPos, e.data ?? )]; case insertParagraph: return [splitBlockOp(docPos)]; case deleteContentBackward: return [deleteBackwardOp(docPos)]; case deleteContentForward: return [deleteForwardOp(docPos)]; case insertFromDrop: case insertFromPaste: return [pasteContentOp(docPos, e.clipboardData)]; default: return []; }每个 op 的 apply 函数只操作文档树返回新的树。比如deleteBackwardOp的分支逻辑光标在文本节点中间删一个字符光标在文本节点开头且前面是组件节点删除整个组件节点光标在组件节点前面删除组件节点光标在两个块级节点交界处合并两个段落。规则看似简单但因为组件节点和文本节点在 DOM 层表现不同——组件是整块删文本是逐字删——所以必须靠docPos.side里的before/after来区分边界。我在实现时漏了组件后侧的光标状态导致按退格键会删掉后面的文本而不是组件修这个边界问题花了两天。3.3 IME 输入法与组合态处理中文输入是所有自研编辑器的噩梦因为拼音输入法的组合阶段会产生大量中间事件如果每次beforeinput都拦截并重渲染 DOM输入框里的拼音组合会闪断或丢失。我最终的策略是组合开启时compositionstart进入“透传模式”不再拦截输入让浏览器原生地把拼音拼到 DOM 里监听compositionend后把 DOM 里多出来的文本一次性取出来转换成一个insertText操作提交到文档树提交完成后立刻重渲染并恢复光标。这个方案能保证输入过程中 DOM 不被重绘打断用户看着拼音像在普通输入框里一样连续。问题也很明显组合期间 React 如果因其他原因重渲染拼音会丢失。我的解法是给编辑器加了一个“组合中锁”组合期间默认阻止任何非必要重渲染只允许异常处理通道工作。if (isComposing) { // 暂缓把新 DOM 同步回模型 pendingDomSnapshot snapshotContainerDom(); return; }组合结束后先取快照再和上次渲染的 DOM 做最小差异对比只提取真正变化的文本节点避免组合结束时整棵树被错误替换。这个细节我觉得是中文编辑体验的分水岭很多从零实现的项目就是在这里放弃的。4. 实操实现组件预设系统的完整落地4.1 项目初始化与基础依赖选择说清楚一个事我这里“从零”指的是从文档模型开始写不是连构建工具都手搓。项目的技术栈相对克制依赖作用vite开发与构建热更新快配置简单react / react-domUI 渲染层immer文档树不可变更新避免手写深拷贝zod可选组件预设 data 的运行时校验初始化项目的过程没什么特别的重点在于目录结构。我按“核心内核 预设插件”拆分方便以后多个业务共用一个编辑器 npm 包editor/ ├── core/ │ ├── model.ts // 文档树类型定义 │ ├── operation.ts // 文档操作原语 │ ├── selection.ts // 选区与坐标换算 │ ├── renderer.tsx // React 渲染管线 │ ├── registry.ts // 组件预设注册表 │ └── clipboard.ts // 剪贴板序列化 ├── presets/ │ ├── member-card.tsx │ ├── status-tag.tsx │ └──>export function RichTextEditor({ value, onChange, presets [], readOnly false, }: RichTextEditorProps) { return ( EditorProvider EditorShell value{value} onChange{onChange} readOnly{readOnly} / /EditorProvider ); }外部使用方只要在组件根部注册预设即可编辑那种类型的节点RichTextEditor value{doc} onChange{setDoc} presets{[memberCardPreset, statusTagPreset]} /这套 API 形态基本参考模块化设计思路内核负责文档模型与编辑能力外置组件通过注册机制进入编辑循环互不侵入。4.2 渲染与序列化的闭环实现组件节点的数据最终要能够导出成通用格式。我的toHtml适配器在导出时负责把 data 封进 HTML 片段在粘贴进来时则走反向解析复制编辑器内组件写一个自定义的text/htmlMIME 类型片段同时用>文档树 --- toHtml --- HTML字符串含组件标记 ^ | | | 复制/导出 | v 文档树 --- fromHtml --- 粘贴/导入 HTML字符串关键在fromHtml的正向解析。为了不依赖复杂第三方解析库我用的策略是递归下降遍历 DOMfunction htmlToEditorNode(el: Element): EditorNode[] { if (el.hasAttribute(data-editor-node)) { const type el.getAttribute(data-component-type)!; const data JSON.parse(el.getAttribute(data-json) || {}); return [{ type: component, id: genId(), componentType: type, data }]; } // 其他标签递归... if (el.tagName P) return [{ type: paragraph, children: walkChildren(el) }]; new Set([B, STRONG]).has(el.tagName) ... }这里有一个坑JSON 塞在 HTML 属性里时会把引号转义成quot;读回来要记得执行unescape否则JSON.parse会直接抛错。最开始我在这上面吃了不少闷亏后来统一封装了encodeAttrJson/decodeAttrJson两个辅助函数所有组件标记均走这两函数问题才绝迹。4.3 两个自定义组件预设的完整示例下面拿两个有代表性的预设展示一下写法一个有交互组件一个纯展示组件。状态标签预设最简单的组件预设形态const StatusTag ({ data }: { data: StatusTagData }) { const colorMap: Recordstring, string { todo: #f59e0b, doing: #3b82f6, done: #10b981, }; return ( span style{{ background: colorMap[data.status] ?? #e5e7eb, color: #fff, padding: 2px 8px, borderRadius: 10px, fontSize: 12 }} {data.label ?? data.status} /span ); }; export const statusTagPreset: ComponentPreset { type: statusTag, defaultData: { status: todo, label: 待办 }, component: StatusTag, toHtml: (data) span>const MemberCard ({ data, onChangeData }: { data: MemberCardData; onChangeData: (d: MemberCardData) void }) { return ( div classNamemember-card img src{data.avatarUrl} alt{data.name} / div classNamemember-info div classNamemember-name{data.name}/div div classNamemember-title{data.title}/div /div button onClick{() { openMemberPicker() .then((member) { onChangeData({ ...data, ...member }); }) .catch((err) { console.error(member pick cancelled, err); }); }} 更换成员 /button /div ); };注意这里多了onChangeData回调这个回调由VoidComponentSlot注入本质是触发了文档树的一条更新指令function updateComponentData(nodeId: string, newData: unknown, doc: EditorNode[]): EditorNode[] { return doc.map((n) { if (n.type component n.id nodeId) { return { ...n, data: newData, meta: { ...n.meta, version: (n.meta?.version ?? 1) 1 } }; } if (n.children?.length) { return { ...n, children: updateComponentData(nodeId, newData, n.children) }; } return n; }); }整个链路就是从“组件内触发事件 → 更新文档树 → 编辑器重渲染 → 组件拿到新 data 自动更新”的受控数据流。组件内部完全不碰 DOM只跟数据打交道。4.4 光标恢复与重渲染时机控制每次文档树变化后重渲染最怕的是光标闪跳。我的光标恢复策略是“三步走”重渲染前记录当前文档坐标重渲染完成后从文档坐标反查 DOM 节点如果坐标落在组件前后用Range.setStartBefore/After定位落在文本节点中间用setStart定位。代码实现function restoreCursor(pos: DocPosition | null) { if (!pos) return; const domNode locateNodeByPath(pos.path); const range document.createRange(); if (domNode.nodeType Node.TEXT_NODE) { range.setStart(domNode, Math.min(pos.offset, domNode.textContent?.length ?? 0)); } else if (pos.side before) { range.setStartBefore(domNode); } else if (pos.side after) { range.setStartAfter(domNode); } const sel window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); }这里有个特别容易触发的 bug离屏组件节点在重渲染后可能还没挂载完成locateNodeByPath找不到目标节点。我引入了一个简单的 retry 机制如果恢复失败就在requestAnimationFrame里再试一次如果连续三次都失败放弃恢复并按“树根起始位置”兜底。对长文档而言这个失败率已经低到可以忽略。5. 常见问题与排查技巧实录5.1 组件节点在复制粘贴时丢失症状编辑器内复制一段含组件节点的内容粘贴回来组件变成了纯文本甚至变成一坨空白。排查路径先看剪贴板事件里text/html数据到底长什么样如果组件标记没被写进去问题出在copy事件处理如果 HTML 有标记但粘贴后找不到组件多半是fromHtml解析递归漏了一层或者>pushHistory(prevDoc, nextDoc) { const diff computeDiff(prevDoc, nextDoc); if (diff.length 0) return; this.undoStack.push({ diff, prevDoc, nextDoc }); if (this.undoStack.length 200) this.undoStack.shift(); }5.4 长文档渲染卡顿与性能治理长文档下重渲染性能是很现实的问题。我遇到的卡顿场景是一次性渲染了包含上百个组件节点的文档尤其是每个组件都带contentEditablefalse且都注册了事件挂载成本很高。性能治理我做了三件事虚拟化渲染只渲染可视区域附近的节点其他节点先渲染占位符。实现时用滚动容器监听 scroll 事件动态计算可视路径区间组件懒加载组件节点不急于在首屏加载通过在VoidComponentSlot里用React.lazy包裹只有组件滚动进视图才真正挂载操作批处理如果一次文档更新包含多个 op统一合并成一次setState更新避免多次渲染。这三板斧下来极端的 500 节点文档卡顿问题基本消失整体编辑流畅度达到了让人愿意使用的状态。6. 踩坑记录与建议清单这个项目做到后面我自己总结了一套“避坑指南”可能比正文里的代码片段更值得留档不要一开始就迷信纯受控渲染。在输入环节保留一定 DOM 原生空间可以让中文输入法、自动填充这类“不太讲道理”的浏览器特性舒服很多。我见过硬刚输入法的项目最后都以失败告终。组件节点越薄越好。它承载的是“位置”不是“业务”。业务状态放自己的数据里有大量交互需求的组件建议在内部弹层或面板里处理别让复杂交互全堆在文档流里。序列化标记越简单越稳妥。我最终没有选择 Tiptap 序列化方案那种全面 JSON 化而是保留一段带标记的 HTML。因为团队后端和运营同事经常直接看 HTML 片段调试纯 JSON 对他们太不友好。光标恢复逻辑一定写在渲染完成之后不要在useEffect里盲目执行组件的componentDidUpdate也可能干扰光标必要时用MutationObserver排查坐标异常。如果你要做的是更复杂的内容系统比如协同编辑或表格嵌套这套架构还要往深处调但可编辑节点 组件预设的骨架是可以平移复用的。做这个项目印象最深的一次调试是用户反馈“把光标放在组件后面然后按退格键经常连着前面的文本一起吞掉”。我排查了很久最后发现根因是deleteBackwardOp的docPos.side after分支没有处理清楚组件后侧光标按退格应该先删组件但我写成了删除组件后面那个文本节点的第一个字符。修完后整个编辑手感立刻稳定了。这种边界问题不可能靠想象发现只有真在使用场景里反复打磨才能堵住。编辑器就是从一堆这种细碎边界问题中长出来的每修一个它就更接近顺手。我用这套方案做出来的项目已经实际支撑了几个内部知识库场景团队成员日常用下来反馈最集中的就是“自定义卡片的交互太自然了”——正是冲着这个反馈我才决定把整条技术脉络记录下来希望对同样在自研编辑器上钻牛角尖的人有些帮助。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑