☰
富文本编辑器开发核心概念:文档模型、选区与数据流转
2026/10/16 1:20:23 网站建设 项目流程

1. 富文本编辑器到底在编辑什么

很多人第一次接触富文本编辑器开发,脑子里冒出来的第一个念头就是“不就是个能加粗、能插图片的输入框吗”。我刚开始做这块的时候也这么想,结果真正动手写第一版的时候,光是一个光标位置的计算就卡了整整两天。富文本编辑器远不是给<textarea>加几个按钮那么简单,它本质上是在浏览器里重新实现一套“文档模型 + 渲染 + 交互”的微型系统。

这个系列我打算从零开始,把富文本编辑器开发里那些绕不开的基础概念一个一个拆开讲清楚。第一篇先不写代码,专门聊概念,因为这块如果理解偏了,后面写出来的东西一定是歪的。适合谁看?适合已经会写前端、想自己造一个编辑器轮子的开发者,也适合正在用现成编辑器但被各种诡异 bug 折磨、想搞明白底层到底怎么回事的人。哪怕你只是想搞清楚“为什么光标会乱跳”“为什么粘贴进来的格式全乱了”,理解这些基础概念之后,很多问题你自己就能定位了。

富文本编辑器要解决的核心问题,说白了就一句话:让用户在浏览器里像用 Word 一样编辑内容,同时产出的结果还能被程序稳定地读取、存储和再次编辑。这句话里藏着三个关键词——编辑、结构化、可持久化。编辑是交互层面的事,结构化是数据层面的事,可持久化是工程层面的事。三者互相牵制,任何一个设计没做好,另外两个都会跟着遭殃。

我见过太多项目,一开始图省事,直接拿contenteditable加document.execCommand就上了,demo 阶段跑得挺欢,一上生产环境就各种崩。问题不在于这两个 API 本身,而在于开发者没有建立起“文档模型”这个概念,把浏览器渲染出来的 DOM 当成了唯一的数据源。这是富文本开发里最典型的一个坑,后面我会详细展开。

2. 三种主流的编辑器架构路线

在动手之前,你得先想清楚自己要走哪条路。富文本编辑器发展到今天,主流架构大致可以分成三类,每一类的复杂度、可控性和适用场景都不一样。选错了路线,后面改起来基本等于重写。

2.1 基于浏览器原生能力的直接编辑方案

这是最“朴素”的一类,核心就是依赖contenteditable="true"让浏览器接管编辑行为,配合document.execCommand执行加粗、斜体、插入链接等操作。它的优点是上手极快,几十行代码就能跑出一个能用的编辑器,浏览器帮你处理了光标、选区、输入法这些最麻烦的事情。

但它的缺点同样致命。execCommand这个 API 的行为在不同浏览器里差异巨大,产出的 HTML 结构完全不可控,可能给你套一层<b>,也可能套<strong>,还可能带一堆内联样式。更麻烦的是,浏览器生成的 DOM 结构会随着编辑操作不断“漂移”,你很难预测最终会变成什么样。我实测过一个场景:连续对同一段文字执行加粗、取消加粗、再加粗,不同浏览器产出的 HTML 竟然有三种不同结果。

提示:如果你的编辑器只需要支持最基础的加粗、斜体、列表,且对产出的 HTML 结构没有严格要求,这条路可以走。但只要涉及协同编辑、格式规范化、自定义节点,趁早放弃。

2.2 基于自定义文档模型的受控编辑方案

这类方案的代表思路是:浏览器里的 DOM 只是渲染结果,真正的数据源是内存里的一棵自定义文档树。用户的每一次操作,先转换成对文档树的操作,文档树更新后再重新渲染到 DOM。这样数据流是单向的,可控性大大提升。

这条路的核心难点在于,你要自己接管原本浏览器帮你做的事情——光标位置怎么映射、输入法怎么处理、选区怎么同步。听起来很吓人,但一旦这套机制搭起来,后面做协同、做版本历史、做格式约束都会顺很多。现在市面上那些功能强大的编辑器,基本都走的是这条路。

