React递归Tree Viewer中useState引发父组件意外关闭问题排查
问题场景与现象
我在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>"{data}"</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事件机制的理解偏差:
事件冒泡与
target的误用
点击子<details>的<summary>时,toggle事件会向上冒泡到所有父级<details>元素。而你用e.target.open更新状态,但事件冒泡过程中e.target始终是触发事件的原始子元素,并非当前处理事件的父级<details>。这导致父级的setOpen被设置为子元素的open值,从而意外关闭。条件渲染的放大效应
通过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

