资讯动态

富文本编辑器代码块实战:内容模型、踩坑与测试用例

发布时间:2026/10/15 1:50:54 来源:尧图企业网站定制
富文本编辑器里加一个代码块按钮听起来就是往工具栏上塞个图标的事。结果我们测试同事拿着半屏的缺陷截图来找我时我才意识到代码块这三个字在一套成熟富文本里到底意味着什么Tab键被吃了、多行缩进变成折叠空格、从外部页面粘进来的代码行首空格全部蒸发、还有一次保存后刷新整块代码居然退化成了一段普通段落。这篇文章就把这次给某后台内容编辑器做代码块支持的全过程拆开讲从内容模型设计、踩坑现场、高亮着色接入到围绕代码块功能写的测试用例设计和一次完整的事故排查链路。适合正在做富文本功能、或者准备评估编辑器方案的同学参考。1. 代码块在富文本里从来不是多一个块级元素那么简单先说清楚一个最容易被低估的点富文本编辑器的核心不是看起来是 HTML 就行而是内容模型、编辑器 DOM 和浏览器原生 DOM 三层结构之间的同步。普通段落里的文字本质上是一个带有加粗、斜体、链接等标记的文本流浏览器也好、编辑器内核也好都可以对空白字符做一定的规范化处理——多敲几个空格、多按几次退格都不会造成信息损失。但代码块不一样它里面每一个空格、每一个换行都是语义的一部分。我用一个土气的类比给新同学解释过普通段落像 Word 文档排版丢了最多难看代码块像是刻着字的石碑空格就是字本身少了两个缩进代码就废了。所以代码块不能走普通文本那套宽松清洗路线必须从内容模型层面当作一种独立的块级节点对待。这带来的第一个连锁反应就是数据模型得改。如果原来编辑器里所有内容都是一段段的 paragraph那导入一个代码块就得让模型层认识code-block这种节点类型而不是继续用带等宽字体的段落来糊弄。我在这次改动里做了这样一个最小模型{ type: code-block, attrs: { language: javascript }, content: [ { type: text, text: const a 1;\n const b 2;\nconsole.log(a b); } ] }也就是说代码块的文本内容整体作为单个文本节点的原始内容存储语言信息放在属性里。这里有个设计上的取舍为什么不把每一行拆成一个独立的 block 节点某些编辑器会把代码块实现成每行一个段落。那是为了兼容老的内容模型代价是你永远无法可靠区分这是用户故意换行还是这是编辑器自动分的段。尤其当你支持跨行选择、在行首按退格合并下一行时行模型的维护成本会成倍上涨。所以我在这次方案里坚持用整块文本原始换行符的存储方式。内容模型的下一步是解决块级节点与光标控制之间的耦合。浏览器原生的 contenteditable 里按 Tab 会把焦点移出编辑区在代码块里按回车默认行为会自动生成一个新的 div 或者 p这些都要在 keydown 阶段拦截。我列一下这次实际遇到的光标/按键问题Tab 键需要阻止默认行为改为插入两个或四个空格同时把光标推进相应距离Enter 键在代码块内应当插入一个换行符而不是拆分、创建新块全选后删除用户 CtrlA 后按 Backspace期望的是把整个代码块内容清空并保留代码块本身而不是删光内容顺带把代码块结构也删了光标跨界移动从普通段落把光标移入代码块第一行选区起点/终点要重新计算不能把普通段落里的格式标记带进去这套逻辑如果用直接操作 contenteditable的祖传方案来做就要自己管理所有光标和浏览器的行为差异如果用内容模型驱动的那类编辑器内核来做拦截这些行为只是往 keymap 里注册几条规则。所以我的第一条建议是做代码块之前先回去翻翻自己编辑器内核里对 block 节点的抽象程度、对 keydown 的接管能力这决定了后面 80% 工作量的大小。这些问题不是硬刚浏览器 API能解决的而是设计层面的事。2. 这次集成代码块我翻车的五个高频场景真正把代码块放进编辑器开始联调之后测试同事一个接一个地报缺陷。我把其中五个高频场景整理一下基本能覆盖绝大多数项目的踩坑面。每一条都从我实际复现的现象说起再给根因和落地解法。场景一从外部页面复制代码缩进全部丢失从代码托管平台或者别人博客里复制一段代码粘进编辑器看着没问题等保存后重新打开行首缩进没了代码全贴到左边。根因是这种粘贴的内容通常带着 HTML 的样式结构和各种 span编辑器默认的粘贴处理器会把内容清洗成自己的结构。清洗规则里如果有合并连续空格或者移除多余空白字符这类逻辑就正好打在代码块的软肋上。解法是在粘贴进来的那一瞬间做路由如果光标当前位于代码块内部粘贴的内容一律按纯文本接收且接收后不做空白规范化原封不动塞进文本节点。部分浏览器在读取剪贴板时还会把代码里的转成lt;这些都是正常现象关键是不要在这个阶段去做任何看起来更美观的处理。场景二Tab 键换了焦点或者插入了空格但光标乱跳前者好理解浏览器默认行为后者是我自己实现 Tab 插入空格时踩的坑——我只插入了文本没有重新计算光标位置。正确的做法是把光标应当停留的位置算清楚Tab 键被拦截后在光标处插入定长的空格串然后把选区折叠到原位置加空格数量的偏移量上。editor.on(keydown, (event) { if (event.key Tab isInsideCodeBlock()) { event.preventDefault(); const spaces .repeat(tabSize); insertTextAtCursor(spaces); moveCursorForward(spaces.length); } });场景三Enter 键把代码块拆成了两半这种问题多发于把代码块当作若干段落实现的编辑器。用户在第一行中间敲回车编辑器把代码块拆成了两个 code-block然后把光标放在两个块之间。这等于把原本连续的代码物理打断了。解决思路上文也说过代码块内回车只能等于在文本节点里插入一个\n。场景四保存后代码块的标记丢失变成纯文本段落这个比较隐蔽问题出在序列化阶段。代码块里的内容如果包含/code这种字符串或者语言标记没有被你的 HTML 转换器识别序列化后整个节点可能被降级成普通段落。我当时的排查链路如下先看编辑态内容模型JSON 里 code-block 节点还在内容完整再看序列化后的 HTML代码块变成了div包裹的文本最后定位我们的 HTML 解析器遇到pre里嵌套了特殊字符的code时回退逻辑触发了节点类型降级修复方式是在序列化器里给代码块加一个不可见的标识属性并且单独处理 pre 节点里的原始文本不经过常规的去除空白字符清理。场景五从编辑器复制代码出去粘贴到别处格式错乱这个方向平时不容易想到但用户真会这么用。从富文本里复制代码粘贴到 Markdown 编辑器里可能带进来一堆 span 包裹的样式。解决方式是复制时让系统对选中区域先判断是否全部处于代码块内如果是则往剪贴板写一份纯文本版本同时保留一份 HTML 版本。这样用户在别处粘贴时拿到的是干净的代码原文。这五个场景有个共同特点问题不在编辑器的视觉层而在输入输出管线的边界上。粘贴、复制、回车、Tab每一个都是内容从外部进入模型或者内容从模型中出去的通道口必须挨个设计拦截规则而不是指望浏览器默认行为能贴合代码块语义。3. 选型考察清单怎么判断一个编辑器能否扛住代码块如果你不是像我一样在现有编辑器上改造而是正在评估第三方方案那么别只看 demo 里代码高亮多好看。高亮是渲染层的事真正决定生死的是内容模型对代码块的支持程度。我给你一条相对完整的考察清单照着看基本不会踩大坑。第一项代码块在内容模型中是不是一等公民。把编辑器的文档结构导出来看一眼如果一个代码块在模型里是一个单独节点、内部只有原始文本、语言元数据存在 attrs 里——这是好迹象。如果代码块是由一堆段落拼成、格式靠 CSS 类名撑着——请谨慎后面 Tab、全选、合并行的坑会一个接一个。第二项粘贴进代码块时它是否保留原始空白。这个测试五秒钟就能做往代码块里粘贴一段行首有两个空格加一个制表符的代码然后保存、重新打开对比原文。行首空格消失的直接淘汰。这个不能靠视觉确认一定要做一次读出来对比字符串的验证。第三项是否拦截了 Tab 和 Enter 的默认行为。分别测试在代码块内按 Tab焦点是否还留在编辑器内按 Enter光标下面是否还是同一个代码块这两个基础交互做不好其它功能都不用谈。第四项内容往返是否无损round-trip fidelity。能力核心是这么一句话从模型层把代码块序列化成 HTML再把这个 HTML 解析回模型内容是否逐字节一致。如果编辑器支持导入 HTML需要重点测这个用包含precode的 HTML 导入然后导出比较前后差异。很多编辑器导出的 HTML 自己解析一遍就变形了。第五项扩展成本是否可控。这一点最容易被忽略。无论你选哪家几乎都不可能在原生状态直接满足你的业务场景——你至少要加一个语言选择器、要自定义高亮库的样式、要处理代码块与折叠面板之类的自定义块的嵌套关系。所以要看它的插件系统暴露了什么接口能不能注册自定义节点能不能覆盖粘贴处理器能不能注册 keymap如果这三样都给你开放了代码块的改动基本是顺水推舟如果插件系统只是让你写点 CSS那代码块的很多事情都得魔改源码。我把市面上常见的三类编辑器血统按代码块友好度做个简单对比编辑器类型代码块内容模型Tab/Enter 接管能力粘贴空白保留扩展代码块成本DOM 直接操作型自己维护 contenteditable差HTML 即模型块结构容易漂移全手动工作量大难保证高文档模型驱动型JSON 文档为核心序列化/解析统一好代码块作为原子节点有行为注册机制可以精确控制中低Markdown 桥接型编辑态偏文本中视实现而定取决于解析规则中中我当时选型时还额外做了一次恶意测试从 Word 里复制一段带行号的代码粘进去。Word 会输出极其复杂的嵌套表格结构很多编辑器在这一步直接崩溃或者把内容变成一团乱 div。这个测试能帮你快速排除掉底层 DOM 处理过于脆弱的方案。选型不能只看演示里代码高亮配色好不好看要盯着输入输出边界看。4. 代码高亮着色与编辑状态的同步两条接入路径实测代码块有了内容模型和键盘行为之后下一个绕不开的模块就是代码高亮着色。这里有一个特别多人踩的坑把高亮库直接作用在编辑区域的 DOM 上结果光标经常丢选区范围也经常算错。原因很简单高亮库会把一行文本拆成很多个 span 着色片段编辑器的光标计算依赖的是稳定的文本节点结构你一边输入一边拆节点光标位置当然会飘。我实际比较过两条接入路径直接说结论。路径一编辑态实时着色。内容在编辑过程中就做语法高亮。优点显而易见用户所见即所得。缺点也很明显——每次输入都需要重新对整块代码做 tokenize性能上会让输入有延迟感更重要的是光标和选区状态会被高亮库重排 DOM 打断。我的处理办法是在高亮更新之前记录当前光标所在的位置高亮渲染完成之后再把光标按最近文本偏移量恢复回去。原理是常见做法// 高亮前记录 const savedOffset getCaretOffsetInCodeBlock(); // 执行高亮渲染设置 innerHTML、着色令牌 highlightCodeBlockElement(codeBlockEl); // 高亮后恢复 restoreCaretFromOffset(savedOffset);但这个方法有个隐蔽前提tokenize 过程不能改变文本内容本身只能给文本包 span或者给 span 加 class。一旦高亮库会修改文本内容例如把连续空格合并了光标恢复计算就会失误。所以选库的时候要专门挑那些只输出着色标记、不改变字符序列的类型。路径二渲染态着色存储层只存纯文本和语言标签。编辑器内部使用纯文本节点存储代码内容不做实时高亮等到最终渲染展示时再用高亮库对代码文本做一次 tokenize 并输出到展示页面。这种做法的好处是内容模型的纯净性编辑态高亮的所有副作用都被隔离在渲染层之外。我这次最终选了这条路径存储模型就长前面说的那样只有text和language。决策逻辑是这么想的富文本的本质是所见即所得但如果代码块的高度足够高实时高亮带来的性能成本和光标抖动会明显影响编辑体验反而得不偿失。相比之下渲染态高亮让编辑输入链路保持最简单的形态展示效果也不差。唯一的代价是编辑态预览与最终渲染效果有细微差异实际体验下来差异基本可以接受。无论选择哪条路径都需要处理着色库的体积问题。代码语言那么多不可能全部打进编辑器主包里。我这里是按需加载的用户在语言选择器里选中某语言后才加载对应语言的 tokenizer。没有匹配着色规则的代码块也要能正常展示背景色与等宽字体作为兜底保证功能可用性永远优先于高亮美观度。高亮接入还有一个容易被忽略的细节语言标签与内容同步。选择器里语言变了模型的 attrs 要跟着变同时高亮结果也要刷新。如果这一层没有处理好会出现下拉选的是 Python代码块高亮却是 JavaScript 配色的诡异现象。所以我在语言切换的处理逻辑里强制做了一次语言变化 - 模型更新 - 回调渲染器重新着色的三步联动。这个功能看着小却是测试最容易遗漏、用户最容易感知的 bug 聚集区。5. 给代码块写测试用例覆盖不了这些边界迟早线上翻车标题里既然带着测试这块我必须展开说说。代码块功能上线之前我们测试同事列了一堆用例我按功能域整理成了一个矩阵。别嫌多这些都是真实场景的浓缩。第一域代码块的基本生命周期。测试场景预期表现从工具栏点击插入代码块编辑区出现空代码块光标定位在块内第一行输入多行代码后按 Enter新增行仍在同一代码块内不存在分块情况在代码块内按 Tab插入固定数量的空格焦点不离开编辑器语言选择器切换语言模型 attrs 更新渲染高亮随之变更全选代码块后按 Backspace代码块保留内容清空光标仍停留在块内第二域内容无损与空白保留。这一部分是代码块保真性的关键我建议把它当成字符串级断言来写而不是视觉级断言。const rawCode function foo() {\n const a 1;\n return a;\n}; insertCodeBlockWithText(rawCode); // 模拟一次保存并重新加载 const reloadedContent saveAndReload(); expect(reloadedContent).toContain(rawCode);实际执行这条用例的时候我抓到过两个问题。一是行首四个空格在某个浏览器下被压成了一个二是从外部粘贴带\t的代码时制表符被转成了普通空格困扰了很多用户。后来统一规定代码块内的文本以原始字符序列作为标准不做任何默认替换。测试用例里甚至加了特殊字符载荷divnbsp;## 注释这类串确保序列化、反序列化过程中不出现转义和实体化导致的变形。第三域编辑操作的边界行为。这一块主要瞄准光标位置计算和选区操作光标在代码块某行中间按 Backspace是否只删该位置前面的字符而不是把上一行拉上来合并光标在代码块首行行首按 Backspace是否能正常删除该位置上一个字符不触发块删除跨行选择代码块内多行后按 Tab所有选中行是否都加缩进选中区不崩溃把光标从代码块外移入块内时行首位置是否准确这些用例看着琐碎但它们验证的是整个光标计算模块的健康度。我们前期不重视的时候就是这些位置频繁出现选区漂移。第四域粘贴与复制的往返测试。粘贴场景按来源分成了三种从代码编辑器复制、从浏览器富文本页面复制、从 Word 复制。分别验证来源验证重点从代码编辑器如 VS Code复制行首空格、空行、行尾无多余br从浏览器页面复制不携带多余 span、背景颜色样式被剥掉从 Word 复制不产生表格结构或嵌套 div只保留纯代码字符串复制场景则反向验证从编辑器代码块内复制代码粘贴到 Markdown 编辑器中得到的原文应无多余 HTML 标签。第五域一次线上事故的完整排查链路。最后说一个差点酿成线上事故的真实案例也是整个代码块工程里最典型的排查过程。现象是用户在编辑态看到的代码高亮、缩进完全正常但发布后页面上代码块整体变成一个p标签包裹的普通文本语言标记和高亮全部丢失。第一步先从编辑态模型层找问题。我用调试接口把编辑器的文档 JSON 导出来确认模型层是完好的code-block节点。这说明问题不出在编辑过程中。第二步定位序列化到 HTML 的环节。把 JSON 模型直接丢给服务器端 HTML 渲染接口产出的 HTML 里代码块节点还在。也就是说服务器端渲染也没问题。第三步抓取发布接口的完整链路。最后发现链路里有个清洗中间层专门用来清理用户内容里的危险标签和多余属性。代码块的pre标签不在白名单里被清洗规则当成危险或无用标签剥掉了只剩内部文本所以最终落地成了普通段落。这次排查看似简单但差点漏掉的点是编辑态与发布态走了不同的渲染管线。编辑态用的是编辑器内核自带的序列化器发布态经过了另一套 HTML 清洗器。两套管线对代码块的认识不一致就会造成本地好好的线上坏了。这提醒我在设计测试用例时必须加入发布链路输出断言专门验证清洗规则是否会保留代码块节点也得提醒负责维护清洗规则的同学白名单里加代码块是一种业务语义变更不是简单的安全配置。后面我还在自动化测试里维护了一条代码块保真回归用例每次改动编辑器内核或清洗规则都先跑一遍所有区块内容往返的对比验证。如果某个版本导致保存后的字符串与原文出现一个空格的差异测试直接标红。这个习惯帮我挡掉了后续大量的隐性回归问题。现在回头复盘整个迭代过程最深的体验是富文本领域里看起来能用和真的能扛住生产环境之间隔着一整层输入输出管道。代码块作为富文本里的特殊公民它把缩进保留、按键接管、序列化一致性、高亮同步这些老问题全部放大了一遍。如果你刚好要做类似的功能先把内容模型定清楚再把输入输出边界的测试用例铺满。这两件事做到位比单纯调一个好看的代码高亮主题重要得多。另外一个小技巧可以分享代码块相关改动我习惯单独开一个保真测试页面里面放上各种特殊字符、中英文混排、超长单行、空行密集的样例代码每改一次编辑器配置就先在这个页面上跑一轮手动验证。这个页面不起眼但它能帮你省下大量和测试同事扯皮的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