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

React嵌套菜单键盘导航:KeyDown事件中如何获取对应Node组件?

嵌套菜单的键盘导航实现问题

我想要实现一个带有嵌套节点和键盘导航功能的菜单,但在KeyDown事件触发时,如何获取对应的Node组件呢?

React官方文档标注Children和cloneElement为不推荐使用的API(未来可能会被废弃)。

我尝试通过Context将所有节点存储在一个数组中,并且仅更新上一个激活节点和下一个激活节点以尽可能减少重渲染,但如何确定节点之间的关系?比如如何知晓哪个节点是当前激活节点的兄弟节点?如何获取完整的节点树结构?

是否存在访问ReactTree的方法?或者有其他可行的实现思路?

现有实现代码:

let MenuContext = createContext();
function Menu(){
  let nodes = useRef([]);
  let activeNode = useRef(null);

  function add(dispatch){
    nodes.push(dispatch);
  }

  function remove(dispatch){
    nodes.slice(nodes.find(item => item === dispatch), 1);
  }

  function handleKeyDown(e){
    if(e.key === "ArrowDown"){ // or ArrowUp, ArrowLeft, ArrowRight, etc.
      // How to find childNode, prevSiblingNode, nextSiblingNode, parentNode
      let nextActive = nodes.find( node => /* TODO */);
      if(nextActive){
        activeNode.current(false); 
        nextActive(true);
        activeNode.current = nextActive;
      }
  }

  return <ul tabindex="0" onKeyDown={handleKeyDown}>
    <MenuContext.Provider value={add, remove, activeNode}>{children}</MenuContext.Provider>
  </ul>
}

function Node({label, children}){
  let ctx = useContext(MenuContext);
  let [active, setActive] = useState(false);
  useEffect(() => {
    if(!ctx) return;
    ctx.add(setActive);
    return () => ctx.remove(setActive);
  });
  return <li>
    <span className={active && "Node__active"}>{label}</span>
    {children}
  </li>
}

function App(){
  return <Menu>
    <Node label="Node1"/>
    <Node label="Node2">
      <ul>
        <Node label="Node2.1" />
        <Node label="Node2.2" />
      </ul>
    </Node>
  </Menu>
}

核心解决方案:构建带层级关系的节点注册表

你当前的Context方案只存储了节点的激活方法,缺失了节点的层级、父子/兄弟关系信息。可以通过以下方式改造:

  • 给每个Node分配唯一标识与层级信息:在Node组件中,通过Context接收父节点的ID和层级,生成自身的唯一ID,将包含id、parentId、level、setActive的节点信息注册到Menu的注册表中。
  • 维护结构化的节点树:Menu组件中用ref存储对象记录节点完整信息,同时维护数组记录节点渲染顺序,用于线性导航。
  • 实现键盘导航逻辑:根据不同按键,基于当前激活节点的信息查找目标节点(比如ArrowDown优先找子节点,再找下一个同级节点)。

改造后的完整代码:

import { createContext, useContext, useId, useEffect, useRef, useState } from 'react';

const MenuContext = createContext(null);

function Menu() {
  const nodesRegistry = useRef({}); // 存储所有节点:{ [nodeId]: { id, parentId, level, setActive, hasChildren } }
  const nodesOrder = useRef([]); // 按渲染顺序存储节点ID,用于线性导航
  const activeNodeId = useRef(null);

  // 注册节点
  const registerNode = (nodeInfo) => {
    nodesRegistry.current[nodeInfo.id] = nodeInfo;
    nodesOrder.current.push(nodeInfo.id);
  };

  // 移除节点
  const unregisterNode = (nodeId) => {
    delete nodesRegistry.current[nodeId];
    nodesOrder.current = nodesOrder.current.filter(id => id !== nodeId);
  };

  // 查找当前激活节点的索引
  const getCurrentNodeIndex = () => {
    return nodesOrder.current.indexOf(activeNodeId.current);
  };

  // 键盘导航逻辑
  const handleKeyDown = (e) => {
    if (!activeNodeId.current) return;

    const currentNode = nodesRegistry.current[activeNodeId.current];
    let nextNodeId = null;

    switch (e.key) {
      case 'ArrowDown':
        // 优先找当前节点的子节点
        const childNodes = Object.values(nodesRegistry.current).filter(node => node.parentId === currentNode.id);
        if (childNodes.length > 0) {
          nextNodeId = childNodes[0].id;
        } else {
          // 找下一个同级或上级的下一个节点
          const currentIndex = getCurrentNodeIndex();
          nextNodeId = nodesOrder.current[currentIndex + 1];
        }
        break;
      case 'ArrowUp':
        const currentIndex = getCurrentNodeIndex();
        if (currentIndex > 0) {
          nextNodeId = nodesOrder.current[currentIndex - 1];
        }
        break;
      case 'ArrowRight':
        // 激活第一个子节点
        const children = Object.values(nodesRegistry.current).filter(node => node.parentId === currentNode.id);
        if (children.length > 0) {
          nextNodeId = children[0].id;
        }
        break;
      case 'ArrowLeft':
        // 回到父节点
        if (currentNode.parentId) {
          nextNodeId = currentNode.parentId;
        }
        break;
      default:
        return;
    }

    if (nextNodeId && nodesRegistry.current[nextNodeId]) {
      // 取消当前激活
      nodesRegistry.current[activeNodeId.current].setActive(false);
      // 激活下一个节点
      nodesRegistry.current[nextNodeId].setActive(true);
      activeNodeId.current = nextNodeId;
    }
  };

  // 初始化激活第一个节点
  useEffect(() => {
    if (nodesOrder.current.length > 0 && !activeNodeId.current) {
      const firstNodeId = nodesOrder.current[0];
      nodesRegistry.current[firstNodeId].setActive(true);
      activeNodeId.current = firstNodeId;
    }
  }, []);

  return (
    <ul tabIndex="0" onKeyDown={handleKeyDown}>
      <MenuContext.Provider value={{ registerNode, unregisterNode, parentId: null, level: 0 }}>
        {children}
      </MenuContext.Provider>
    </ul>
  );
}

function Node({ label, children }) {
  const { registerNode, unregisterNode, parentId, level } = useContext(MenuContext);
  const [active, setActive] = useState(false);
  const nodeId = useId();
  const hasChildren = !!children;

  useEffect(() => {
    const nodeInfo = {
      id: nodeId,
      parentId,
      level,
      setActive,
      hasChildren
    };
    registerNode(nodeInfo);
    return () => unregisterNode(nodeId);
  }, [registerNode, unregisterNode, parentId, level, nodeId, hasChildren]);

  // 处理节点点击激活
  const handleClick = () => {
    setActive(true);
    activeNodeId.current = nodeId;
  };

  return (
    <li>
      <span
        className={active ? "Node__active" : ""}
        onClick={handleClick}
      >
        {label}
      </span>
      {hasChildren && (
        <ul>
          <MenuContext.Provider value={{
            registerNode,
            unregisterNode,
            parentId: nodeId,
            level: level + 1
          }}>
            {children}
          </MenuContext.Provider>
        </ul>
      )}
    </li>
  );
}

function App() {
  return (
    <Menu>
      <Node label="Node1" />
      <Node label="Node2">
        <Node label="Node2.1" />
        <Node label="Node2.2" />
      </Node>
      <Node label="Node3">
        <Node label="Node3.1">
          <Node label="Node3.1.1" />
        </Node>
      </Node>
    </Menu>
  );
}

export default App;

关键改进点

  • 节点关系结构化:每个节点注册时携带parentId和level,轻松识别父子/兄弟关系
  • 减少重渲染:仅在节点激活状态变化时,对应Node组件重渲染,Menu组件因使用ref存储注册表,不会频繁重渲染
  • 符合React最佳实践:避免使用Children/cloneElement这类Legacy API,通过Context传递层级信息,自然构建节点树

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.28 12:25:29