ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从零实现在线文档编辑器:核心模型与实战指南

从零实现在线文档编辑器:核心模型与实战指南 做编辑器类项目这几年我接到的需求里出现频率最高的一个词就是“editor”。这个词看起来简单真做起来却特别容易失控因为它背后可能藏着完全不同的东西是带工具栏的富文本编辑器是支持 Markdown 的文档编辑器还是那种从零手写内核的代码编辑器连核心交互都不一样。这篇文章就以“editor”为切入点聊聊从零开始实现一个在线文档类编辑器时真正需要想清楚的技术选型、数据模型、核心模块实现路径以及我在实际落地过程中踩过的一些坑。内容会尽量贴近真实工程场景适合准备自己写编辑器、或者正在选型改造编辑器内核的开发者参考。1. 动工之前先搞清楚你要做的是哪种“editor”1.1 编辑器的三大流派选错方向后面全是灾难很多人拿到“editor”这个标题就急着找组件库其实第一步应该是给需求做分类。市面上常见的编辑器从底层形态上可以分成三类第一类是所见即所得的富文本编辑器典型如微信公众号后台、Notion 的正文区、各家的工单回复框。用户不关心 HTML 和 Markdown 源码只关心光标点下去、按钮按下去内容就变粗变斜变红。这类编辑器的本质是“文档树 选区 操作命令”。第二类是Markdown 编辑器比如 Typora、语雀的源码模式。底层是一个文本输入框或者 CodeMirror 类的代码编辑区配合预览面板做双向同步。核心难点不在选区操作而在解析 Markdown 语法、维护光标与渲染位置的映射。第三类是代码编辑器比如 VS Code、Monaco。它的核心是语法高亮、自动补全、缩进处理、大文件虚拟滚动跟文档编辑器的技术栈重合度其实很低一般不会用 contenteditable 实现。我这次要展开的是第一类在线富文本/文档编辑器。如果你手里的“editor”项目也是这种后面的内容可以直接照抄思路如果是后两类某些模块可以考虑跳过但数据模型和操作原语的设计思路仍然有参考价值。1.2 自研内核和二次开发成熟内核怎么权衡确定类型后紧接着就是那个经典问题自己写还是基于 ProseMirror / Slate / Quill 这类成熟内核二次开发我的建议是不要因为“想省事”就直接套用框架也不要因为“看起来很酷”就什么都自己写。关键看你的需求层在哪。方案优点典型代价适用场景原生 contenteditable 自制文档模型完全可控无框架束缚选区、IME、兼容性坑非常多工期不可控对编辑器有深度定制需求团队有前端底层能力基于 Slate 构建文档模型设计清晰插件化需要理解其模型约束历史版本 API 变动大团队愿意投入学习成本需要复杂节点定制基于 ProseMirror 构建Schema 严格协同方案成熟概念多入门曲线陡样式定制麻烦需要协作编辑或内容结构需要强校验基于 Quill 快速交付上手快默认功能完整深度定制受限制树形结构表达弱业务形态接近传统富文本工期紧张拿我手头的一个项目来说早期为了快速上线直接用了 Quill。需求只是加粗、斜体、列表、图片确实挺快。后来业务方要求做“文档块拖拽排序”Quill 默认的 Delta 模型表达这种块级结构非常别扭改造成本远超预期。最后整体迁移到了自研模型 contenteditable 渲染层才把拖拽、折叠、嵌套引用块做顺。所以如果需求里明显存在“结构化文档”的诉求比如多级标题、块引用、列表折叠、自定义卡片我更推荐一开始就认真评估自研模型或者直接用 Slate/ProseMirror 这类自带结构表达能力的框架而不是用 Quill 这类传统富文本方案硬套。1.3 第一版千万别贪多MVP 边界要清晰确定自研后第一版的功能边界一定要克制。我跟不少朋友聊过他们做 editor 翻车绝大多数不是技术实现不行而是第一版就想着把所有功能都塞进去表格、图片剪裁、协同、评论、历史版本、导入 Word……正确的做法是先定义最小可用版本我的建议清单是文本基础能力加粗、斜体、下划线、删除线、行内代码块级能力标题、段落、有序列表、无序列表、引用块光标与选区点击定位、Shift 选区、上下左右移动粘贴处理带格式复制、纯文本粘贴、图片粘贴上传占位工具栏联动光标所在位置高亮对应格式快捷键基础Ctrl/Cmd B/I/U、回车分段、退格合并数据导出把内部文档树序列化为干净的 HTML表格、评论、多人同时编辑、历史版本回溯这些在第一版里都不要碰。第一版的目标是验证“文档模型 选区 操作命令”这一条主链路是否稳定。主链路稳了后面加功能只是工作量问题主链路不稳后面每加一个功能都会触发新 bug。2. 数据模型设计决定了 editor 能走多远2.1 不要在 DOM 上做业务逻辑一定要有中间模型这是我做 editor 最大的一个教训。早期项目里我天真地以为可以用“读 innerHTML 字符串”的方式来处理文档内容结果加粗、列表嵌套、空段落这些问题接踵而来光是判断“当前光标是否在一个列表项里”就要写十几行 DOM 遍历代码后来迫不得已重构才把文档模型抽了出来。正确的做法是浏览器 DOM 只是渲染层编辑器内部必须维护一份独立的、结构化的文档树。什么是文档树简单说把用户看到的内容抽象成节点。一个文档是由块级节点Block组成的比如段落、标题、引用、列表项每个块级节点里又有若干个行内节点Inline比如文字、加粗片段、链接。它的结构用 TypeScript 描述大概是这样的type Node BlockNode | InlineNode; interface BlockNode { type: paragraph | heading | quote | list-item; attrs?: { level?: number; // heading 级别1-6 listType?: ordered | bullet; align?: left | center | right; }; children: InlineNode[]; } interface InlineNode { type: text | link | mention | image; text?: string; // text 类型时保存文本内容 href?: string; // link 类型时保存地址 src?: string; // image 类型时保存图片地址 marks?: Mark[]; // 行内样式集合如加粗/斜体/颜色 } type Mark bold | italic | underline | strike | code | { color: string };这套模型有几个关键设计点所有跟业务相关的判断都基于模型节点比如判断“当前行是不是标题”只需要看当前 BlockNode.type行内样式不直接修改 DOM 标签而是记录在 marks 数组里变址更新时只需要局部刷新渲染层根据模型生成 DOM 树尽量只做“单向绑定”用户操作时通过事件回调回写模型再由模型触发局部渲染。2.2 操作原语Operation是撤销、协同的地基有了文档树之后还需要定义一组“如何修改这棵树”的接口。我采用的方式类似 OT 里的操作原语思想所有修改操作都收敛到几个原子方法上type Operation | { type: insert-text; path: number[]; offset: number; text: string } | { type: delete-range; startPath: number[]; startOffset: number; endPath: number[]; endOffset: number } | { type: set-block-attr; path: number[]; attrs: Recordstring, unknown } | { type: split-block; path: number[]; offset: number } | { type: merge-block; path: number[]; withPrevious: boolean } | { type: apply-marks; startPath: number[]; startOffset: number; endPath: number[]; endOffset: number; marks: Mark[] } | { type: remove-marks; startPath: number[]; startOffset: number; endPath: number[]; endOffset: number; marks: Mark[] };这里的 path 表示节点在文档树中的路径比如[0, 1]表示第一个块级节点下的第二个内联节点。offset 表示在文本中的偏移位置。为什么强调一定要这一层操作抽象因为它带来了三个直接收益撤销重做好做多了。撤销栈里存的对象不是编辑器快照而是这些操作本身。用户执行 CtrlZ 时只需要逆序反向执行操作即可不需要对比整个文档树差异。协同编辑的基础就是操作同步。多人同时编辑时只要每个人的操作都是这种原子操作就可以通过 OT 算法或 CRDT 对操作做合并。如果模型层直接操作 DOM协同根本无从谈起。可测试性大幅提升。单测可以直接构造一个文档树应用一串操作序列断言结果是否符合预期不需要依赖浏览器事件。2.3 空段落为什么难处理因为“空”也是一种数据编辑器的“空”状态很容易被忽略却非常折磨人。用户把一段文字全部删除后模型里不能没有块节点否则光标就无处安放但同时一个完全空的段落节点渲染成 DOM 时又需要保留占位高度不然用户看不到“可输入位置”。我在项目里对空段落的处理方式是保留一个paragraph节点且 children 为空数组渲染层针对这种空节点输出一个带br标签的占位 DOM。这样按下回车时能正常拆分块光标定位也稳定。这个细节在数据模型设计阶段就要考虑进去否则后面补会改到很多地方。3. 核心模块实操从选区到序列化逐个击破3.1 选区管理所有编辑器功能的前提在富文本编辑器里绝大多数操作都和选区Selection绑定在一起。用户选中一段文字加粗本质是“获取选区范围 - 将范围内文本节点打上 marks - 重新渲染”。浏览器在使用 contenteditable 时原生提供了window.getSelection()API它返回一个 Selection 对象其中包含 anchorNode、anchorOffset、focusNode、focusOffset 等属性。但原生选区含义和文档模型不对应所以我封装了一套选区转换逻辑function selectionToRange(selection: Selection): EditorRange | null { const anchorNode selection.anchorNode; const focusNode selection.focusNode; if (!anchorNode || !focusNode) return null; const start getModelPositionFromDom(anchorNode, selection.anchorOffset); const end getModelPositionFromDom(focusNode, selection.focusOffset); // 根据文档顺序归一化开始和结束位置 if (comparePositions(start, end) 0) { return { start, end }; } return { start: end, end: start }; }这里的getModelPositionFromDom是核心难点它的作用是把一个 DOM 节点加偏移量映射成文档树里的{ path, offset }。实现上需要从 DOM 节点逐级向上遍历找到它属于哪个块节点、哪个内联节点再结合偏移量算出模型位置。一个很实用的经验是永远不要直接信任 DOM 上的 index比如某个 DOM 元素下的文本偏移遇到嵌套标签strongem内容/em/strong时偏移量计算很容易出错。我的做法是给每个内联节点对应的 DOM 元素加 data 属性标记它对应的 path快速定位。3.2 IME 输入法合成中文输入不乱的诀窍做编辑器的都知道中文输入法是最大的坑之一。用户敲“nihao”的时候浏览器会先触发 compositionstart再触发多次 compositionupdate最后 compositionend 才真正把“你好”写入文档。如果不处理 composition 事件就会出现两个典型问题一是输入过程中重复触发 input 事件导致文档树被多次修改二是候选框弹出时选区信息被破坏导致候选文字落到错误位置。我的解决方案是给编辑器加一个“composition 锁”let isComposing false; editorEl.addEventListener(compositionstart, () { isComposing true; }); editorEl.addEventListener(compositionend, (e) { isComposing false; // compositionend 后浏览器已经写入文本这里用 requestAnimationFrame 延迟同步一次 requestAnimationFrame(() { syncFromDomToModel(); }); });在input事件处理逻辑里如果isComposing true就直接取消同步等到 compositionend 后再一次性把 DOM 内容和模型对齐。这种“延迟一拍”的做法实测下来非常稳大幅减少了中文输入时的光标跳动和字符重复问题。注意compositionend 触发后DOM 中的文本可能已经更新但模型还没有同步。因此不能在 compositionend 里立即读取旧模型而是应该重新从 DOM 同步一次或者主动把输入文本作为一次 insert-text 操作写入模型。3.3 格式化操作别再用 document.execCommand很多老教程里给选中文字加粗用的是document.execCommand(bold)。这是个简单方案但存在不少隐患不同浏览器对 execCommand 的实现不一致它产生的 DOM 结构不可控容易冒出b、strong、span stylefont-weight: bold;各种形态还要处理撤销栈不受控的问题。更稳妥的做法是“基于文档树的格式化”拿到选区转换后的模型区域遍历这块区域内命中的所有文本节点在对应 marks 里追加或移除目标 mark然后局部渲染。核心伪代码是function applyMark(range: EditorRange, mark: Mark) { const { start, end } range; const editorOp: Operation { type: apply-marks, startPath: start.path, startOffset: start.offset, endPath: end.path, endOffset: end.offset, marks: [mark], }; editor.applyOperation(editorOp); }局部渲染这块有两个方案全量重渲染和精准 DOM 更新。文档树比较小的时候全量重渲染重排整棵 DOM 树省事够用文档变长之后全量重渲染会导致光标位置丢失所以我最终改成了“节点级精准渲染”只更新 marks 变化的那个 inline 节点对应的 DOM 元素通过判断旧 marks 和新 marks 的差异生成新的strong、em等子元素替换进去。这个方案对长文档性能明显更好。3.4 序列化与粘贴清洗对外输出干净对内接收可控编辑器最终要把内容交给后端或复制到剪贴板因此必须实现“文档树到 HTML”的序列化函数function serializeToHtml(doc: BlockNode[]): string { return doc.map(block { const inner block.children .map(inline { let text escapeHtml(inline.text || ); if (inline.marks?.includes(bold)) text strong${text}/strong; if (inline.marks?.includes(italic)) text em${text}/em; if (inline.href) text a href${inline.href}${text}/a; return text; }) .join(); return p${inner}/p; }).join(); }这个序列化函数要注意三件事第一所有文本必须做 HTML 转义防止注入第二mark 的嵌套顺序要稳定比如先判断 bold 再判断 italic避免同一个文本序列化出两种结构第三block 类型和标签之间的映射要固定比如 heading 映射成h1quote 映射成blockquote。序列化输出的 HTML 是“对外口径”。从外部拷贝 HTML 进来时则要走另一套“清洗逻辑”接收方先用 DOM parser 把 HTML 解析成一颗临时树再遍历白名单标签p、br、strong、em、ul、ol、li、a、h1-h6、blockquote 等其余标签全部剥掉最后映射回文档树。粘贴清洗时最容易出现的问题是“样式残留”比如从 Word 粘贴过来的内容会带着一堆stylemso-...内联样式。我的做法是清洗时将 style 属性全部丢弃只保留白名单标签本身和 href、src 这类必要属性。图片粘贴则走额外的上传流程粘贴时先展示 loading 占位再由上传完成事件替换为真实图片节点。3.5 撤销重做基于操作栈的反向补偿前面数据模型部分提到操作原语让撤销变得很直观。撤销栈里保存的是历史操作记录撤销时逐条反向执行。反向执行的规则是insert-text 的反操作是 delete-range删除对应偏移范围内的文本delete-range 的反操作是 insert-text在删除位置重新插入split-block 的反操作是 merge-block把拆分出来的块和上一个块合并apply-marks 的反操作是 remove-marks。还有一个细节容易忽略用户输入“abcdef”这样的连续字符如果每一个字符都记为一个操作撤销要按六次才能删完体验很糟糕。所以我在操作入栈前会做合并连续 500ms 内、同一选区路径上的 insert-text 操作合并成一次。这个时间窗口不是拍脑袋我试过 300ms 和 1000ms最终 500ms 在“响应速度”和“合并预期”之间最均衡。3.6 快捷键系统拦截、命名、分发一个都不能少编辑器里的快捷键看起来是小功能但拦截时机稍不注意就会出 bug。比如 Ctrl/Cmd B 要加粗但如果用户焦点在工具栏按钮上这个快捷方式就不该触发编辑器加粗又比如输入法组合期间不应该触发任何快捷键。我封装了一套快捷键管理模块interface ShortcutRule { key: string; // b | enter | tab ctrl?: boolean; shift?: boolean; alt?: boolean; run: (editor: Editor) void | false; } const rules: ShortcutRule[] [ { key: b, ctrl: true, run: (editor) editor.toggleMark(bold) }, { key: i, ctrl: true, run: (editor) editor.toggleMark(italic) }, { key: z, ctrl: true, run: (editor) editor.undo() }, { key: y, ctrl: true, run: (editor) editor.redo() }, ];处理 keydown 事件时先判断isComposing再判断事件源是否在编辑区内部然后匹配规则。按 Tab 插入缩进时我习惯preventDefault()并手动在模型里插入四个空格或缩进标记避免 Tab 键把焦点移出编辑区。4. 实测现场那些让人头疼的“editor 疑难杂症”4.1 光标跳动的根因渲染时序没对齐做 editor 遇到的第一个高发问题就是光标闪烁跳动。表现是用户敲一个字光标突然跑到行首或行尾。排查下来大部分原因是输入事件后模型更新了但 DOM 更新的时机太晚浏览器重新渲染时把选区位置重置了。解决办法是操作后立即在同一帧内触发渲染更新并且在渲染结束后重新恢复选区。如果必须用异步渲染一定要先把选区信息存下来渲染结束后用selection.setPosition或Range对象恢复。实测经验是所有可能改变文档结构的操作都应该走同一个“操作 - 渲染 - 恢复选区”管道不要散落在各处单独处理。4.2 移动端光标乱跳iOS Safari 的私有坑移动端编辑器的光标问题比桌面端严重得多。iOS Safari 对 contenteditable 的支持有自己的“理解”点击空白区域时光标经常不精确弹起软键盘后页面滚动光标跑出可视区。我试下来比较有效的补救措施给编辑区设置-webkit-user-modify: read-write兼容旧版本布局上避免编辑区高度被内容撑开后频繁变化给容器设置最小高度焦点事件触发后用requestAnimationFrame延迟执行scrollIntoView({ block: nearest })让光标所在位置尽量滚回可见区域软键盘弹出导致 viewport 变化时不要强制重置 scrollTop这会让光标再次跳动。坦白讲移动端的光标问题很难做到 100% 完美核心策略是“减少不必要的渲染重排保持编辑区内布局稳定”。如果业务不太依赖移动端手写编辑可以优先保证桌面端体验。4.3 粘贴 HTML 后格式乱掉清洗规则要逐步收紧粘贴是一个内容来源极杂的场景。同一个编辑框里用户可能粘贴来自网页的富文本、来自 Word 的排版内容、来自另一台编辑器的 JSON 序列化内容还有纯文本。我的做法是分三步处理监听 paste 事件优先取clipboardData.getData(text/html)取不到再取text/plain对 html 字符串生成临时 DOM递归遍历白名单标签遇到不认识的标签直接丢内容但保留子节点把清洗后的 DOM 映射回文档树最后走正常的 insert 操作流程。这里特别提醒不要直接innerHTML html然后querySelector批量处理有些复杂 HTML 中的嵌套错误会产生预期之外的结构。比较稳的是用DOMParser或template元素生成独立文档后再遍历。4.4 空白占位符“输入没字却显示提示”的实现方案空文档需要显示“请输入正文”之类的占位符。用 CSS 伪元素:empty来做是最简单的但 contenteditable 里空段落经常会包含br导致:empty失效。我最终用文档树判断当模型里只有一个空 paragraph 且无任何文本子节点时渲染层在这个 paragraph 根元素上添加一个placeholder类名配合 CSS::before显示提示文字。这样纯 CSS 就能实现占位效果不需要 JS 反复检查文本长度。5. 架构演进从单人编辑到协同和后续扩展路线5.1 协同编辑没那么神秘但也不该第一版就做几个人同时编辑同一个文档时常见的技术路线是 OTOperation Transformation或 CRDT。OT 的核心是让每个用户的操作都走前面说的操作原语服务器和客户端对并发操作做转换保证最终一致。CRDT 则是给每个字符或节点分配唯一 ID合并时按照 ID 排序天然具备收敛性。我的建议是第一版不要碰协同但数据模型设计时一定要预留“操作 ID”和“用户 ID”字段。将来需要做协同不需要推翻数据层只需要加一层网络传输和并发合并逻辑。如果一开始就直接在 DOM 上操作后续协同的改造成本差不多等于重写编辑器。5.2 自定义节点扩展引用块、卡片、表格怎么挂载编辑器发展到一定阶段业务方一定会提表格、卡片、音频、代办列表等需求。这类需求本质上就是“新增 BlockNode 类型的渲染和操作支持”。以表格为例interface TableNode extends BlockNode { type: table; rows: number; cols: number; cells: string[][]; }渲染层只需要为type table提供一个独立的渲染组件工具栏里“插入表格”则是一个生成 TableNode 的操作。所有后续编辑操作增删行列、修改单元格都是围绕这个节点的局部状态展开。这种“模型驱动节点渲染”的设计让扩展新节点变得非常顺畅也是我坚持自研文档模型的核心原因。5.3 模块拆分测试编辑器代码也很适合单测编辑器逻辑相对独立很适合做单元测试。我会把数据模型、操作应用、序列化、快捷键规则这些纯逻辑模块和 DOM 渲染、事件绑定这些浏览器相关模块分开。单测环境用 jsdom 或 Node 内置的测试框架跑纯逻辑浏览器相关逻辑等联调时再覆盖。这样重构时心里的底气会足很多改模型代码不会莫名其妙破坏一堆渲染功能。我个人在项目里的体会是做 editor 最忌讳的是“先写起来边写边想”。编辑器牵扯到数据模型、渲染层、选区、输入法、粘贴、撤销、协同多个层面边界不清晰时任何一个模块的小改动都可能引发别处 bug。先把文档树、操作原语和选区转换这三根柱子立稳再往上面堆功能你会发现后面每加一个新能力代价都只是新增一种节点类型或一个操作命令整个系统会越用越顺而不是越用越乱。如果你正打算开始一个 editor 项目建议从最小闭环入手一个工具栏加粗按钮、一个文本输入区、一个序列化的 HTML 输出。这个闭环跑通你就已经掌握了 editor 的核心骨架剩下的都是在这个骨架上挂肉的过程。
返回列表