那么为什么要从零设计实现新的富文本编辑器

发布时间:2026/7/2 18:43:30
那么为什么要从零设计实现新的富文本编辑器 设计Quill、结合React视图层的Draft、纯粹的编辑器引擎Slate、高度模块化的ProseMirror、开箱即用的TinyMCE/TipTap、集成协同解决方案的EtherPad等等。我也算是比较关注于各类富文本编辑器的实现包括在各个站点上的编辑器实现文章我也会看。但是我发现这其中极少有讲富文本编辑器的底层设计绝大多数都是讲的应用层例如如何使用编辑器引擎实现某某功能等。虽然这些应用层的实现本身也会有一定复杂性但是底层的设计却是更值得探讨的问题。此外我觉得富文本编辑器很类似于低代码的设计准确来说是No Code的一种实现。本质上低代码和富文本都是基于DSL的描述来操作DOM结构只不过富文本主要是通过键盘输入来操作DOM而无代码则是通过拖拽等方式来操作DOM我想这里应该是有些共通的设计思路。而我恰好前段时间都在专注于编辑器的应用层实现在具体实现的过程中也遇到了很多问题并且记录了相关文章。然而在应用层实现的过程中遇到了很多我个人觉得可以优化的地方特别是在数据结构层面上希望能够将我的一些想法应用出来。而具体来说主要有下面的几个原因:编辑器专栏#纸上得来终觉浅绝知此事要躬行。我的博客是从20年开始写的记录的内容很多基本上是想到什么就写什么毕竟是作为平时学习的记录。然后在24年写了比较多的富文本编辑器的文章主要是整理了平时遇到的问题以及解决方案集中在应用层的设计上例如:初探富文本之文档虚拟滚动初探富文本之OT协同算法...此外前段时间还研究了slate富文本编辑器相关的实现并且也给slate的仓库提过一些PR。还写了一些slate相关的文章并且还基于slate实现了一个文档编辑器同样也是比较关注于应用层的实现例如:WrapNode数据结构与操作变换Node节点与Path路径映射...在实现了诸多的应用层的功能之后发现整个编辑器有很多可以深入研究的地方。特别是有些实现看似很理所当然但是仔细研究起来会发现这其中有很多细节可以探究例如在DOM结构后常见的零宽字符、Mention节点的渲染等等这些内容都可以单独拿出来记录文章这其实就是我想从零实现编辑器的最重要原因。24年开始写了很多业务上的东西到了25年就略感题穷而目前我也没有别的擅长的方面由此写编辑器相关的内容是比较好的选择这样对于文章的选题也会简单些。不过虽然想的是深入写编辑器相关的内容但是在平时遇到问题的时候还是会记录下来例如最近有个基于immer配合OT-JSON实现的状态管理的想法可以实现。而对于编辑器的具体实现我目前的目标是实现可用的编辑器而不是兼容性非常好且功能完备的编辑器。主要是现在已经有非常多优秀的编辑器实现且有很多生态插件可以支持能够满足大部分的需求。目前我想实现的编辑器主要是兼容Chrome浏览器即可移动端的问题暂时不会考虑。不过如果能够将编辑器做得比较好的话自然可以去做兼容性适配。不过目前还是试探性地来设计并实现编辑器期间必然会遇到很多问题这些问题也将会成为专栏的主体内容。最开始的时候我是准备将编辑器完善后再开始撰写文章后来发现设计过程中的历史方案同样很有价值因此决定将设计过程也一并记录下来。如果将来真的能够将编辑器适用于生产环境那么这些文章就能够溯源到模块为什么这么设计想必也是极好的。整体来说我们不能一口吃成胖子但是一口一口吃却是可以的。深入编辑器#这部分是让我想起来一句话我们富文本编辑器是这样的你不写你不懂。编辑器是个非常注重细节的工程很多时候都需要深入研究浏览器的API例如document上的caretPositionFromPoint方法用以获取当前某个点所在的选区位置通常用于拖拽文本后的落点定位。除此之外还有很多选区相关的API例如Selection、Range等等这些都是编辑器实现的基础。那么深入编辑器底层就是很有意义的事情很多时候我们都需要跟浏览器打交道即使是对我们平时的业务开发也会有价值。在这里我想聊一下编辑器中的零宽字符以此例学习编辑器的细节设计这是一个非常有意思的话题类似这种内容就是不研究则不会关注到的有趣事情。零宽字符顾名思义是没有宽度的字符因此就很容易推断出这些字符在视觉上是不显示的。因此这些字符就可以作为不可见的占位内容实现特殊的效果。例如可以实现信息隐藏以此来实现水印的功能以及加密的信息分享等等某些小说站点会通过这种方式以及字形替换来追溯盗版。而在富文本编辑器中如果我们在开发者工具检查元素时可能会发现一些类似于ZeroWidthSpace;即U200B类似的字符这就是常见的零宽字符。例如在飞书文档的编辑器中我们通过([data-enter])就可以检查到其中存在的零宽字符。Copy!-- document.querySelectorAll([data-enter]) -- span>div contenteditabletrue divspan末尾零宽字符 Line 1/spanspan#8203;/span/div divspan末尾零宽字符 Line 2/spanspan#8203;/span/div divspan末尾纯文本 Line 1/span/div divspan末尾纯文本 Line 2/span/div /div那么在这个零宽字符如果只是渲染效果的话那么可能实际上起的作用并不很必要。但是在交互上这个效果却很有用例如此时我们有3行文本如果此时从第1行末尾选到第2行时并且按下Tab键那么此时这两行的内容就会缩进。那么如果没有这个显示效果此时进行缩进操作用户可能认为仅仅是选中了第2行但是实际上是选中了1/2两行文本。这样的话用户可能会以为是BUG而我们也实际接受过这个交互效果的反馈。Copy123| 4|x56也对各个在线文档实现进行了简单调研: 基于contenteditable实现的编辑器中飞书文档、早期EtherPad存在这个交互实现自绘选区的编辑器中钉钉文档存在这个实现Canvas引擎实现的编辑器中腾讯文档、Google Doc存在这个实现。在渲染效果部分零宽字符还有一个重要的作用是撑起行内容。当我们的行内容为空时此时这个行DOM结构的内容就是空这就导致此行的高度塌陷为0且无法放置光标。为了解决这个问题我们可以选择在行内容中插入零宽字符这样就可以撑起行内容且可以放置光标。当然使用br来撑起行高也是可以的使用这两种方案会各有优劣且兼容性方面也有所不同。Copydiv>div precode xxx /code/pre span>div contenteditable styleoutline: none div>.length // 2 \u200d // ‍数据结构设计#编辑器数据结构的设计是影响面非常广的事情无论是在维护编辑器的文本内容、块结构嵌套、序列化反序列化等还是平台应用层面上的diff算法、查找替换、协同算法等以及后端服务的数据转换、导出md/word/pdf、数据存储等都会涉及到编辑器的数据结构设计。通常来说基于JSON嵌套的数据结构来表达编辑器Model是很常见的例如Slate、ProseMirror、Lexical等等。以slate编辑器为例无论是数据结构还是选区的设计都尽可能倾向于HTML的设计因此可以存在诸多层级节点的嵌套。Copy[ { type: paragraph, children: [{ text: editable }], }, { type: ul, children: [ { type: li, children: [{ text: list }], }, ], }, ];通过线性的扁平结构来表达文档内容也是常见的实现方案例如Quill、EtherPad、Google Doc等等。以quill编辑器为例其内容上的数据结构表达不会存在嵌套当然本质上还是JSON结构而选区则采用了更精简的表达。Copy[ { insert: editable\n }, { insert: list\n, attributes: { list: bullet } }, ];当然还有很多特别的数据结构设计例如vscode/monaco的piece table数据结构。代码编辑器又何尝不是一种富文本编辑器毕竟其是可以支持代码高亮的功能的只不过类似piece table的结构我还没有太深入研究。在这里我希望能够以线性的数据结构来表达整个富文本结构虽然嵌套的结构能够更加直观地表达文档内容但是对于内容的操作起来会更加复杂特别是存在嵌套的内容时。以slate为例在0.50之前的版本API设计非常复杂需要比较大的理解成本虽然之后将其简化了不少:Copy// https://github.com/ianstormtaylor/slate/blob/6aace0/packages/slate/src/interfaces/operation.ts export type NodeOperation | InsertNodeOperation | MergeNodeOperation | MoveNodeOperation | RemoveNodeOperation | SetNodeOperation | SplitNodeOperation; export type TextOperation InsertTextOperation | RemoveTextOperation;从这里可以看出来slate对于文档内容的完整操作是需要9种类型的Op。而如果是基于线性结构的话我们就只需要三种类型的操作即可表达整个文档的操作。当然对于一些类似Move的操作则需要额外的选区Range计算处理相当于将计算成本移交到了应用层。Copy// https://github.com/WindRunnerMax/BlockKit/blob/c24b9e/packages/delta/src/delta/interface.ts export interface Op { // Only one property out of {insert, delete, retain} will be present insert?: string; delete?: number; retain?: number; attributes?: AttributeMap; }此外嵌套结构的normalize会变得很复杂且变更造成的时间复杂度也会变高特别是脏路径标记算法以及标记后的数据处理也需要由上述Op处理。还有用户操作导致的嵌套层级无法非常好地控制就要normalize过程时规范数据否则下面例如粘贴HTML时就可能会出现大量的数据嵌套。Copy[{ children: [{ children: [{ children: [{ children: [{ // ... text: content }] }] }] }] }]再举个更加实用的例子如果我们此时存在格式的嵌套内容。例如quote与list两种格式嵌套如果此时我们文档的数据结构是嵌套结构那么操作内容就会存在ul quote或者quote ul的两种情况正常情况下我们必须要设计规则来做normalize而扁平结构下属性全部写在attrs内不同操作造成的数据格式变更是完全幂等的。Copy// slate [{ type: quote, children: [{ type: ul, children: [{ text: text }] }], }, { type: ul, children: [{ type: quote, children: [{ text: text }] }], }] // quill [{ insert: text, attributes: { blockquote: true, list: bullet } }]扁平的数据结构在数据处理方面会存在优势而在视图层面上扁平的数据结构表达结构化的数据会是比较困难的例如表达代码块、表格等嵌套结构。但是这件事并非是不可行的例如Google Doc的复杂表格嵌套就是完全的线性结构这其中是存在很巧妙的设计在里边的在这里先不展开了。此外如果我们需要实现在线文档的编辑器的话在整个管理流程中可能会需要diff即取得两边数据结构的增删改。这种情况下扁平的数据结构能够更好地处理文本内容而JSON嵌套结构的数据则会麻烦很多。还有一些其他关于数据处理方面的周边应用整体复杂度都要提升不少。最后还是有协同相关的实现协同算法是富文本编辑器的可选模块。无论是基于OT的协同算法还是Op-Based CRDT的协同算法都是需要传输上述的op类型与数据的那么很显然9种操作的op类型会比3种操作的op类型更加复杂。OT.js: Text 数据类型ShareDB Rich-Text: Delta OT 数据类型ShareDB JSON0: JSON OT 数据类型ShareDB Slate: Slate OT 数据结构适配器YJS YText: Delta 数据类型实现YJS YMap/YArray: JSON 数据类型实现YJS Slate: Slate 数据结构适配器因此我希望能够以线性的数据结构来实现整个编辑器结构这样quill的delta就是非常好的选择。但是quill是自行实现的视图层结构并非是可以组合react等视图层的形式组合这些视图层的优势就是可以直接使用组件库样式来实现编辑器而避免了每个组件都需要自行实现。那么这里我准备基于quill的数据结构来从零实现富文本编辑器核心层并且像slate一样以此组合基本的视图层。方案选型#其实这里有个有趣的问题为什么用不到1mb的代码量就可以实现部分类似office word编辑器的能力是因为浏览器已经帮我们做了很多事情并通过API提供给开发者包括输入法处理、字体解析、排版引擎、视图渲染等等。因此我们是需要设计出如何跟浏览器交互的方案毕竟我们实际上是需要跟浏览器交互的。而对于富文本编辑器最经典的描述则是分为了三级:L0: 基于浏览器提供的ContentEditable实现富文本编辑使用浏览器的document.execCommand执行命令操作。 是作为早期轻量编辑器可以较短时间内快速完成开发但可定制的空间非常有限。L1: 同样基于浏览器提供的ContentEditable实现富文本编辑但数据驱动可以自定义数据模型与命令的执行。常见的实现有语雀、飞书文档等等可以满足绝大部分使用场景但无法突破浏览器自身的排版效果。 |L2: 基于Canvas自主实现排版引擎只依赖少量的浏览器API。常见的实现有Google Docs、腾讯文档等等具体实现需要完全由自己控制排版相当于使用画板而不是DOM来绘制富文本技术难度相当高。实际上在目前的开源产品中这三种类型的编辑器都有涉及到特别是绝大多数开源的都是L1类型的实现。而这其中还分化了不依赖ContentEditable却也不是完全自绘引擎而是依赖DOM呈现内容外加自绘选区的实现实际上倒是可以算作L1.5的级别。本着学习的目的自然要选择开源产品多的实现这样遇到问题可以更好地借鉴和分析相关内容。因此我同样打算选择基于ContentEditable实现数据驱动的标准MVC模型的富文本编辑器基于这种方式来与浏览器交互实现基本的富文本编辑能力。在此之前我们还是先了解一下基本的编辑器实现:ExecCommand#如果我们仅仅需要最基本的行内样式例如加粗、斜体、下划线等这可能在一些基本输入框中是足够的那么我们自然可以选择使用execCommand来实现。甚至直接基于execCommand的好处就是其体积会非常小例如 pell 的实现仅仅需要3.54KB的代码体积此外还有 react-contenteditable 等实现。我们也可以实现可以加粗的最小DEMOexecCommand命令可以在contenteditable元素中选区内的元素执行document.execCommand方法接受三个参数分别是命令名称、显示用户界面、命令参数。显示用户界面一般