它的代价是前期投入大。你得设计文档模型的数据结构,得实现模型到 DOM 的渲染器,得处理模型位置和 DOM 位置的双向映射。我个人的经验是,一个能稳定跑起来的最小可用版本,至少需要两到三周的专注开发。

2.3 混合方案:原生编辑加模型兜底

还有一类折中方案,平时用原生contenteditable处理输入,但在关键节点(比如失焦、粘贴、保存前)把 DOM 结构“清洗”成规范化的文档模型。这样既享受了原生编辑的便利,又保证了最终数据的可控性。

这种方案适合那些对实时性要求不高、但对最终数据质量有要求的场景。它的风险在于“清洗”这一步很难做到完美,尤其是遇到复杂的嵌套结构时,DOM 到模型的转换经常会有信息丢失。我踩过的坑是:用户粘贴了一段带表格的内容,清洗后表格结构全塌了,变成了纯文本。

三条路线的对比如下:

对比维度原生直接编辑自定义文档模型混合方案
上手难度低高中
数据可控性差好中
协同编辑支持几乎不可能天然支持困难
自定义节点扩展困难容易中等
适合场景简单评论框专业编辑器中等复杂度表单

3. 文档模型:编辑器的真正核心

不管你走哪条路线,只要想让编辑器“听话”,就必须在脑子里建立起文档模型的概念。这是整个富文本开发里最重要的一个基础概念,没有之一。

3.1 为什么不能把 DOM 当数据源

DOM 是给渲染用的,不是给存储用的。它有几个天生的毛病让它不适合当数据源。第一,DOM 结构太灵活了,同一个视觉效果可以有无数种 HTML 写法,比如加粗可以是<b>、<strong>、<span style="font-weight:bold">,你没法用一套逻辑统一处理。第二,DOM 里混着大量和内容无关的东西,比如光标的占位元素、浏览器的临时标记,这些都会污染数据。第三,DOM 操作是命令式的,你很难追踪“这次修改到底改了什么”,而协同编辑、撤销重做这些功能恰恰需要精确的变更描述。

我举个具体的例子你就明白了。假设用户在编辑器里输入了“你好”两个字,然后选中“好”字加粗。如果以 DOM 为数据源,你拿到的是类似<p>你<b>好</b></p>这样的结构。但如果用户是先加粗再输入,可能得到的是<p>你<b>好</b></p>,看起来一样,但中间经历的 DOM 操作序列完全不同。当你需要把这些操作同步给另一个用户时,DOM 层面的差异会让你抓狂。

3.2 文档模型的基本形态

一个典型的文档模型是一棵树,每个节点有明确的类型和属性。最顶层通常是文档节点,下面挂着段落、标题、列表、引用等块级节点,块级节点里面再挂文本节点、行内样式节点等。

用伪代码描述大概长这样:

// 文档模型的结构示意 { type: 'document', children: [ { type: 'paragraph', children: [ { type: 'text', content: '这是一段' }, { type: 'text', content: '加粗文字', marks: ['bold'] }, { type: 'text', content: '普通文字' } ] }, { type: 'heading', level: 2, children: [ { type: 'text', content: '这是一个二级标题' } ] } ] }

注意这里的关键设计:文本内容和格式标记是分离的。一段文字就是一段文字,它带不带加粗、带不带颜色,是通过marks这样的标记属性来描述的,而不是通过嵌套的标签。这样做的好处是,格式的增删变成了对标记数组的操作,非常清晰,也容易做合并和冲突处理。

3.3 位置系统:模型里的“坐标”

文档模型有了,接下来要解决一个关键问题:怎么描述“光标在哪里”“选区从哪里到哪里”。在 DOM 里,浏览器用节点加偏移量的方式表示位置,但模型里的节点和 DOM 节点不是一一对应的,所以你需要一套自己的位置系统。

常见的设计是用“路径 + 偏移量”来表示。路径就是从根节点到目标节点经过的子节点索引序列,偏移量是在目标节点内部的字符位置。比如路径[0, 1]偏移3,表示第一个段落的第二个子节点里,第三个字符之后的位置。

