React递归树形组件搜索功能异常问题排查
树形搜索功能异常问题排查
问题现象
- 搜索一级/中间父节点时,匹配结果的节点图标显示为展开状态(应为折叠)
- 点击展开匹配到的父节点后,看不到子节点
- 示例:搜索
Category1时,节点显示但图标展开,点击后无内容;搜索Applications时层级显示正确,但同样展开状态且无子女节点
问题代码
DrawnTree.jsx
import React, { useState } from "react"; import "./styles.css"; import { Node } from "./Node"; const DrawnTree = ({ treeData, currentActive, setCurrentActive }) => { const [search, setSearch] = useState(""); const containsNodesWithTerm = (nodes, searchTerm) => { const ids = []; const _traverse = (nodes, searchTerm) => { nodes.forEach((node) => { if (node.name.toUpperCase().includes(searchTerm.toUpperCase())) { ids.push(node.key); } if (node.nodes.length) { _traverse(node.nodes, searchTerm); } }); }; _traverse(nodes, searchTerm); return ids.length; }; const filterNodes = (nodes, searchTerm = "") => { const _filter = (nodes, searchTerm) => { nodes.forEach((node) => { if ( node.name.toUpperCase().includes(searchTerm.toUpperCase()) || containsNodesWithTerm(node.nodes, searchTerm) ) { node.visible = true; node.opened = true; } else { node.visible = false; } if (!searchTerm) { node.opened = false; } if (node.nodes.length) { _filter(node.nodes, searchTerm); } }); }; _filter(nodes, searchTerm); return nodes; }; const filteredTree = filterNodes(treeData, search); return ( <div> <input type="text" onChange={(e) => setSearch(e.target.value)} /> <div className="tree-list-section"> <div className="left-holder-bottom-list-section"> {filteredTree.map((node) => ( <Node key={node.key} node={node} level={0} currentActive={currentActive} setCurrentActive={setCurrentActive} /> ))} </div> </div> </div> ); }; export default DrawnTree;
Node.jsx
import React, { useEffect, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; export const Node = ({ node, level, currentActive, setCurrentActive }) => { const { name, key, nodes, visible, opened } = node; const [isOpen, setIsOpen] = useState(opened); const hasChildren = !!node?.nodes?.length; const nodeType = level === 0 ? "node" : !hasChildren ? "leaf" : "group"; const activeClassName = currentActive === name ? "active" : ""; useEffect(() => { setIsOpen(opened); }, [opened]); if (!visible) { return null; } return ( <> <div className={`list-row level-${level} ${nodeType} ${activeClassName}`} onClick={() => { setIsOpen((open) => !open); if (!hasChildren) { setCurrentActive((prevName) => (!(prevName === name) ? name : "")); } }} key={key} > <div className="list-item-holder" style={{ paddingLeft: `${level === 0 ? "16" : 40 * level}px` }} > {hasChildren && ( <div className="list-item-expander-holder"> <span className={`expand-collapse-icon ${isOpen ? "collapse" : "expand"}`} > <span className="expand-icon"> <FontAwesomeIcon icon="caret-down" /> </span> <span className="collapse-icon"> <FontAwesomeIcon icon="caret-right" /> </span> </span> </div> )} <div className="list-item-details-holder">{name}</div> </div> </div> {isOpen && hasChildren && ( <div className="list-row-children"> {nodes.map((node) => ( <Node key={node.key} node={node} level={level + 1} currentActive={currentActive} setCurrentActive={setCurrentActive} /> ))} </div> )} </> ); };
错误原因分析
- 强制设置父节点为展开状态:在
filterNodes函数中,只要父节点自身匹配搜索词或包含匹配的子节点,就直接设置node.opened = true,导致父节点默认显示展开图标,不符合“匹配父节点时默认折叠”的需求。 - 子节点可见性被错误隐藏:搜索父节点时,子节点未被标记为
visible: true,即使父节点展开,子节点也不会渲染,导致点击后无内容。 - 状态逻辑冲突:
Node组件中isOpen状态由opened字段控制,搜索时强制设置opened = true,覆盖了手动折叠的状态逻辑。
解决方案
修改filterNodes函数逻辑
- 仅当节点是叶子节点匹配或需要展开路径到叶子节点时,才设置父节点为展开状态;匹配父节点自身时,默认保持折叠。
- 确保父节点匹配时,其子节点的可见性逻辑正常,用户手动展开后能看到内容。
修改后的核心函数代码:
const containsNodesWithTerm = (nodes, searchTerm) => { let hasMatch = false; const _traverse = (nodes) => { for (const node of nodes) { if (node.name.toUpperCase().includes(searchTerm.toUpperCase())) { hasMatch = true; break; } if (node.nodes.length) { _traverse(node.nodes); if (hasMatch) break; } } }; _traverse(nodes); return hasMatch; }; const filterNodes = (nodes, searchTerm = "") => { // 深拷贝原数据,避免修改原始树结构 const clonedNodes = JSON.parse(JSON.stringify(nodes)); const _filter = (nodes) => { nodes.forEach((node) => { const selfMatches = node.name.toUpperCase().includes(searchTerm.toUpperCase()); const childMatches = containsNodesWithTerm(node.nodes, searchTerm); // 节点可见条件:自身匹配 或 包含匹配的子节点 node.visible = selfMatches || childMatches; // 展开逻辑:只有子节点匹配时自动展开,自身匹配时保持折叠 if (childMatches) { node.opened = true; } else if (selfMatches) { node.opened = false; } else { node.opened = false; } // 清空搜索词时重置所有状态 if (!searchTerm) { node.opened = false; node.visible = true; } if (node.nodes.length) { _filter(node.nodes); } }); }; _filter(clonedNodes); return clonedNodes; };
关键优化点
- 深拷贝原数据:避免直接修改传入的
treeData,防止污染原始状态。 - 区分展开逻辑:子节点匹配时自动展开父节点(方便定位叶子节点),自身匹配时默认折叠(符合需求)。
- 修复子节点可见性:父节点自身匹配时,子节点的可见性由自身匹配逻辑决定,用户手动展开后能看到对应内容。
内容的提问来源于stack exchange,提问作者lrr59
相关产品推荐
相关产品推荐

