本文提供一种可靠方案,解决在 contenteditable div 中动态插入 html(如关键词高亮)后光标重置到开头的问题,通过精确计算光标偏移、安全重建 dom 和合理使用 range api 实现光标位置保持。
本文提供一种可靠方案,解决在 contenteditable div 中动态插入 html(如关键词高亮)后光标重置到开头的问题,通过精确计算光标偏移、安全重建 dom 和合理使用 range api 实现光标位置保持。
在基于 contenteditable 的简易代码编辑器中,为关键词(如 let、const)添加语法高亮时,直接修改 innerHTML 会破坏当前 DOM 结构,导致光标(caret)意外跳转至起始位置(offset 0),甚至抛出 DOMException: Selection.setPosition: The offset is out of range 错误。根本原因在于:textContent 是纯文本视图,而 innerHTML 操作会将文本重新解析为 DOM 节点,原有光标锚点(anchor node + offset)所指向的文本位置可能已不存在于新生成的节点树中。
✅ 正确思路:避免依赖 anchorOffset,改用字符级偏移 + 安全光标恢复
原代码试图通过 selection.anchorOffset 计算插入位置,但该值在 contenteditable 中语义模糊(尤其在多节点结构下),且 editor.firstChild 不一定存在或不匹配预期。更鲁棒的做法是:
- 以纯文本为基准:始终用 innerText(而非 textContent)获取用户可见内容,它能更好反映渲染后的文本流;
- 延迟高亮时机:不在每次输入都全量重渲染,而是仅在检测到关键词变化时触发高亮;
- 光标重定位采用 Range + setStartAfter() 策略:不依赖易失效的 setPosition(),而是将光标置于最后一个子节点末尾(适用于追加式编辑场景),或通过遍历节点精确定位到对应字符位置。
以下是优化后的完整实现(兼容 Firefox 及现代浏览器):
<div id="editor" contenteditable="true"></div>
<style>
#editor {
min-height: 120px;
min-width: 250px;
border: 1px solid #ccc;
padding: 8px;
font-family: monospace;
white-space: pre-wrap; /* 关键:保留换行与空格,支持多行 */
word-wrap: break-word;
}
.keyword {
color: #d73a49;
font-weight: bold;
}
</style><script>
const editor = document.getElementById("editor");
const keywords = ["function", "var", "let", "const", "if", "else", "for", "while", "return"];
const keywordRegex = new RegExp(`\b(${keywords.join('|')})\b`, 'g');
function highlightKeywords() {
const text = editor.innerText; // 获取用户实际看到的文本
const html = text.replace(keywordRegex, '<span class="keyword">$&');
// 暂存当前光标位置(字符索引)
const selection = window.getSelection();
let cursorPos = 0;
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(editor);
preCaretRange.setEnd(range.endContainer, range.endOffset);
cursorPos = preCaretRange.toString().length;
}
// 应用高亮(重置 innerHTML)
editor.innerHTML = html;
// 安全恢复光标:定位到第 cursorPos 个字符处
restoreCaretPosition(cursorPos);
}
function restoreCaretPosition(targetCharIndex) {
const range = document.createRange();
const selection = window.getSelection();
let count = 0;
function traverse(node) {
if (node.nodeType === Node.TEXT_NODE) {
const len = node.textContent.length;
if (count + len >= targetCharIndex) {
// 找到目标文本节点及偏移
range.setStart(node, targetCharIndex - count);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
count += len;
} else {
for (let child of node.childNodes) {
if (traverse(child)) return true;
}
}
return false;
}
traverse(editor);
// 若未精确定位(如超出范围),默认置于末尾
if (selection.rangeCount === 0 || !selection.getRangeAt(0).commonAncestorContainer.contains(editor)) {
range.selectNodeContents(editor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
// 绑定事件(移除冗余 keydown 处理,交由浏览器原生换行)
editor.addEventListener("input", highlightKeywords);
// 可选:防抖优化(高频输入时不卡顿)
// const debouncedHighlight = debounce(highlightKeywords, 100);
// editor.addEventListener("input", debouncedHighlight);
</script>
⚠️ 注意事项与进阶建议
- innerText vs textContent:innerText 会忽略不可见元素(如 <script>)、受 CSS 影响(如 display:none),但在 contenteditable 场景中更贴近用户视觉编辑体验;若需严格原始文本,请改用 textContent 并自行处理换行符( → <br>)。</script>
-
性能考量:全量高亮对长文本开销较大。生产环境建议:
- 仅高亮当前可视区域(viewport)内内容;
- 使用 MutationObserver 替代 input 事件,捕获真实 DOM 变化;
- 对关键词匹配做前缀树(Trie)优化。
-
Enter 键处理:原 keydown 中手动插入
易引发嵌套问题。推荐使用 CSS white-space: pre-wrap 配合浏览器原生换行行为,更稳定。 - 特殊字符兼容性:若需支持 '、" 等符号不跳动,应在正则中排除字符串字面量(需词法分析,非简单正则可覆盖);此时建议引入轻量解析器(如 acorn 或自定义状态机)。
该方案已在 Firefox 最新版验证通过,兼顾准确性与健壮性,是构建轻量级在线代码编辑器的实用起点。











