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

React递归Tree Viewer中useState引发父组件意外关闭问题排查

React递归实现Tree Viewer时父级details意外关闭的问题分析

问题场景与现象

我在React中开发树状查看器,用<details>标签实现层级结构,通过递归组件渲染树。最初的伪代码如下:

// 伪代码
function NodeWithChildren({thing}) {
  return (
    <details>
      <summary>{thing.name}</summary>
      <div>
         {thing.children.map((child, ndx) => <Node node={child} key={`c${ndx}`} />)}
      </div>
    </details>
  );
}

function Node({thing}) {
  return (isLeaf) ? (
     <div>{thing.name}: {thing.value}</div>
  ) : (
     <NodeWithChildren thing={thing} />
  );
}

之后给NodeWithChildren添加useState控制<details>的展开状态,仅在open为true时渲染子节点:

function NodeWithChildren({thing}) {
  const [open, setOpen] = useState(true);
  return (
    <details open={open} onToggle={e => setOpen(e.target.open)}>
      <summary>{thing.name}</summary>
      {open && <div>
         {thing.children.map((child, ndx) => <Node node={child} key={`c${ndx}`} />)}
      </div>}
    </details>
  );
}

但出现异常:点击子<details>标签时,所有父级<details>都会被关闭。即使去掉条件渲染逻辑,问题依然存在。

完整组件代码:

/* eslint-disable @typescript-eslint/no-use-before-define */

import React, { useState } from "react";
import "styles.css";

enum ValueType {
  kBasic,
  kObject
}

const getValueType = (data: any) => {
  if (typeof data === "string") {
    return ValueType.kBasic;
  } else {
    return ValueType.kObject;
  }
};

interface JsonValueObjectValueProps {
  propName: string;
  value: any;
}

function JsonValueObjectValueBasic({
  propName,
  value
}: JsonValueObjectValueProps) {
  return (
    <div className="spector2-jsonvalue-key-value">
      <div className="spector2-jsonvalue-key">{propName}:</div>
      <div className="spector2-jsonvalue-value">
        <JsonValue data={value} />;
      </div>
    </div>
  );
}

function JsonValueObjectValueObject({
  propName,
  value
}: JsonValueObjectValueProps) {
  const [open, setOpen] = useState(true);
  const objectHasKeys = Object.keys(value).length > 0;
  return (
    <details
      open={open}
      onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
      className="spector2-jsonvalue-key-value-expandable"
    >
      <summary>
        {propName}: {objectHasKeys ? (open ? `{` : `{...},`) : `{},`}
      </summary>

      {open && objectHasKeys && (
        <React.Fragment>
          <div className="spector2-jsonvalue-key-value-expandable-value">
            <JsonValue data={value} />
          </div>
          <div className="spector2-jsonvalue-close-symbol">{`},`}</div>
        </React.Fragment>
      )}
    </details>
  );
}

function JsonValueObjectValue({ propName, value }: JsonValueObjectValueProps) {
  const valueType = getValueType(value);
  switch (valueType) {
    default:
    case ValueType.kBasic:
      return <JsonValueObjectValueBasic value={value} propName={propName} />;
    case ValueType.kObject:
      return <JsonValueObjectValueObject value={value} propName={propName} />;
  }
}

export function JsonValueObject({ data }: { data: Record<string, any> }) {
  return (
    <div>
      {Object.entries(data).map(([key, value], ndx) => (
        <JsonValueObjectValue key={`e${ndx}`} propName={key} value={value} />
      ))}
    </div>
  );
}

export function JsonValue({ depth, data }: { depth?: number; data: any }) {
  if (typeof data === "string") {
    return <div>&quot;{data}&quot;</div>;
  } else {
    return <JsonValueObject data={data} />;
  }
}

const json = {
  name: "Tami",
  relatives: {
    Carole: {
      relationship: "mom"
    },
    Terry: {
      relationship: "dad"
    },
    Brandy: {
      relationship: "dog"
    }
  }
};

export default function App() {
  return (
    <div className="App">
      <JsonValue data={json} />
    </div>
  );
}

配套CSS:

.App {
  font-family: monospace;
}

.spector2-jsonvalue-key {
  margin-right: 0.5em;
}

.spector2-jsonvalue-key-value {
  display: flex;
  margin-left: 1em;
}

.spector2-jsonvalue-value {
  display: flex;
}

.spector2-jsonvalue-key-value-expandable-value {
  margin-left: 1em;
}

.spector2-jsonvalue-close-symbol {
  margin-left: 1em;
}

问题根源分析

你对Hooks的基础使用没有误区,问题出在对原生DOM事件机制的理解偏差:

  1. 事件冒泡与target的误用
    点击子<details>的<summary>时,toggle事件会向上冒泡到所有父级<details>元素。而你用e.target.open更新状态,但事件冒泡过程中e.target始终是触发事件的原始子元素,并非当前处理事件的父级<details>。这导致父级的setOpen被设置为子元素的open值,从而意外关闭。

  2. 条件渲染的放大效应
    通过open状态条件渲染子节点时,父级状态更新会触发组件重渲染,子节点的卸载/挂载会让事件冒泡的表现更明显,但即使去掉条件渲染,事件冒泡的核心问题依然存在。

解决方案

方案1:阻止事件冒泡

在子<details>的onToggle事件中阻止冒泡,避免父级事件被误触发:

function JsonValueObjectValueObject({
  propName,
  value
}: JsonValueObjectValueProps) {
  const [open, setOpen] = useState(true);
  const objectHasKeys = Object.keys(value).length > 0;
  
  const handleToggle = (e) => {
    e.stopPropagation(); // 阻止事件冒泡
    setOpen((e.target as HTMLDetailsElement).open);
  };

  return (
    <details
      open={open}
      onToggle={handleToggle}
      className="spector2-jsonvalue-key-value-expandable"
    >
      {/* 其余代码不变 */}
    </details>
  );
}

方案2:使用currentTarget替代target

e.currentTarget指向当前绑定事件的元素(即父级<details>),而非触发事件的子元素。修改状态更新逻辑:

const handleToggle = (e) => {
  setOpen((e.currentTarget as HTMLDetailsElement).open);
};

此方案无需阻止冒泡,每个父级会读取自身的open状态,不会受子元素影响。

方案3:依赖原生<details>状态

如果不需要复杂状态控制,可去掉useState,完全依赖<details>原生状态,浏览器会自动处理层级展开/关闭:

function JsonValueObjectValueObject({
  propName,
  value
}: JsonValueObjectValueProps) {
  const objectHasKeys = Object.keys(value).length > 0;
  return (
    <details
      className="spector2-jsonvalue-key-value-expandable"
      defaultOpen // 初始展开用defaultOpen而非open
    >
      <summary>
        {propName}: {objectHasKeys ? `{` : `{},`}
      </summary>
      <React.Fragment>
        <div className="spector2-jsonvalue-key-value-expandable-value">
          <JsonValue data={value} />
        </div>
        <div className="spector2-jsonvalue-close-symbol">{`},`}</div>
      </React.Fragment>
    </details>
  );
}

总结

问题核心是对原生DOM事件中target与currentTarget的区别理解不足,以及事件冒泡的影响。通过区分这两个属性或阻止冒泡,即可解决父级<details>被误关的问题。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.14 14:20:27