React受控contentEditable组件光标跳转问题及selectionRange值异常原因排查
问题原因分析
你遇到的核心问题是Range对象和DOM节点是强绑定的:当你通过dangerouslySetInnerHTML更新contentEditable div的内容时,React会替换掉div内部的DOM结构(旧的文本节点会被移除,新的文本节点被创建)。你在inputHandler中保存的Range是基于旧DOM节点的,当旧DOM被销毁后,这个Range对象会自动失效,转而指向新div的起始位置——这就是为什么在useLayoutEffect中看到的selectionRange变成了startOffset: 0的原因。
看控制台输出就能验证这一点:rerender时的selectionRange是旧DOM下的正确Range,但useLayoutEffect是在DOM更新完成后执行的,此时旧的text节点已经被替换,原来的Range失去了它指向的节点,就默认定位到了新div的开头。
解决方案:保存光标相对位置而非Range对象
要解决这个问题,我们不能直接保存Range对象,而是要保存光标在内容中的相对位置信息,等DOM更新完成后,再根据这个信息重新计算并设置光标位置。
下面是修正后的代码:
import { useState, useRef, useLayoutEffect } from 'react' function App() { const [HTML, setHTML] = useState('') const [caretOffset, setCaretOffset] = useState(0) const editableRef = useRef(null) // 计算光标在整个内容中的字符偏移量 const getCaretPosition = () => { const selection = window.getSelection() if (!selection.rangeCount || !editableRef.current) return 0 const range = selection.getRangeAt(0) const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(editableRef.current) preCaretRange.setEnd(range.endContainer, range.endOffset) return preCaretRange.toString().length } // 根据偏移量重新定位光标 const setCaretPosition = (offset) => { if (!editableRef.current) return const selection = window.getSelection() const range = document.createRange() range.setStart(editableRef.current, 0) range.collapse(true) // 遍历所有文本节点,找到对应偏移量的位置 const textNodes = [] const traverse = (node) => { if (node.nodeType === Node.TEXT_NODE) { textNodes.push(node) } else { for (let i = 0; i < node.childNodes.length; i++) { traverse(node.childNodes[i]) } } } traverse(editableRef.current) let currentOffset = offset for (const node of textNodes) { if (node.textContent.length >= currentOffset) { range.setStart(node, currentOffset) break } else { currentOffset -= node.textContent.length } } selection.removeAllRanges() selection.addRange(range) } // DOM更新后恢复光标位置 useLayoutEffect(() => { setCaretPosition(caretOffset) }, [HTML, caretOffset]) function inputHandler(ev) { // 先保存当前光标偏移量,再更新HTML状态 const currentOffset = getCaretPosition() setCaretOffset(currentOffset) setHTML(ev.target.innerHTML) } return ( <> <div ref={editableRef} contentEditable suppressContentEditableWarning onInput={inputHandler} dangerouslySetInnerHTML={{ __html: HTML }} /> <div>html:{HTML}</div> </> ) } export default App
代码说明
保存光标相对偏移量:
getCaretPosition函数计算光标相对于contentEditable div整个内容的字符偏移量,这个纯数值和DOM节点无关,不会因为DOM更新而失效。
DOM更新后恢复光标:
useLayoutEffect监听HTML和caretOffset的变化,在DOM更新完成后,调用setCaretPosition根据保存的偏移量重新定位光标。setCaretPosition通过遍历div内的所有文本节点,累计字符长度,找到对应偏移量的位置,创建新的Range并设置selection。
使用ref获取DOM节点:
- 用
editableRef直接获取contentEditable的div,确保每次操作的都是最新的DOM节点,避免依赖state更新后的DOM引用。
- 用
这样修改后,光标就不会再跳回开头了,因为我们保存的是内容中的相对位置,而不是绑定到旧DOM的Range对象。
内容的提问来源于stack exchange,提问作者snowape
相关产品推荐
相关产品推荐

