You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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
代码说明
  1. 保存光标相对偏移量:

    • getCaretPosition函数计算光标相对于contentEditable div整个内容的字符偏移量,这个纯数值和DOM节点无关,不会因为DOM更新而失效。
  2. DOM更新后恢复光标:

    • useLayoutEffect监听HTML和caretOffset的变化,在DOM更新完成后,调用setCaretPosition根据保存的偏移量重新定位光标。
    • setCaretPosition通过遍历div内的所有文本节点,累计字符长度,找到对应偏移量的位置,创建新的Range并设置selection。
  3. 使用ref获取DOM节点:

    • 用editableRef直接获取contentEditable的div,确保每次操作的都是最新的DOM节点,避免依赖state更新后的DOM引用。

这样修改后,光标就不会再跳回开头了,因为我们保存的是内容中的相对位置,而不是绑定到旧DOM的Range对象。

内容的提问来源于stack exchange,提问作者snowape

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.30 12:17:48