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

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>
      )}
    </>
  );
};

错误原因分析

  1. 强制设置父节点为展开状态:在filterNodes函数中,只要父节点自身匹配搜索词或包含匹配的子节点,就直接设置node.opened = true,导致父节点默认显示展开图标,不符合“匹配父节点时默认折叠”的需求。
  2. 子节点可见性被错误隐藏:搜索父节点时,子节点未被标记为visible: true,即使父节点展开,子节点也不会渲染,导致点击后无内容。
  3. 状态逻辑冲突: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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.23 19:14:54