我前前后后用坏过好几个“editor”方案,最开始图省事直接引第三方富文本组件,结果到了项目后期菜单打架、样式污染、光标乱跳,改起来比重新写一个还费劲。后来我索性自己做了一个叫 editor 的文本编辑小组件,不追求功能大而全,就为了能把内容编辑、结构化存储和样式隔离这三件事稳稳拿住。这篇就记录一下整个设计过程、踩过的坑,以及最后沉淀下来的可复现方案。如果你也正被各种编辑器方案折磨,或者准备从零趟一遍文本编辑器这块浑水,这篇文章应该能帮你省掉一大半的试错成本。
我把它定位成一个“够用且不乱来”的编辑器:核心提供段落、标题、加粗、斜体、列表、引用、代码块这几类基础能力,数据最终输出为结构化 JSON,渲染层再做一次清洗,保证进数据库之前是干净可控的。下面我把整个实现过程拆开讲。
1. 整体设计与技术选型
1.1 先想清楚 editor 要解决什么问题
开发之前我最先做的不是打开 IDE 写代码,而是把需求一条条列出来。很多编辑器项目做到一半改不动,根子就在前期没想明白边界。对我这个 editor 来说,需要解决的核心问题有三类:
第一是输入体验得自然。用户大部分时间不会把注意力放在编辑器的工具栏上,他们就是在框里打字、粘贴、换行、偶尔拖拽一下图片或选中一段文字加个样式。这里最偷懒也最稳妥的做法是直接用浏览器的 contenteditable,让原生行为去处理输入,然后我们再对结果做拦截和清洗。但直接裸用 contenteditable 会带来一个严肃问题,浏览器之间的行为差异大得惊人,同样的粘贴操作在 chrome 和 safari 里塞进来的 HTML 结构完全不同,更不要说旧版本浏览器对列表嵌套的迷之处理。所以“原生输入 + 自动纠偏”是必须的。
第二是数据不能脏。绝大多数文本编辑器翻车都是从脏数据开始的,用户在页面上 Ctrl+V 粘贴了一篇来自某个网站的文章,编辑器如果不做拦截,整段带内联样式、带 script 标签、带各种非法嵌套节点的 HTML 就进来了。前端看着还行,但这份数据一旦存进数据库,再被其他端消费时就是灾难。所以 editor 的数据出口必须是一份经过 schema 校验的 JSON,而不是随手抓来的 HTML 字符串。
第三是扩展要可预期。编辑器不可能一步到位,今天的方案做到标题加粗,明天就可能要加表格、加图片、加 Todo。如果从一开始就把数据结构写死,后续每次扩展都等于重构。因此我需要在内部定义一套稳定的节点模型,任何富文本能力都映射为对某类节点的增删改查,这样新功能加进来只是新增一种节点类型和处理规则,不会动编辑器内核。
这三类问题圈定了,选型思路就清晰了。editor 不需要去拼 CKEditor 那样的大而全,也不需要像飞书文档那样做一个重协同内核,它可以像一个“有自律能力的 div”——浏览器提供输入,我们负责把输入变成干净的结构。
1.2 技术路线对比:contenteditable 还是重写模型
做编辑器之前我对比过好几条路线,这里把关键结论写出来,方便后来人少走弯路。
第一条路线是纯 contenteditable + document.execCommand。这应该是很多人最开始接触的方式,优点是真的快,写几行代码就能让一段 HTML 可编辑,加粗、斜体、列表这类基础操作浏览器原生支持。但 execCommand 已经处于废弃状态,各家浏览器实现参差不齐,而且它操作的是 DOM 树,拿到手的数据质量非常依赖用户操作路径,用户一旦复制粘贴复杂内容,整棵树基本就是不可控状态。我自己的判断是:这种路线可以用于原型验证,不适合作为正式产品的底层依赖。
第二条路线是 contenteditable 负责输入 + 虚拟 DOM/数据模型驱动视图重渲染。这也是目前很多主流编辑器框架选用的方案,比如 Slate、Lexical 都是类似思路。它们的核心特点是:DOM 始终只是数据的表现层,用户输入、删除、粘贴这些操作触发的是数据模型层面的变更,然后由模型差异再去更新 DOM。这样数据结构和用户看到的东西始终是一一对应的,脏数据很难混进来。代价是事件的拦截难度变大,比如删除键到底删了哪个节点、光标怎么定位,都得自己掌控。一旦真正理解这套模型,后面做扩展就是乘线性收益,所以我个人非常推荐。
第三条路线是像 ProseMirror 那样做更底层的文档模型和事务机制。功能最强、严谨度最高,但学习曲线极其陡峭,对大多数中小型项目来说有点杀鸡用牛刀。我这次没有用它,核心原因是我需要保持项目逻辑尽量透明,出了问题能凭自己的代码定位,而不是在框架的抽象层面反复猜。
综合下来,editor 的实际选型是:底层使用 contenteditable 作为输入通道,上层自建一套轻量 JSON 数据模型,用户操作后统一走“事件捕获 → 模型更新 → 视图重绘”的闭环。这个思路结合了原生能力的高效和自建模型的干净,是中小型编辑器比较理想的一个平衡点。
1.3 轻量 JSON 数据模型为什么是核心
我在设计 editor 的时候,一开始就把所有编辑内容想成“一棵树”,而不是“一串 HTML”。这里面的差异非常关键。HTML 字符串在表现层很方便,浏览器自己就能解析渲染,但你要对它做结构化处理就麻烦了,比如想找“文档里第三段的第二个行内元素是不是加粗”,如果用 HTML 字符串就得做一次标签解析,再加上各种嵌套优先级的问题,解析逻辑很容易写成一坨补丁式的正则和遍历。
JSON 树模型就没有这个问题。我定义文档的基本单位是 Node,每个 Node 有明确的类型、属性和子节点,数据结构本身是语义化的。比如一个段落是 paragraph 类型,它下面有若干文本节点;一个加粗的行内片段是 mark 包裹的文本;一个列表项是 listItem,内部还有嵌套的 list。所有内容操作,无论是插入、删除、改样式还是重排顺序,最终都变成对这棵树节点的操作。数据结构稳定了,后面做存储、做跨端渲染、做协同协作,全都是顺水推舟的事。
模型层还有一个隐形的收益,就是“视图可以被随时重建”。在纯 contenteditable 时代,页面上的内容就是唯一事实来源,一旦用户在浏览器里手动改乱了 DOM,数据就跟着乱了。但在模型驱动下,DOM 只是模型的一个投影,我随时可以拿模型重新生成一份干净的 DOM,也可以把用户在页面上做的任意操作反向翻译成模型变更。这种双向可控性,是 editor 稳定不翻车的底气。
2. 节点模型与数据结构设计
2.1 节点类型设计:按照语义划分而不是按标签划分
在设计 schema 时,我特意避开了“按 HTML 标签建模”的陷阱。如果把节点类型设计成 p、span、strong、em 这种,模型层就会跟 DOM 高度耦合,业务语义反而丢失。比如同样是加粗,有的业务场景里它是强调重点,有的场景里它是品牌标识符,如果模型里只存 strong,后续想区分样式来源就很费劲。
editor 的做法是定义一套业务语义节点:paragraph 表示普通段落、heading 表示带层级的标题、quote 表示引用块、codeBlock 表示代码块、bulletList 和 orderedList 表示列表容器、listItem 表示列表项、image 表示图片节点。行内层面定义 text 文本节点,以及 bold、italic、underline、strike、code、link、mention 这些 mark 类型。Mark 和 Node 是两回事:Node 是树上的实体,而 Mark 是挂在 Node 上的修饰信息,一个文本节点可以同时有 bold 和 italic 两种 mark,这在 JSON 模型里就是两个数组元素的事,非常直观。
节点定义的伪代码大致是这样的:
{ "type": "doc", "children": [ { "type": "heading", "attrs": { "level": 2 }, "children": [ { "type": "text", "text": "这是二级标题" } ]}, { "type": "paragraph", "children": [ { "type": "text", "text": "这是普通段落," }, { "type": "text", "text": "这是加粗内容", "marks": ["bold"] } ]}, { "type": "bulletList", "children": [ { "type": "listItem", "children": [ { "type": "paragraph", "children": [ { "type": "text", "text": "列表项一" } ]} ]} ]} ] }这套模型的好处是,后端拿到 JSON 后不用理解任何 HTML 就能渲染到小程序、App 或者 PDF 上,只要按节点类型逐一适配组件就行。所以我强烈建议在项目初期就把“文档模型”和“渲染层”拆开,即便你当前只在 Web 上用,也要为未来的多端渲染留好余地。
2.2 Schema 校验与脏数据清洗
编辑器如果只是“能编辑”远不够,数据进库之前必须过一道安全阀。我在 editor 里加了一个 thin schema 校验层,所有即将被写入模型的内容,无论来自用户输入还是粘贴,都会被校验函数跑一遍。校验规则包括:类型白名单(不在 schema 里的节点一律丢弃或转换成普通段落)、属性白名单(loading、onclick、style 这类属性直接剥掉)、层级限制(比如 list 里不允许直接塞一个没有 listItem 包裹的 paragraph)、文本长度限制以及 Mark 作用域约束。
粘贴清洗是校验层工作量最大的地方。浏览器粘贴的 HTML 携带了大量非标准内容,如果直接让它进模型,后面所有内容都会带病运行。我实际用的清洗策略可以概括为“解析 → 过滤 → 映射”。先用浏览器原生能力把粘贴内容解析成 DOM,然后递归遍历节点,不在白名单的标签直接移除或降级处理,style 属性提取出少量安全的内联样式映射到模型属性(比如 text-align),然后走一遍 schema 校验,最后生成干净的节点树插入模型。这一步做完,至少能过滤掉百分之九十几的脏内容。
我在编辑器初始化、内容加载、粘贴操作、拖拽插入这几条入口处都统一加了校验,宁可多校验几次增加少量性能开销,也绝不放过任何一条可能把脏数据带进来的路径。这里其实是很多教程不会强调的点:编辑器的安全性和稳定性不是靠业务层自觉的,而是靠入口把关的完整性。
2.3 序列化方案:JSON 存储与 HTML 导出的双向路径
虽然内部模型是 JSON,但对外展示时绝大多数场景还需要 HTML。比如用户把内容复制到邮件、粘贴到公众号后台,或者给 SEO 用的首屏静态化,都是 HTML 格式。所以 editor 必须同时具备“JSON 转 HTML”和“HTML 转 JSON”两条路径。
JSON 转 HTML 相对简单,遍历节点树,每个类型对应一套渲染规则,把属性映射成 DOM 属性,加上样式类名,最后拼成 HTML 字符串。这步要注意的是转义问题——文本节点里的<、>、&如果不做转义,用户输入一个<script>字样就可能造成整个页面出问题。尽管我们保存的是 JSON,但凡是导出 HTML 的环节都必须做标准的 HTML 实体编码。
HTML 转 JSON 的比较复杂,基本就是一个简化版 DOM 解析器。我实现时约束了输入来源主要是自家导出的 HTML 和第三方粘贴的 HTML,前者结构规整,后者需要走清洗流程。整体思路是:先用 DOM 解析出树,再按 schema 规则“翻译”成节点。这里要特别留意列表嵌套和行内 mark 混排的情况,直接对文本做 split 是最容易出问题的点,我后来改成“逐个文本节点扫描、按 mark 栈合并”的方式,复杂度上来了,但数据正确性基本一次到位。
3. 编辑交互与核心功能实现
3.1 光标处理的原理与常见陷阱
做编辑器绕不开的光标,就是 DOM 里的 Selection 和 Range 对象。Range 的本质是一段连续的文档区域,包含起始位置和结束位置;Selection 则是用户在页面上选中的 Range 集合。在 model 驱动视图的模式下,光标同样需要映射到模型坐标:我的 editor 里定义了一个 Selection 对象,它记录的是文档节点坐标系里的 from 和 to,而不是 DOM 坐标。每次用户在页面上移动光标或点击,我都监听 selectionchange 事件,再把 DOM Range 转换成模型坐标,存到编辑器的 state 里。
光标最常出问题的场景是重绘后失焦,比如用户选了加粗,我更新模型然后重绘视图,如果重绘后不恢复光标,用户的输入焦点就丢了,体验直接崩掉。解决思路是:在重绘前记录当前模型坐标 selection,重绘后重新计算对应 DOM Range,再通过 Selection API 恢复。这里面有个细节,文本节点被拆分成多个 mark 片段后,同一个模型 offset 可能映射到不同的真实 DOM,所以在重绘后恢复光标一定要按“节点 path + 节点内 offset”来定位,不能简单地按字符索引来。这个坑我踩过很深,后来专门为它写了一个 getCursorByPath 的工具函数,所有光标操作统一走这个函数,才算彻底稳定。
3.2 输入拦截:如何让浏览器听话
浏览器在 contenteditable 里做的输入操作,默认直接作用到 DOM。但我们希望所有输入操作都先经过模型,这就需要对输入行为做拦截。拦截的两条主线是:composition 事件(中文输入法)和 beforeinput 事件。
中文输入是校验门槛中最头痛的一块:用户在输入法组合拼音时,编辑器内容会临时出现一段未确认的字符,如果此刻去触发模型更新或者做节点重排,轻则输入中断,重则拼音候选框直接消失。editor 的做法是:把整体输入过程当成一个“黑盒”,在 compositionstart 时进入输入态,所有 DOM 变更暂时不转发到模型;compositionend 时读取最终的 DOM 内容,一次性同步到模型。也就是说,输入法组词期间,DOM 短暂领先模型,但组词一结束,两者立刻一致。这种方案对中文用户极其友好,如果你的产品要面向中文场景,建议从一开始就重视 composition 事件。
beforeinput 是一个被严重低估的 API,它能在 DOM 被真正修改前告诉你即将发生的变更类型,比如 insertText、deleteContentBackward、insertFromPaste 等。editor 在拦截了 beforeinput 之后,只有必要情况才手动阻止默认行为,比如在代码块里按 Tab 键插入两个空格,这种场景浏览器默认行为是跳出焦点,我们必须 preventDefault 然后往模型里插入空格。其余的纯文本输入,比如敲一个字母,我倾向于放行,先让 DOM 更新,再通过 mutation 事件同步到模型。这样能最大程度保留浏览器的原生编辑手感,卡顿感也会小很多。
3.3 工具栏命令:操作模型而不是操作 DOM
工具栏或快捷键触发的命令,全部走统一 commander 接口。每条命令接收当前模型 state 和必要的参数,返回新的模型 state。比如执行“加粗”,第一步通过当前 selection 取出需要处理的范围,第二步遍历范围内的文本节点,给它们增加或移除 bold mark,第三步返回新树交给视图层渲染。
这里的关键原则是,任何时候都不要用document.queryCommandState或者操作 DOM 的 classList 来切换状态。因为 DOM 只是表现层,它的状态未必和模型一致。编辑器视图层的一切状态都应该从模型推导出来,工具栏按钮高不高亮、可用不可用,全部基于当前 selection 在模型上下文里的计算结果。我曾经见过有人为了方便直接读 DOM 样式做工具栏状态,一旦遇到撤销重做,UI 状态和模型就明显不一致,用户操作变得完全不可预测。所以这个原则不是洁癖,而是保命。
3.4 撤销重做的正确实现方式
撤销重做的难点在于,它必须覆盖所有模型变更,包括文本输入、格式调整、列表缩进、图片插入等。editor 的实现是把每一步模型变更都包装成一个事务(transaction),事务里记录了变更前后的文档快照。注意这里不能直接存整棵树的深拷贝快照,因为大型文档深拷贝的性能开销非常可观。我用的方案是增量补丁:记录事务内所有节点的插入、删除、属性变化和 mark 变化,撤销时按补丁反向应用,重做时按正向应用。
为了把连续输入合并成一次撤销记录,我设计了一个 debounce 机制:用户连续输入 800ms 内的所有文本变更合并成同一个事务。这样用户按 Ctrl+Z 时不会一次只删一个字,体验会和主流文档编辑器一致,不会出现撤销二十次才能把一段话删完的窘况。这个机制在实现上不复杂,但对体验的提升是决定性的。
4. 样式隔离与扩展机制
4.1 为什么编辑器必须做样式隔离
富文本编辑器最容易遭人诟病的,就是它对页面整体样式的“污染”。为了防止污染,editor 从两个方面下手:一是编辑区域内部的样式不得泄漏到外部,二是外部页面的样式不得干扰编辑区内容。
第一点相对好做:我把所有编辑器相关的样式都限定在.editor-root这个类名之下,并且用相对具体的选择器去覆盖。第二条外部样式干扰则很隐蔽,页面全局的p { margin: 0 }或者a { color: red }都可能在编辑器内部生效,导致同一份内容复制到另一个产品后版式大变。为了避免这一点,我在白名单类名基础上做了一组 reset 样式,把 margin、padding、line-height、color、font-size 等关键属性统一设置好,不让外部规则趁机偷家。
内容一旦是“所见即所得”,样式跑的就得稳。这句话听起来简单,但实际你会发现样式不隔离的编辑器,基本就等于一个随时会引爆的 CSS 大地雷。安全起见,我建议所有写编辑器样式的人都养成一个习惯:哪怕再简单的一条规则,也写上.editor-root前缀。这虽然会让代码啰嗦一些,但它能避免你深夜接到“线上页面样式莫名错乱”的报警电话。
4.2 插件机制:让新功能像搭积木
做了核心内核之后,我顺手设计了一套极简插件机制,用来承载各种非核心功能。插件注册时提供 plugin name、plugin schema(新增节点的定义)、plugin view(给工具栏加的按钮或其他 UI)、plugin commands(新增命令)以及 plugin handlers(事件监听)。内核里有个 plugin registry,所有插件注册后统一被内核加载,当遇到模型 update、selection change、paste、drop 等事件时,按注册顺序逐一分发给插件处理。
这套机制带来的直接好处是:新功能永远不用改内核代码。比如后来我加了一个“高亮块”扩展,只需要新增一个 highlightBlock 节点类型,写一个插入命令,再在工具栏加一个按钮即可。编辑器内核完全不用动,测试回归的焦虑感大大降低。如果你的编辑器需求已经比较复杂,比如有多种 block 类型和业务联动,强烈建议在一开始就预留插件入口,否则后期所有逻辑都堆在核心文件里,代码会迅速变成无人敢动的泥潭。
4.3 性能优化与大数据量文档渲染
很多人觉得文本编辑器能有多大性能问题,直到你粘了一篇几十万字的文章进去,然后按下 Backspace 键,页面直接卡死三秒。editor 的性能优化主要集中在三点:增量渲染、虚拟滚动和防抖。
编辑器在每次模型变更后,只更新真实变化的节点,而不是整棵树重绘。这个我在视图层用了类似“按 path 做 diff”的策略:模型变更会携带哪些节点的 path 被改动,视图层只重新渲染这些 path 对应的 DOM 块。对于超长列表这种结构,我采用了虚拟滚动的思路:固定视口高度,只渲染视口可见区间的 block,滚动时动态替换 DOM 内容。虚拟滚动和 contenteditable 天然有冲突,因为编辑器的光标需要落到真实 DOM 上,所以我把策略调整为“编辑态不启用虚拟滚动,但非编辑的阅读态启用”,在编辑器聚焦进入编辑态时自动切换为完整渲染,性能问题基本可控。
5. 常见问题与排查技巧实录
我在整个开发过程中记录了不少实际遇到的问题,整理成了一张速查表,基本涵盖了新上手会踩的大部分坑。
| 现象 | 可能原因 | 排查思路与解决 |
|---|---|---|
| 粘贴后样式全变乱 | 粘贴的 HTML 含大量内联样式 | 检查清洗函数是否过滤 style,补齐映射白名单 |
| 中文输入丢失后半截 | composition 事件处理不当 | 确认在 compositionend 后才同步模型,期间不要重绘 DOM |
| 光标在重绘后跳到开头 | 光标恢复逻辑用了 DOM 坐标而不是模型坐标 | 改用 path + offset 定位,重绘后重新计算 DOM Range |
| 工具栏高亮状态和内容不一致 | 直接从 DOM 反查状态 | 统一切换成从模型 state 推导 UI 状态 |
| 撤销重做一次删太多/太少 | 事务粒度不合理 | 调整合并机制里的时间窗口阈值,比如 800ms 合并一次 |
| 外部全局样式污染编辑区 | reset 样式没写全 | 在 editor-root 下补齐 reset,关键属性全部显式声明 |
| 图片选了没反应或者拖拽位置错乱 | 浏览器默认拖拽行为干扰模型 | 在 drop 事件里 preventDefault,手动算目标位置插入节点 |
| 性能卡顿严重 | 每次变更整树重渲染 | 改成按 path 的增量渲染,尽量缩小重绘范围 |
通过实际排查,我最想强调的一点是:编辑器绝大多数诡异问题,到最后都能追溯到“模型和视图状态不一致”这一根源。所以无论遇到什么毛病,第一件事永远是检查模型里的 JSON 是不是符合预期,如果模型是干净的,问题一定出在视图层或转换层。养成这个诊断习惯,排查效率能提升一大截。
还有一个很实用的小技巧:开发时在浏览器 console 里手动editor.getJSON()看内容树,很多看起来复杂的问题一眼就能看出来。我会给编辑器加一个 debug 模式,在 window 对象上暴露 editor 实例,方便随时检查模型的每一个状态。
6. 工具选型与纯前端依赖解析
6.1 为什么尽量不依赖重型编辑器框架
不止一次有朋友问我:“现在框架这么多,为什么还要自己动手写?”我的回答很简单:大部分业务场景根本不需要重型框架的全部能力。你需要的是在 Web 后台管理页里做一段支持基础格式的内容录入,但引入一个大框架意味着接受它的文档模型、事件机制、插件体系,以及可能需要额外的样式重置和性能代价。一旦产品需求出了偏差,比如你想要的格式框架A不支持,或者行为跟框架内置策略冲突,就得硬读框架源码去 hack,成本反而更高。
editor 的核心逻辑只依赖浏览器能力和几行工具代码,依赖面小,出问题时可排查范围小,上生产环境也放心。当然如果你的需求确实很重,比如需要大规模协同编辑、复杂表格嵌套、历史版本对比,那直接选用成熟框架反而更稳。我的建议是:在动手前先把项目需求边界列清楚,再决定“自研还是引入”,这比看到别人用了什么就追着用什么靠谱得多。
6.2 编辑器常见库对比与适用场景
为了让你心里有数,我整理了主流方案的一些真实对比。其中 CKEditor 是老牌选手,功能全面,配置项多,适合后台类快速集成,但体积重,定制深了容易陷入配置地狱。Slate 是 React 生态里比较流行的自建方案,模型灵活,你可以按需组装,但因为它不内置太多开箱即用的功能,开发成本很高。Lexical 是另一条轻量方向,性能和 API 设计都不错,目前生态还在成长期。ProseMirror 文档模型强大,适合需要做复杂结构文档的专业场景,但学习曲线非常陡峭。
| 库 | 优势 | 短板 | 适合场景 |
|---|---|---|---|
| CKEditor | 开箱即用、功能全 | 整体偏重、定制受限 | 后台管理系统、快速集成 |
| Slate | 模型自由、React 友好 | 需要自建很多模块 | 想做高度自定义编辑器 |
| Lexical | 轻量、性能好、API现代 | 生态相对年轻 | 轻量但可扩展产品 |
| ProseMirror | 模型强、严谨、可做协作 | 学习成本高、抽象复杂 | 专业文档、协同编辑类产品 |
有一点必须提醒:这几种方案都不是银弹,选任何库都会遇到自己独有的问题。我选择自建也不是因为框架不行,而是因为需求边界已经很明确,且我更看重长期可控性。如果你的团队时间紧张、需求量又不算特殊,开箱即用的成熟库绝对是更理性的选择。
6.3 自己实现时建议保留的最小依赖集
如果最终决定像 editor 一样自己实现,我建议保留的最小依赖集是:树节点模型(这个必须自己编码)、事件分发器(Node 本身就够用)、命令系统(内部约定即可)、DOM 变更监听(依赖浏览器 MutationObserver 能力)以及一个极简的序列化转换器。除此之外,渲染层可以使用虚拟 DOM 库或者直接操作真实 DOM,看团队技术栈定。
我不建议在一开始就引入太多类似 immutable、rxjs 之类的重型依赖,因为编辑器本身就是状态密集型的逻辑,外部状态管理库反而会把简单的事情复杂化。基本的数据不可变操作,完全可以用原生技巧配合少量工具函数实现。我后来实际写下来,整个核心逻辑也就几千行,放在一个中等规模的 class 文件里都能管理得过来,根本不需要引入成体系的外部状态层。保持核心的小和透明,是后续能长期维护的关键。
7. 项目后续扩展与个人心得
editor 做到这里,其实已经可以平稳支撑以富文本录入为核心的业务形态了。顺着这套模型,后续如果要继续扩展,我计划把 image 节点从“URL 引用”升级为“局部上传 + 缩略图 + 原图”的组合结构,同时在 model 层预留 attributes 扩展位,方便后续接入协同编辑时挂载版本向量和游标信息。另一个值得规划的方向是基于 JSON 模型做全文检索,因为数据足够结构化,搜索时可以直接对 paragraph 和 heading 建立索引,比全文扫 HTML 高效得多。
最后再分享一个我在无数深夜修 bug 后的体会:做编辑器最重要的品质就是“对结构有洁癖”。在动手前把文档模型定义严谨,把节点边界弄清楚,把入口校验做全,后面百分之七八十的坑都可以提前绕开。相反,如果一开始觉得“先跑起来再说”,后续补脏数据补样式污染的成本会远超你预期。
这个小编辑器目前已经在我自己的两个后台项目里平稳跑了一年多,期间依然会有小调整,但内核几乎没有大改过。每次改动,都要先问一遍自己:这次改动有没有绕过 schema、有没有绕过模型坐标、有没有打破“模型驱动视图”的原则?只要这三条底线还在,编辑器再复杂也翻不了天。希望这篇文章能帮你在做编辑器时少踩几个坑,也欢迎你去动手写一个属于自己的 editor,这种从内部理解编辑器的体验,真的是花钱买不到的。