这套位置系统是后面所有操作的基础。插入文字、删除内容、应用格式、同步协同,全都要靠它来定位。我建议在设计阶段就把位置相关的操作单独抽出来,做成一套独立的工具函数,因为这部分逻辑会被反复调用,而且最容易出 bug。

注意:位置系统一定要考虑“边界情况”。比如光标在段落开头、段落结尾、空段落里、跨节点选区的起止点,这些边界如果没处理好,后面会出现大量“光标跳到奇怪地方”的问题。

4. 选区与光标:用户交互的入口

用户在编辑器里的所有操作,几乎都是从选区和光标开始的。理解浏览器原生的选区机制,是做好编辑器交互的前提。

4.1 浏览器原生选区的结构

浏览器提供了一套 Selection API,核心是window.getSelection()返回的 Selection 对象。一个选区由锚点(anchor)和焦点(focus)组成,锚点是用户开始选择的位置,焦点是结束的位置。如果两者相同,就是一个 collapsed 的光标;如果不同,就是一段选中区域。

每个位置由“节点 + 偏移量”构成。对于文本节点,偏移量是字符索引;对于元素节点,偏移量是子节点索引。这套机制本身不复杂,复杂的是它和你的文档模型之间的映射关系。

我实测下来,最容易出问题的地方是跨节点的选区。比如用户从第一个段落的中间选到第三个段落的中间,这时候锚点和焦点在不同的块级节点里,你需要把这段选区正确地转换成模型里的范围,中间跨越的所有节点都要处理到。如果中间还有列表、引用这种嵌套结构,转换逻辑会更绕。

4.2 光标位置的保存与恢复

一个非常常见的需求是:用户点了工具栏上的按钮,编辑器失焦,操作完成后要把光标恢复到原来的位置。如果直接操作 DOM,浏览器失焦后选区信息就丢了,你得在失焦前把位置存下来,操作完再恢复。

在自定义文档模型的方案里,这件事反而更好做,因为你的位置是基于模型的,不依赖 DOM 的实时状态。你只需要在失焦时把模型位置存下来,恢复时根据模型位置重新计算 DOM 位置并设置选区即可。

这里有个细节值得注意:恢复光标时要考虑内容是否发生了变化。如果操作过程中删除了光标所在位置的节点,直接恢复会导致位置越界。稳妥的做法是恢复前先校验位置的有效性,无效的话就退到最近的合法位置。

4.3 输入法处理的特殊性

中文、日文、韩文用户会用到输入法,输入法在编辑过程中会产生“组合中”的临时文本,这部分文本还没有最终确定。浏览器会触发compositionstart、compositionupdate、compositionend这几个事件。

处理输入法的难点在于,组合过程中的文本不应该触发你的模型更新逻辑,否则用户每敲一个拼音字母都会产生一次变更记录,撤销重做会变得一团糟。正确的做法是监听compositionstart时进入“组合模式”,期间暂停模型同步,等compositionend时再一次性把最终文本同步到模型。

我踩过的一个坑是:在组合模式下如果用户点了工具栏按钮,组合会被强制结束,这时候如果没处理好,会丢失正在输入的文本。解决办法是在组合模式下禁用工具栏,或者点击工具栏时先主动结束组合。

5. 数据流转:从输入到存储的完整链路

理解了文档模型和选区之后,我们来看一次完整的编辑操作是怎么流转的。把这个链路搞清楚,你就知道每个环节该在哪里做文章了。

5.1 一次输入操作的完整生命周期

假设用户在光标处输入了一个“好”字,整个流程大致是这样的:

  1. 浏览器触发beforeinput事件,事件对象里带着输入类型和要插入的内容。
  2. 你的编辑器拦截这个事件,阻止浏览器的默认行为(因为你要自己接管)。
  3. 根据当前光标位置,构造一个“插入文本”的操作。
  4. 把这个操作应用到文档模型上,模型更新。
  5. 根据模型的变化,计算出需要更新的 DOM 范围,做最小化的 DOM 更新。
  6. 更新完成后,重新计算光标位置并设置到 DOM 上。

