富文本编辑器里加一个代码块按钮,听起来就是往工具栏上塞个图标的事。结果我们测试同事拿着半屏的缺陷截图来找我时,我才意识到"代码块"这三个字在一套成熟富文本里到底意味着什么: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 键:在代码块内应当插入一个换行符,而不是拆分、创建新块
- 全选后删除:用户 Ctrl+A 后按 Backspace,期望的是把整个代码块内容清空并保留代码块本身,而不是"删光内容顺带把代码块结构也删了"
- 光标跨界移动:从普通段落把光标移入代码块第一行,选区起点/终点要重新计算,不能把普通段落里的格式标记带进去
这套逻辑如果用"直接操作 contenteditable"的祖传方案来做,就要自己管理所有光标和浏览器的行为差异;如果用内容模型驱动的那类编辑器内核来做,拦截这些行为只是往 keymap 里注册几条规则。所以我的第一条建议是:做代码块之前,先回去翻翻自己编辑器内核里对 block 节点的抽象程度、对 keydown 的接管能力,这决定了后面 80% 工作量的大小。这些问题不是"硬刚浏览器 API"能解决的,而是设计层面的事。
2. 这次集成代码块,我翻车的五个高频场景
真正把代码块放进编辑器开始联调之后,测试同事一个接一个地报缺陷。我把其中五个高频场景整理一下,基本能覆盖绝大多数项目的踩坑面。每一条都从我实际复现的现象说起,再给根因和落地解法。
场景一:从外部页面复制代码,缩进全部丢失
从代码托管平台或者别人博客里复制一段代码,粘进编辑器,看着没问题,等保存后重新打开,行首缩进没了,代码全贴到左边。根因是这种粘贴的内容通常带着 HTML 的样式结构和各种 span,编辑器默认的粘贴处理器会把内容"清洗"成自己的结构。清洗规则里如果有"合并连续空格"或者"移除多余空白字符"这类逻辑,就正好打在代码块的软肋上。
解法是在粘贴进来的那一瞬间做路由:如果光标当前位于代码块内部,粘贴的内容一律按纯文本接收,且接收后不做空白规范化,原封不动塞进文本节点。部分浏览器在读取剪贴板时还会把代码里的<转成<,这些都是正常现象,关键是不要在这个阶段去做任何"看起来更美观"的处理。
场景二: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,需要重点测这个:用包含<pre><code>的 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的代码时,制表符被转成了普通空格,困扰了很多用户。后来统一规定:代码块内的文本以"原始字符序列"作为标准,不做任何默认替换。测试用例里甚至加了"特殊字符载荷":<div> ## 注释这类串,确保序列化、反序列化过程中不出现转义和实体化导致的变形。
第三域:编辑操作的边界行为。
这一块主要瞄准光标位置计算和选区操作:
- 光标在代码块某行中间按 Backspace,是否只删该位置前面的字符,而不是把上一行拉上来合并
- 光标在代码块首行行首按 Backspace,是否能正常删除该位置上一个字符,不触发块删除
- 跨行选择代码块内多行后按 Tab,所有选中行是否都加缩进,选中区不崩溃
- 把光标从代码块外移入块内时,行首位置是否准确
这些用例看着琐碎,但它们验证的是整个光标计算模块的健康度。我们前期不重视的时候,就是这些位置频繁出现选区漂移。
第四域:粘贴与复制的往返测试。
粘贴场景按来源分成了三种:从代码编辑器复制、从浏览器富文本页面复制、从 Word 复制。分别验证:
| 来源 | 验证重点 |
|---|---|
| 从代码编辑器(如 VS Code)复制 | 行首空格、空行、行尾无多余<br> |
| 从浏览器页面复制 | 不携带多余 span、背景颜色样式被剥掉 |
| 从 Word 复制 | 不产生表格结构或嵌套 div,只保留纯代码字符串 |
复制场景则反向验证:从编辑器代码块内复制代码,粘贴到 Markdown 编辑器中,得到的原文应无多余 HTML 标签。
第五域:一次线上事故的完整排查链路。
最后说一个差点酿成线上事故的真实案例,也是整个代码块工程里最典型的排查过程。现象是:用户在编辑态看到的代码高亮、缩进完全正常,但发布后页面上代码块整体变成一个<p>标签包裹的普通文本,语言标记和高亮全部丢失。
第一步,先从编辑态模型层找问题。我用调试接口把编辑器的文档 JSON 导出来,确认模型层是完好的code-block节点。这说明问题不出在编辑过程中。
第二步,定位序列化到 HTML 的环节。把 JSON 模型直接丢给服务器端 HTML 渲染接口,产出的 HTML 里代码块节点还在。也就是说服务器端渲染也没问题。
第三步,抓取"发布"接口的完整链路。最后发现链路里有个"清洗"中间层,专门用来清理用户内容里的危险标签和多余属性。代码块的<pre>标签不在白名单里,被清洗规则当成"危险或无用标签"剥掉了,只剩内部文本,所以最终落地成了普通段落。
这次排查看似简单,但差点漏掉的点是:编辑态与发布态走了不同的渲染管线。编辑态用的是编辑器内核自带的序列化器,发布态经过了另一套 HTML 清洗器。两套管线对代码块的认识不一致,就会造成"本地好好的,线上坏了"。这提醒我在设计测试用例时必须加入"发布链路输出断言",专门验证清洗规则是否会保留代码块节点,也得提醒负责维护清洗规则的同学:白名单里加代码块是一种"业务语义变更",不是简单的安全配置。
后面我还在自动化测试里维护了一条"代码块保真回归用例",每次改动编辑器内核或清洗规则,都先跑一遍所有区块内容往返的对比验证。如果某个版本导致保存后的字符串与原文出现一个空格的差异,测试直接标红。这个习惯帮我挡掉了后续大量的隐性回归问题。
现在回头复盘整个迭代过程,最深的体验是:富文本领域里"看起来能用"和"真的能扛住生产环境"之间隔着一整层输入输出管道。代码块作为富文本里的"特殊公民",它把缩进保留、按键接管、序列化一致性、高亮同步这些老问题全部放大了一遍。如果你刚好要做类似的功能,先把内容模型定清楚,再把输入输出边界的测试用例铺满。这两件事做到位,比单纯调一个好看的代码高亮主题重要得多。另外一个小技巧可以分享:代码块相关改动我习惯单独开一个"保真测试"页面,里面放上各种特殊字符、中英文混排、超长单行、空行密集的样例代码,每改一次编辑器配置就先在这个页面上跑一轮手动验证。这个页面不起眼,但它能帮你省下大量和测试同事扯皮的时间。