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
相关产品推荐
相关产品推荐

