从零实现富文本编辑器:核心原理与避坑指南

📅 发布时间:2026/9/15 7:08:58
从零实现富文本编辑器:核心原理与避坑指南
一个平平无奇的“editor”标题背后其实是一整个领域的事。最近我正好把一个富文本编辑器从零到一完整做了一遍这中间踩的坑、绕的弯、最后沉淀下来的方案比我想象中多得多。如果你也想自己实现一个类似的编辑器或者只是好奇这个看似日常的组件内部到底发生了什么这篇文章应该能帮你省下大量试错时间。先说清楚我做的 editor 是什么形态一个运行在浏览器里的富文本编辑器支持加粗、斜体、下划线、标题、列表这些基础格式还必须有稳定的撤销重做以及从剪贴板粘贴时能自动清理垃圾样式。听起来就是个标准后台产品里都会出现的组件但真正落地时页面上每一处“理所当然”的行为都藏着浏览器打架、光标错乱、历史记录断档这类问题。这篇文章会从整体设计、核心原理、具体实现到问题排查完整记录我当时的思考过程和最终方案适合正在接触前端编辑器、或者准备实现类似能力的同学参考。1. 整体设计先分清编辑器到底在解决什么问题1.1 需求第一刀工具条到内容的闭环做任何组件前第一个动作都不是写代码而是把交互链路画清楚。我的 editor 需求拆开之后其实只有一条主干用户在工具栏上点按钮对应的格式立即作用到当前选区上用户继续输入格式状态还能正确反映在工具栏上。这一个闭环听起来简单但它同时牵扯三个子问题选区是什么、格式怎么作用到内容上、格式状态怎么回读。选区问题是最容易被低估的。用户可能把鼠标拖过一段文字、可能光标停在某个位置、可能用键盘 shift方向键选择内容甚至可能跨行选择。每种情况对应的 Range 对象结构都不一样操作时要分别处理。格式作用到内容上又分两种场景有选区时是包裹标签、替换标签、还是插入新节点没选区时是写入一个标记让后续输入继承格式。最后格式状态回读需要在光标移动时遍历光标所在的节点链判断它当前算不算加粗、算不算标题。这三个子问题互相牵制任何一环做漏了后续都会返工。我当时的做法是先把这三件事分别写成独立模块。一个模块只负责选区管理提供 getRange、setRange、saveRange 这类接口一个模块只负责节点操作跟 DOM 标签打交道另一个模块负责格式化状态查询。这样虽然前期多分了几层但后面遇到 bug 时可以快速定位问题到底出在哪一环而不是在一个 500 行的函数里大海捞针。1.2 技术选型甩开 contenteditable 不现实但也不能裸用现在实现一个富文本编辑器绕不开的起点就是 contenteditable 属性。它让元素变成可编辑还自带一部分光标移动、输入、删除的能力。当年很多项目直接在这上面堆 document.execCommand加粗就调 execCommand(bold)插入列表就调 execCommand(insertUnorderedList)写起来确实快但问题也明显。execCommand 最大的坑是浏览器行为不统一。同一个 bold 命令在某些浏览器里会生成b某些会生成strong还有的会包一层span stylefont-weight: bold;。如果你需要把编辑结果稳定地存到后端、再稳定地渲染回来这种标签不统一就是灾难。我记得当时测试粘贴场景时Chrome 会自动给粘贴内容包上一层带样式属性的父节点Safari 又用另一套规则同一段内容在不同浏览器里走一遍DOM 结构能差出好几个版本。所以我的建议很明确contenteditable 本身可以留着用来获得免费的输入和光标能力但 execCommand 不能作为核心依赖格式操作必须自己接管。为什么要留 contenteditable 而不是干脆自己实现全部因为完全脱离它的编辑器意味着要自己处理 IME 输入法合成、组合字符、移动端键盘行为这工作量不是一个小团队能短时间完成的。contenteditable 提供了相对可靠的底层输入体验我们只要在它上面建立自己的格式化层就能兼顾稳定性和可控性。设计上相当于我们只借用它的“手和脚”但“大脑”换成自己的逻辑。1.3 内部结构与数据流编辑器内部我参考了经典的前后端分离思路但不至于做成完整文档模型那么重。核心是有两块一块是 DOM 渲染层也就是用户在页面上看到、实际编辑的内容另一块是操作记录层专门记录用户执行的每个格式化动作供撤销和重做使用。操作记录层不一定需要维护一份与 DOM 平行的完整 JSON 文档因为我们的需求还没到协同编辑那种程度。我选择在这个阶段直接对 DOM 做快照每次操作前记录一份关键节点的序列化结果操作后再记录一份撤销就是比较差异然后恢复。这样实现快但要注意不能整个编辑器内容都做快照否则大文档会卡顿。我当时的方案是按“块”拆分每次只对当前选中区域涉及的最小一级父容器做快照后面详细说。数据流我定成单向的工具栏按钮触发format(type)→ 拿到当前选区 → 根据选区边界计算要改动的节点集合 → 执行节点操作 → 重建快照 → 更新工具栏状态。整个链路里不允许组件直接去操作 DOM所有变更都必须走这一个入口。这样做的好处是排查问题时只需要检查这一个函数行为可预期。2. 核心实现原理选区、光标和 DOM差点把我劝退2.1 Selection 与 Range先搞清楚你摸到的是什么浏览器里“选中”这件事由两个对象配合表达。Selection 表示当前页面上的选中状态它可能包含多个 Range但正常情况下就是一个Range 表示一段连续的区域有 startContainer、startOffset、endContainer、endOffset 这些关键属性。startContainer 可能是文本节点也可能是元素节点如果落在文本节点上startOffset 就是字符偏移如果落在元素节点上startOffset 就是第几个子节点。理解这个差异是做所有格式操作的前提。举个具体例子用户在文本“今天天气不错”里选中了“天气”两个字。这时候 startContainer 指向保存整句话的文本节点startOffset 是 2endContainer 指向同一个文本节点endOffset 是 4。如果选区跨了两个段落startContainer 和 endContainer 就分别指向两个不同的段落节点startOffset 和 endOffset 各自对应段落里的子节点序号。处理格式时必须根据容器类型分别计算否则会出现把整个段落都加粗、而不是只加粗选中文字的错误。实际执行格式化前我最常做的一件事是记录选区范围然后操作完之后再恢复。因为任何 DOM 节点的新增、删除、包裹都会让现有 Range 失效浏览器的光标位置也可能跳到奇怪的地方。恢复选区的通用做法是操作前记录 startContainer、startOffset、endContainer、endOffset操作后重新从根节点出发找到相同位置并重新设置 Selection。如果中间有节点被替换还要先找出新节点和旧节点的对应关系。这块逻辑建议单独抽一个函数后面几乎每个功能都要调用。2.2 为什么不能把希望寄托在浏览器默认行为上很多人会觉得用户选中文字后点加粗我们只要往 DOM 里包一层strong就完事。但真实选区远没有这么听话。举个例子用户在同一个文本节点里选中了中间五个字你要把这五个字用strong包起来就必须先把原文本节点拆成三个前面的普通文本、中间五个字、后面的普通文本。拆完之后再把strong插到中间光这一步就要处理边界条件。如果选区起点和终点分别落在两个不同的段落里那就要对起点段、跨过的中间段、终点段分别做不同处理。选区中间可能还有已经加粗的文字、有链接、有列表项每一个都意味着不同的合并或拆开策略。浏览器默认行为不会帮你做这些事情。execCommand 之所以让人觉得好用只是因为它把这些复杂度内部消化了代价是行为不可控。一旦自己接管格式化就必须正确面对节点树的各种边界情况。我的原则是任何格式化操作都先归一化选区再展开到块级父节点然后在节点树上做局部变换最后再统一清理空标签。这个流程每步都有对应函数任何一步出问题都能立刻定位。切换到我们自己控制的另一个好处是可以自由决定输出结构。比如加粗我统一用strong并且不允许嵌套多余的strong如果一段文字里有一个层级的strong又要再加粗就直接跳过而不是再包一层。这种结构约定在生产环境中非常重要因为内容最终要存数据库、要跨平台渲染统一稳定的标签结构能省掉后期大量样式兼容问题。2.3 渲染与现实DOM 结构不等于文档结构你在 contenteditable 里看到的 DOM和用户在语义上理解的“文章”往往不是一回事。浏览器会把输入的文字、回车创建的段落、粘贴带来的临时节点全部混在一起。比如用户连续按几个回车DOM 里可能出现多个空段落pbr/p这些在语义上只是空行但如果不加处理保存内容时就会存下一堆无意义节点。再有从 Word 里粘贴内容DOM 会出现大量span style...和段落属性直接把这个 DOM 存下来将来在任何非浏览器环境里渲染都会出问题。编辑器内部因此需要一个“清理层”或者说“归一化层”作用是把任意混乱的 DOM 转成我们的规定结构。我添加了一个normalizeNode函数它负责处理几件事合并相邻的同类文本节点、移除空的 span 和内联样式、把b转成strong、把i转成em、把风格不一致的列表标签统一成ul/ol/li。每次格式化前先跑一遍局部归一化每次粘贴后跑一遍全量归一化。这样我们存进历史记录的内容基本就是我们想要的干净结构。值得一提的是这一步不能太激进否则会破坏浏览器正在进行的输入状态。尤其是中文输入法候选词还在合成时不能动它所在的文本节点。所以归一化要放在操作完成后的“空闲”阶段或者至少等 compositionend 事件触发之后再做。这个细节处理不好用户输入中文时就会频繁丢字或光标错乱。3. 从零到能用动手写一个编辑器内核3.1 初始化与基本输入管理第一步是把页面上的一个 div 变成可编辑容器。HTML 只需要contenteditabletrue但 JS 端要做的初始化事情不少。我建立了一个 Editor 类构造时传入容器、默认内容、工具栏按钮回调等配置。初始化时把默认内容解析成干净 DOM 后塞进容器同时往 window 上挂 selectionchange 和 keydown 监听还要监听容器自身的 input 事件。keydown 监听不是做大量拦截而是只处理少数必须控制的场景。比如 Tab 键默认会把焦点移走但编辑器里通常希望插入缩进或从列表中退出Enter 在列表项里希望继续列表而不是新起段落。我选择在这两个场景里拦截默认行为其他输入事件全交给浏览器。input 事件则用来触发内容变更后的“脏检查”比如判断当前编辑结果和上次快照是否一致如果不一致就记录历史快照并同步工具栏状态。这里有个实际经验很值得说不要用 DOMNodeInserted 这类同步变更事件它们已经废弃而且性能很差。用 input 事件 定时器或微任务组合就够我们只需要在用户停止输入后做一次“是否需要提交历史”的判断即可。同时要注意程序内部自己引发的 DOM 修改也会触发 input 事件所以历史记录模块要设计一个“记录中”标志防止把内部归一化误判为用户操作。3.2 实现加粗、斜体和下划线的正确姿势当工具栏的加粗按钮被点击时我做的第一件事不是找strong而是先拿到当前保存的 Range然后调用一个getSelectedNodes函数把选区涉及的所有内联节点找出来再逐一处理。这个函数的基本逻辑是从 endContainer 开始向上找到块级父节点然后遍历 Range 覆盖范围内所有节点收集文本节点和已存在的内联元素。只处理文本节点能简化很大一部分逻辑。处理一个文本节点时如果它整个都在选区范围内我就直接包或拆如果只有部分在范围内就要执行节点切分。切分本身是递归的思路是先判断起点偏移是不是 0不是就 splitText终点同理最后把中间剩下的文本节点交给 formatNode 去包。这样处理后的 DOM 结构非常干净不会有多余的零长度文本节点。这里还有一个容易被忽略的细节如果选区里已经有一部分是加粗的另一部分不是用户点加粗时已经加粗的部分应该保持不变未加粗的部分才被包裹。而非简单地把整个选区再套一层strong。所以我实现了一个判断函数先扫描选区所有内联节点如果“存在未加粗文本且存在加粗文本”只处理未加粗的那部分。这个交互细节对用户感受影响很大值得花时间做好。3.3 撤销重做编辑器里最容易被问崩溃的功能撤销和重做几乎是我整个项目里最磨人的部分。用户期望是点了五次加粗按五次撤销每一步都回到当时状态输入了一长串文字撤销一次至少要回到整段输入之前粘贴一段内容撤销一次要删掉整个粘贴结果。浏览器原生 undo 只存在于受控的 execCommand 场景里一旦我们自己操作 DOM原生撤销立即失效必须自己实现。我选择的是基于快照的撤销栈每次用户操作完成后把当前编辑器内容序列化成 HTML 存进一个数组撤销时弹出一个旧快照并恢复。这个方法实现简单但要解决两个问题。一是快照粒度如果每次 input 都存快照用户输入一个字就存一次整个撤销栈全是逐字快照体验反而很差。我的做法是引入“合并窗口”如果两次输入事件发生时间间隔小于 500ms就更新当前历史条目而不是新开一条。这样用户一口气输入一整段时只会生成一条历史记录撤销时能一次删完。二是快照内容直接对整个编辑容器做 innerHTML 序列化内容多时会卡顿。我优化成“按块快照”只对当前操作涉及的块级节点做序列化再根据起始块索引和结束块索引记录变更区域。撤销时先把历史里的相应块恢复再合并进当前文档。这个方案在大文档场景下性能明显更好。第三个问题是内部操作不要误入历史栈。归一化、程序初始化渲染这些变更都不该让用户能撤销回去。所以我封装了一个executeWithHistory入口只有通过这个入口触发的变更才会写入历史栈内部函数直接操作 DOM不经过历史记录。这个约束一开始就要定好否则后面到处都是撤销失效的 bug。3.4 列表、标题和粘贴清理列表是我实现过的格式化功能里最需要耐心的一种。给一个普通段落加无序列表块级层面把它改成li外面包上ul从列表继续按回车新起一行也要保持li形态。这些行为虽然繁琐但逻辑线性不像内联节点切分那么绕。我额外处理了“列表里按 Enter 退出”的规则在空列表项里按 Enter就把这个空项去列表化变回普通段落这样用户能把光标从列表里退出来。标题的实现比想象中的简单本质上就是块级标签替换。选中一段文字把它的块级父节点从p改成h2或h3然后做一次内部归一化确保标题里不残留无用的span。难点在回读状态光标落在标题上时工具栏要能正确显示当前是 H2。我的状态回读函数从光标位置向上遍历块级父节点把标签名作为状态值返回一次遍历就能拿到当前块的所有块级状态。粘贴清理是另一个高频需求。用户从文档或网页里复制内容粘贴进来时会带上各种样式和垃圾标签。我选择监听 paste 事件并禁止默认粘贴行为改为手动读取剪贴板的 text/html 和 text/plain。如果存在 text/html先放到一个临时 div 里跑一遍智能化清洗删除所有 style 和 class、把常见字体标签转成语义标签、移除空的嵌套标签。最后只把清洗干净的 HTML 通过insertHTML插入。如果用户明确只是想贴纯文本也可以读取 text/plain 并以纯文本方式插入。这里提醒一句剪贴板数据的读取是异步的粘贴事件里不能同步拿到内容必须用event.clipboardData.getData()或新的 Clipboard API。不同浏览器对这个 API 的权限策略有差异测试时要在真实页面环境里测不要在 iframe 或本地 file 协议下想当然。4. 常见问题速查表与排查经验4.1 光标位置越操作越乱症状点击工具栏加粗后光标跳到文档开头或者选中状态消失后续输入跑到错误位置。主要原因就是操作前后没有正确保存和恢复 Range。Range 里记录的是节点引用一旦节点被替换、删除或拆分原 Range 就失效。我之前提到过“记录容器节点和 offset操作后重新定位”的方式实操时要特别注意 offset 的差异如果 offset 原本指向元素节点内的第几个子节点而操作后该元素多了或少了子节点直接用原 offset 会偏移。我的做法是操作后调用一个restoreRangeFromPath函数它从根节点开始按“子节点路径”重新找到对应节点再重建 Range。子节点路径比直接记 offset 稳定得多。4.2 撤销到最后内容反而变乱症状连续撤销几次之后文档里出现重复段落或丢失样式。这通常是快照作用域和恢复算法不匹配导致的。按块快照最怕的是历史记录里的块结构和当前文档结构对不上比如用户把两个段落合并成了一个撤销时只需要一个块的快照但恢复时却要替换当前的两个块。我后来把快照恢复写成“先根据历史记录重建受影响区间的块列表再做差分对比而不是直接全部替换”虽然代码复杂一些但不会再出现重复段落。4.3 粘贴后格式全乱症状从 Word 复制的内容贴进来行间距巨大、字体颜色丢失、出现奇怪占位符。清洗逻辑不能只做表面替换。常见残留有 Word 生成的特殊标签、条件注释、带mso-前缀的样式、被错误嵌套在行内的块级标签。我维护了一个标签白名单不在白名单里的元素一律保留子节点、移除自身。对于 style 属性我没有试图解析每条 CSS 规则而是直接丢弃除非元素语义本身需要样式比如背景色这类用语义标签表达不了的功能。处理完这些后再跑一次normalizeNode基本能拿到干净结果。4.4 移动端输入法下光标消失症状在手机浏览器里输入中文候选词选中的瞬间光标不见了继续输入时内容插到错误位置。这是 IME 合成与 DOM 操作冲突的典型表现。当 compositionstart 触发后一直到 compositionend 之前浏览器处于输入法合成状态此时任何主动的 DOM 操作都可能打断合成。我的解决办法是在 composition 期间暂停所有内部格式化、归一化和快照更新用一个标志位isComposing锁住整个编辑器。等 compositionend 触发后再做一次轻量归一化并把快照更新推迟 300ms。这个方案在微信内置浏览器和 Safari 上都实测稳定。5. 编辑器还能怎么往下走我的后续规划写到这里核心功能已经是可用的状态但距离一个真正成熟的产品级编辑器还有不少路要走。我打算从两个方向继续扩展。第一个方向是内容格式的多样化。现在的 editor 支持基础行内格式和块级格式但还缺少图片、链接、分割线、引用块这些内容类型。插入图片的核心难点在上传和占位。我的计划是把图片节点包在一个块级容器里上传期间显示占位状态上传完成后替换为真实地址这个流程同样要走历史记录保证用户能撤销图片插入。链接相对简单但需要注意链接内不能再嵌套其他链接且点击链接时不应该触发编辑焦点跳转。第二个方向是编辑器结构的模块化。代码拆成独立包后可以分别发布成工具栏、核心编辑、清理工具三个模块。这样未来如果另一个项目只需要一个纯文本编辑器就可以不引入工具栏组件如果要做 Markdown 编辑器也可以复用底层的块级渲染和快照历史能力。模块化边界定好了后续扩展才不会动一发而牵全身。就我个人实际体验来说做一个 editor最大的收益不是最终交付了多完美的组件而是强迫自己把前端里最容易“蒙混过关”的 DOM 操作、选区管理、状态同步这些问题彻底想清楚。如果你也在做类似功能我建议给自己留足测试时间尤其是兼容性测试。同一个操作在 Chrome、Safari、微信内置浏览器、夸克这类 WebView 里的表现能差出好几个维度你至少要把核心流程在这几个环境里各跑一遍才敢说这个编辑器真的能上线。