在前端项目中,有时候我们会在 contenteditable
元素中处理用户输入,比如想自动清理非法字符(例如本文中我需要清理的 | 符号),同时保持光标位置不变。但实际操作中,如果你直接修改 el.textContent
或 innerText
,会导致光标丢失或跳到开头。
本文将记录我如何解决这个问题,完整复原用户输入时的光标偏移。
🧩 需求场景
富文本输入框,当用户输入的内容中包含 | 字符时,需删除该字符,并保留当前光标位置。
场景一:直接输入 | 字符;
场景二:通过输入法输入时,包含了 | 字符。
🧪 效果展示
-
用户输入包含
|
字符; -
按下空格后自动清除
|
; -
光标依旧保持在正确位置,不会跳到最前或末尾;
-
支持中文输入法,避免 composition 阶段误触发。
💥 问题来源
-
window.getSelection()
获取的range.endContainer
失效; -
range.endOffset
变成0
; -
光标跳到开头了!
🧠 补充说明:中文输入法兼容性
在中文等复合输入法(IME)场景下,用户输入字符会经历一个“合成阶段(composition)”,这个阶段字符尚未最终确认。如果你在 input
或 keyup
阶段直接操作 DOM(比如清除非法字符),可能会不小心删除用户正在输入的拼音或候选词。
为避免这种情况,我们需要借助 compositionstart
和 compositionend
来识别这个阶段:
<span
contenteditable="true"
@keydown="editKeydown"
@keyup="editKeyup"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
>
</span>
data() {
return {
isComposing: false, // 标记是否处于输入法合成中
};
},
methods: {
onCompositionStart() {
this.isComposing = true;
},
onCompositionEnd() {
this.isComposing = false;
},
}
✅ 解决方案
设定 span 元素 contenteditable="true"
场景一:
通过绑定的 @keydown 事件直接阻断输入:
if (e.key === "|" && e.ctrlKey === false && e.metaKey === false) {
e.preventDefault(); // 阻止默认的 `|` 输入
}
场景二
1. 捕获更新前的光标偏移
getCaretCharacterOffsetWithin(element) {
const selection = window.getSelection();
let caretOffset = 0;
// 如果没有选区,直接返回 0
if (!selection || selection.rangeCount === 0) return 0;
// 获取当前的选区(range 表示光标范围)
const range = selection.getRangeAt(0);
// 克隆一份 range 以免影响真实的选区
const preCaretRange = range.cloneRange();
// 将 range 折叠为一个点(光标所在位置)
preCaretRange.collapse(false); // false 表示折叠到末尾,即光标处
// 设置起点为 element 的起始位置
preCaretRange.selectNodeContents(element);
// 结束点为光标位置
preCaretRange.setEnd(range.endContainer, range.endOffset);
// 选中的文本即为从头到光标的所有字符,取长度即可
caretOffset = preCaretRange.toString().length;
return caretOffset;
}
2. DOM 更新后重新设置光标
setCaretPosition(element, offset) {
const range = document.createRange(); // 创建一个新的光标范围
const selection = window.getSelection(); // 获取当前选区
let currentOffset = 0; // 用于累计字符数
let nodeStack = [element]; // 使用队列实现 DFS
let found = false;
// 遍历所有子节点(按顺序查找 offset 对应的文本节点)
while (nodeStack.length && !found) {
const node = nodeStack.shift();
if (node.nodeType === Node.TEXT_NODE) {
const nextOffset = currentOffset + node.textContent.length;
// 判断目标 offset 是否落在当前文本节点内
if (offset <= nextOffset) {
// 设置光标起始位置(从当前文本节点起始偏移)
range.setStart(node, offset - currentOffset);
found = true;
break;
}
currentOffset = nextOffset;
} else {
// 非文本节点,继续往下遍历
for (let i = 0; i < node.childNodes.length; i++) {
nodeStack.push(node.childNodes[i]);
}
}
}
// 如果找到了目标位置,则设置光标
if (found) {
range.collapse(true); // 折叠为光标位置
selection.removeAllRanges(); // 清除旧光标
selection.addRange(range); // 应用新光标位置
}
}
3. @keyup
处理逻辑整合
// 将更新后的纯净内容同步到数据源中(如 v-model 或 props)
this.test.value = newText;
// 更新 DOM 内容(注意:此操作早于获取偏移量时,会导致光标跳转到开头)
el.textContent = newText;
注:需更换数据源为所绑定的字段。
我所遇到的光标调到开头的问题是由过早更新textContent所导致的获取偏移量时的异常(恒为0)
editKeyup(e) {
// 避免在中文输入中误触发
if (this.isComposing) return;
if (e.code === "Space") {
const el = e.currentTarget;
// 获取原始文本内容(包含临时插入的分隔符 '|')
let inputText = el.textContent;
// 去除 '|' 字符,得到新内容
let newText = inputText.replace(/\|/g, "");
if (newText !== inputText) {
// 将更新后的纯净内容同步到数据源中(如 v-model 或 props)
this.test.value = newText;
// DOM 更新后,恢复光标位置
this.$nextTick(() => {
// 获取当前光标在元素中的字符偏移
const preCaretOffset = this.getCaretCharacterOffsetWithin(el);
// 计算被删除的 '|' 数量
const removedCount = (inputText.match(/\|/g) || []).length;
// 更新 DOM 内容(注意:此操作会让光标丢失)
el.textContent = newText;
// 重新计算光标位置,减去被删掉的字符数量
const newOffset = Math.max(0, preCaretOffset - removedCount);
// 再次使用 nextTick,确保内容渲染后再设置光标
this.$nextTick(() => {
this.setCaretPosition(el, newOffset);
});
});
}
}
},
🧠 小结
这个方案的核心在于:
-
先记录偏移,再改 DOM,再恢复位置;
-
使用
Range
和Selection
精准控制; -
contenteditable
场景下比v-model
更灵活但也更容易踩坑。