
前言:一个看起来很简单的功能
我们的网站后台有一个富文本编辑器,支持插入「代码块」。我们开发了一个很自然的需求:
- 代码块里如果没有任何内容,按一下回车就应该退出代码模式,回到正文编辑;
- 写完代码之后,连按两次回车:第一次回车新建一行(还在代码里),第二次回车在空行上退出代码模式,回到正文。
听起来是不是非常简单?然而这个功能从第一版实现到最终修复,我们一共折腾了四轮,期间还走过缓存、DOM 结构、浏览器探针三条完全不同的排查路线。这篇文章就把整个过程从头到尾完整复盘一遍。
前置知识:编辑器里的代码块长什么样
先交代一下背景。编辑器是 contenteditable 富文本,代码块不是原生 <pre> 那么简单,而是被包了一层带语言标签的结构:
<div class="code-block" data-lang="javascript">
<div class="code-block__head">
<span class="code-block__lang">JavaScript</span>
</div>
<pre><code>代码内容</code></pre>
</div>顶部那一行是语言标签(点击可以切换语言),真正的内容在 <pre><code> 里。
第一版实现:只处理「整个代码块为空」
第一版写得很直接:监听编辑器的 keydown,如果按的是回车、并且光标在代码块里,就判断整个代码块的文本是否为空——为空就退出代码模式,把代码块删掉、换成一个新的正文段落,光标移进去。
editor.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.shiftKey) return;
const block = 找到光标所在的代码块();
if (!block) return;
const code = block.querySelector('pre code, pre');
// 只有整个代码块完全为空才退出
if (!code || code.textContent.trim() !== '') return;
e.preventDefault();
退出代码模式(block);
});这个版本的效果:空的代码块回车能退出,但只要代码块里有一行字,按多少次回车都退不出去。
实际测试:有内容就永远退不出去
很快发现:只要代码块里有一行文字,哪怕新建了十行空白、回车了十遍,也不会退出代码模式。原因显而易见——我的判断条件是「整个代码块为空」,而实际场景是「代码块有内容,光标停在尾部空行」。第一版实现从一开始就理解错了需求。
排查路线一:浏览器缓存
收到反馈后,第一反应是「是不是浏览器还在跑旧代码」。于是做了两件事:给所有静态资源加了 Cache-Control: no-cache,再给所有 JS 引用加上版本号 ?v=xxx,保证浏览器必定加载最新脚本。
结果是:毫无效果。这也提醒我们——当功能「看似没生效」时,不要急着甩锅给缓存,先把问题复现清楚。
排查路线二: DOM 结构
用无头浏览器做端到端实测时,发现了一个有意思的现象:在真实 Chromium 里,点击代码块后直接输入文字,文本可能落在 <pre> 里,而不是嵌套的 <code> 里,结构变成这样:
<pre>line1<code></code></pre>也就是说 <code> 是空的,文本是 <pre> 的直接子节点。我原来的代码用 querySelector('pre code, pre') 会选中那个空的 <code>,导致「光标前/后文本」的计算全错。于是改成统一以 <pre> 为边界来计算。
结果:还是不行。说明问题不在这一层。
排查路线三:真实浏览器逐帧探针
前两条路都走不通,决定不再猜,直接在真实浏览器里装一个「探针」:在 keydown 捕获阶段监听回车,把光标位置、结构、前后文本全部 dump 出来。结果真相大白:
输入 line1 并按一次回车后,编辑器内部结构变成:
<pre>line1<br><br><code></code></pre>
光标位置:startContainer = PRE 元素,startOffset = 2关键发现有两个:
- Chromium 在
<pre>里按回车,不是插入换行文本,而是插入<br>元素,一次回车会产生两个<br>(一个代表换行,一个代表空行占位),光标停在两个<br>之间; - 致命的是:Range.toString() 和 textContent 都会静默忽略
<br>。
第二点就是「空能退、有字不能退」的真正根因!我之前用 Range.toString() 去取「光标前的文本」,结果遇到 <br> 时它什么也不返回。真实结构是 line1<br><br>,我读到的却是 "line1"——最后一个 之后的「当前行」永远是 line1,永远非空,于是永远不满足退出条件。
最终方案:自己序列化 <br>
既然浏览器 API 会忽略 <br>,那就绕开它:自己遍历 <pre> 的 DOM,文本节点照抄、遇到 <br> 就补一个 ,同时精确算出光标在这个序列里的字符位置。这样无论 Chromium 把换行存成文本还是 <br>,我们都能拿到一致的字符串。
function serializePreWithCaret(preEl, caretNode, caretOffset) {
let text = '';
let pos = -1;
const appendNode = (node) => {
let n = 0;
const rec = (nn) => {
if (nn.nodeType === 3) { text += nn.nodeValue; n += nn.nodeValue.length; return; }
if (nn.nodeName === 'BR') { text += '
'; n += 1; return; }
for (let i = 0; i < nn.childNodes.length; i++) rec(nn.childNodes[i]);
};
rec(node);
return n;
};
const walk = (node) => {
if (pos >= 0) return;
if (node === caretNode) {
if (node.nodeType === 3) {
pos = text.length + Math.min(caretOffset, node.nodeValue.length);
text += node.nodeValue;
} else {
pos = text.length;
for (let i = 0; i < caretOffset && i < node.childNodes.length; i++) pos += appendNode(node.childNodes[i]);
for (let i = caretOffset; i < node.childNodes.length; i++) appendNode(node.childNodes[i]);
}
return;
}
if (node.nodeType === 3) { text += node.nodeValue; return; }
if (node.nodeName === 'BR') { text += '
'; return; }
for (let i = 0; i < node.childNodes.length; i++) walk(node.childNodes[i]);
};
walk(preEl);
if (pos < 0) pos = text.length;
return { text, pos };
}得到序列化文本和光标位置后,退出判断就非常简单了:
const info = serializePreWithCaret(preEl, range.startContainer, range.startOffset);
const beforeText = info.text.slice(0, info.pos); // 光标前的文本
const afterText = info.text.slice(info.pos); // 光标后的文本
const currentLine = beforeText.split('
').pop(); // 光标所在行
// 光标在空行上,且后面没有任何内容 -> 退出代码模式
if (currentLine.trim() === '' && afterText.trim() === '') {
e.preventDefault();
退出代码模式(block);
}验证:覆盖所有真实结构
最终用 jsdom 和真实浏览器分别验证了以下几种结构,全部通过:
- 完全空的代码块:回车即退出;
- 文本在
<code>内(<pre><code>line1</code></pre>):写完内容后第 1 次回车仍换行,第 2 次回车在空行退出; - 文本直接落在
<pre>里(<pre>line1<code></code></pre>):同样生效; - 真实 Chromium 的
<pre>line1<br><br><code></code></pre>结构、光标在 (pre, 2):第 2 次回车退出; - 光标在内容末尾(非空行):第 1 次回车不退出,正常换行;
- 光标在中间、后面还有内容:不退出。
经验总结
这次排查收获最大的几条经验:
- 先复现,再下结论。「没生效」第一反应往往归咎缓存,但绝大多数时候是代码问题;用真实环境 + 探针把事实摆出来,比猜强一百倍。
- Chromium 的换行存储方式随元素类型变化。普通段落回车生成
<div>,<pre>里回车生成<br>,只有文本节点里才可能有 。 - Range.toString() / textContent 会忽略
<br>。凡是涉及「光标前后文本」的判断,遇到<br>就会得到错误结果,必须自己序列化。 - 光标边界点有两种形态。可能在文本节点内(nodeType 3 + 字符偏移),也可能在元素级(元素 + 子节点下标),都要兼容。