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

React中contenteditable ul列表的状态管理问题

解决可编辑
    作为数组表单字段时的重复值问题

你遇到的核心问题是React受控组件逻辑与contentEditable元素的DOM自主更新机制冲突:每次onInput触发时,你从innerHTML解析出数组并更新状态,React随即重新渲染<ul>的子元素,把新的<li>插入到已被用户编辑过的DOM中,导致DOM内容重复,下次解析时就会生成重复的数组项,形成恶性循环。

方案1:优化临时方案(onBlur触发同步)

既然你已经发现onBlur能避免重复问题,可直接完善这个方案——仅在用户编辑完成、失去焦点时同步状态,避免频繁渲染导致的DOM冲突:

import { useState, type FormEvent } from 'react';

export default function List() {
  const [value, setValue] = useState<string[]>(['first line', 'second line']);

  function handleBlur(event: FormEvent) {
    const items = event.currentTarget.innerHTML
      .split(/<li>(.*?)<\/li>/)
      ?.map((item: string) => item.replace(/<(.*?)>/g, ''))
      ?.filter(Boolean);
    setValue(items);
  }

  return (
    <ul
      contentEditable
      suppressContentEditableWarning
      onBlur={handleBlur}
    >
      {value.map((item, index) => (
        <li key={index}>{item}</li>
      ))}
    </ul>
  );
}

这个方案逻辑简单,完全复用React的状态管理,适合对实时同步要求不高的场景。

方案2:实时同步且避免重复(直接操作DOM)

如果需要实时同步状态,可通过useRef直接操作<ul>的DOM元素,绕过React对子元素的渲染控制,避免重复渲染冲突:

import { useState, useRef, useEffect, type FormEvent } from 'react';

export default function List() {
  const [value, setValue] = useState<string[]>(['first line', 'second line']);
  const ulRef = useRef<HTMLUListElement>(null);

  // 实时解析DOM中的<li>文本同步到状态
  function handleInput() {
    if (!ulRef.current) return;
    const items = Array.from(ulRef.current.children)
      .map(li => (li as HTMLLIElement).textContent?.trim() || '')
      .filter(Boolean);
    setValue(items);
  }

  // 当外部状态更新时,同步到DOM(避免状态与DOM不一致)
  useEffect(() => {
    if (!ulRef.current) return;
    // 仅当状态与当前DOM内容不一致时才更新,避免不必要的DOM操作
    const currentDomItems = Array.from(ulRef.current.children)
      .map(li => (li as HTMLLIElement).textContent?.trim() || '')
      .filter(Boolean);
    if (JSON.stringify(currentDomItems) !== JSON.stringify(value)) {
      ulRef.current.innerHTML = value.map(item => `<li>${item}</li>`).join('');
    }
  }, [value]);

  return (
    <ul
      ref={ulRef}
      contentEditable
      suppressContentEditableWarning
      onInput={handleInput}
    />
  );
}

关键逻辑说明:

  1. 用useRef获取<ul>的DOM引用,直接从DOM中遍历<li>获取文本,解析逻辑更准确,无需处理HTML标签拆分的问题。
  2. 通过useEffect在状态变化时同步DOM内容,且增加了一致性判断,避免重复更新DOM。

关于光标跳转问题的补充

你提到的dangerouslySetInnerHTML导致光标跳转的问题,可通过Selection API解决:在更新DOM前保存当前光标位置,更新后恢复。示例代码片段:

// 保存光标位置
function saveSelection() {
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0) return null;
  const range = selection.getRangeAt(0);
  const preSelectionRange = range.cloneRange();
  preSelectionRange.selectNodeContents(ulRef.current!);
  preSelectionRange.setEnd(range.startContainer, range.startOffset);
  const start = preSelectionRange.toString().length;
  return { start, end: start + range.toString().length };
}

// 恢复光标位置
function restoreSelection(positions: { start: number; end: number }) {
  const selection = window.getSelection();
  const range = document.createRange();
  range.setStart(ulRef.current!, 0);
  range.collapse(true);
  const textNode = ulRef.current!.firstChild;
  if (textNode) {
    range.setStart(textNode, positions.start);
    range.setEnd(textNode, positions.end);
    selection?.removeAllRanges();
    selection?.addRange(range);
  }
}

// 在useEffect中使用
useEffect(() => {
  if (!ulRef.current) return;
  const currentDomItems = Array.from(ulRef.current.children)
    .map(li => (li as HTMLLIElement).textContent?.trim() || '')
    .filter(Boolean);
  if (JSON.stringify(currentDomItems) !== JSON.stringify(value)) {
    const selection = saveSelection();
    ulRef.current.innerHTML = value.map(item => `<li>${item}</li>`).join('');
    if (selection) restoreSelection(selection);
  }
}, [value]);

这个逻辑可以在更新DOM后恢复光标到之前的位置,提升编辑体验。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.04 20:22:45