这套流程走下来,数据流是单向的:事件 → 操作 → 模型 → DOM → 光标。每一步都有明确的输入和输出,出了问题也容易定位是哪一环的锅。

对比一下原生方案:浏览器直接改 DOM,你只能事后去读 DOM 反推发生了什么。这就是为什么原生方案在复杂场景下会失控——你永远在“猜”浏览器做了什么。

5.2 操作与变更记录

把用户行为抽象成“操作”是自定义模型方案的精髓。一个操作通常包含类型(插入、删除、格式化等)、位置、内容这几个要素。这样做的好处是,操作本身是可序列化的,你可以把它存起来做撤销重做,也可以发给其他协作者做同步。

撤销重做就是操作栈的应用:每次操作入栈,撤销时执行反向操作,重做时再执行原操作。听起来简单,但实际做的时候要注意操作的合并。比如用户连续输入了十个字,如果每个字都是一个独立操作,撤销一次只删一个字,体验会很差。通常的做法是把短时间内同类型的连续操作合并成一个。

5.3 渲染与更新的性能考量

模型更新后要同步到 DOM,这里有个性能问题:如果每次都全量重新渲染整个文档,文档一大就会卡。所以需要做差异比对,只更新变化的部分。

差异比对的粒度可以粗可以细。粗粒度的做法是以块级节点为单位,哪个块变了就重渲染哪个块。细粒度的做法是精确到文本节点,只更新变化的文本。粗粒度实现简单但性能一般,细粒度性能好但实现复杂。我的建议是先用粗粒度跑通,等遇到性能瓶颈再优化到细粒度。

提示:做 DOM 更新时,尽量复用已有的 DOM 节点,只改需要改的属性。频繁地销毁和重建节点会导致光标丢失、输入法中断等一系列问题。

6. 格式系统:让样式可控可预测

格式是富文本编辑器区别于纯文本编辑器的核心功能,也是最容易做乱的部分。一个设计良好的格式系统,应该让格式的表示、应用、查询都有清晰的规则。

6.1 行内格式与块级格式的区分

格式大致分两类。行内格式作用于文字级别,比如加粗、斜体、颜色、字号,它们可以在一段文字内部局部生效。块级格式作用于段落级别,比如标题级别、对齐方式、列表类型,它们影响整个块。

这两类格式的处理逻辑完全不同。行内格式需要处理“部分选中”的情况,比如用户选中一段文字的一部分加粗,你得把这段文字拆成三段,中间那段带加粗标记。块级格式则简单得多,直接改块的属性就行。

我建议在模型设计阶段就把这两类格式分开处理,不要混在一起。混在一起的话,后面做格式查询和格式清除会非常痛苦。

6.2 格式的叠加与冲突处理

当多种格式叠加时,冲突是难免的。比如一段文字既有加粗又有取消加粗的标记,或者既有红色又有蓝色。这时候需要一套明确的优先级规则。

常见的做法是“后设置的覆盖先设置的”,同时提供“清除格式”的操作让用户一键回到默认状态。对于互斥的格式(比如对齐方式只能有一种),应用新格式时自动移除旧的同类格式。

这里有个细节:格式的存储方式决定了冲突处理的难度。如果格式是存在文本节点的marks数组里,处理冲突就是操作数组;如果格式是嵌套的标签结构,处理冲突就要做树的遍历和重构,复杂度高很多。这也是我推荐用扁平化标记而不是嵌套标签的原因之一。

6.3 格式的查询与状态同步

工具栏上的按钮需要显示当前光标位置的格式状态,比如光标在加粗文字里,加粗按钮就要高亮。这个功能看似简单,实现起来却有不少坑。

难点在于光标可能处于“格式边界”上。比如光标在加粗文字和普通文字的交界处,它到底算加粗还是不加粗?不同的编辑器有不同的处理策略,有的取前面的格式,有的取后面的,有的干脆两个状态都显示。你需要根据产品需求定一个规则,然后保持一致。

另外,当选区跨越了多种格式时,工具栏状态怎么显示也是个问题。常见做法是:如果选区内所有文字都是加粗,按钮高亮;如果只有部分加粗,按钮显示“半选中”状态;如果全都不加粗,按钮不高亮。

7. 常见问题与排查思路

概念讲完了,最后分享几个我在实际开发中反复遇到的问题和排查思路。这些问题几乎每个做富文本的人都会碰到,提前知道能省不少时间。

7.1 光标乱跳的几种典型原因

光标乱跳是富文本开发里最高频的问题,没有之一。根据我的经验,原因主要有这么几类:

第一类是 DOM 更新时把光标所在的节点重建了。浏览器发现原来的节点没了,就把光标扔到文档开头或者某个默认位置。解决办法是更新 DOM 时尽量复用节点,实在要重建,重建后手动恢复光标。

第二类是模型位置和 DOM 位置的映射算错了。这种情况通常出现在嵌套结构或者空节点附近。排查方法是把模型位置和 DOM 位置都打印出来对比,看在哪一步开始对不上。

第三类是输入法组合过程中触发了 DOM 更新。组合中的文本还没确定,这时候动 DOM 会打断输入法,导致光标异常。解决办法就是前面说的,组合模式下暂停同步。

7.2 粘贴内容格式混乱的处理

用户从网页或者 Word 里粘贴内容,带进来一堆乱七八糟的标签和样式,这是另一个高频问题。处理思路是:拦截粘贴事件,拿到剪贴板里的 HTML,用一套白名单规则过滤,只保留你支持的标签和属性,然后转换成你的文档模型。

白名单规则要根据你的编辑器支持的格式来定。比如你只支持加粗、斜体、链接,那白名单里就只留这几个,其他全部转成纯文本。过滤的时候要注意处理嵌套结构,比如一个<div>里套了<span>又套了<b>,你得把有效的格式提取出来,无效的层级拍平。

我踩过的坑是:有些来源的 HTML 里带着大量内联样式,比如style="font-weight:700;color:rgb(0,0,0)",你得把这些样式解析成你的格式标记,而不是直接丢弃。否则用户粘贴过来发现格式全没了,体验很差。

7.3 撤销重做的边界情况

撤销重做看起来简单,实际做起来边界情况很多。比如:撤销到最初状态后再撤销会怎样?重做过程中用户输入了新内容,重做栈要不要清空?连续输入的操作怎么合并?

我的处理策略是:撤销栈空了就禁用撤销按钮,重做栈在用户产生新操作时清空,连续输入的合并在时间间隔超过阈值或者操作类型变化时断开。这些规则没有标准答案,关键是保持行为一致,让用户能形成稳定的预期。

问题现象可能原因排查方向
光标跳到开头DOM 节点被重建检查更新逻辑是否复用节点
输入法输入丢字组合模式未正确处理检查 composition 事件监听
粘贴后格式全乱未做 HTML 过滤检查粘贴拦截和白名单
撤销后内容错位操作记录不完整检查操作是否记录了完整上下文
工具栏状态不同步格式查询逻辑有误检查边界位置的格式判定

7.4 性能问题的定位方法

编辑器卡顿通常出现在文档内容较多的时候。定位性能问题,我一般用 Chrome 的 Performance 面板录一段操作,看火焰图里哪个函数占用时间最长。常见的热点有这么几个:全量重新渲染、频繁的 DOM 查询、深拷贝大对象、格式查询时遍历整棵树。

优化的方向也很明确:把全量渲染改成增量更新,把频繁的 DOM 查询结果缓存起来,避免不必要的深拷贝,格式查询时只遍历选区涉及的节点而不是整棵树。这些优化做完,一般文档规模下的性能都能接受。

我个人在实际开发中的体会是,富文本编辑器这个领域,概念清晰比代码技巧重要得多。文档模型、位置系统、操作抽象这几个核心概念如果理解到位了,具体写代码的时候你会知道每一步在干什么、为什么要这么干。反过来,如果概念是模糊的,你就会陷入“试一下这个 API 行不行”“换个写法看看能不能跑”的泥潭,写出来的代码也没法维护。下一篇我会具体讲文档模型的数据结构设计和实现,把今天这些概念落到代码上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询