在React项目开发中,嵌套列表是常见的UI结构,比如多级菜单、树形数据展示等场景。这类场景往往需要同时支持两个核心功能:一是为所有列表项生成全局统一的连续序列号,不受嵌套层级影响;二是支持键盘方向键导航,让用户可以通过键盘快速切换焦点到不同的列表项,包括跨层级的焦点移动。这两个功能的实现需要合理设计组件结构和状态逻辑,下面会逐步拆解实现方案。

核心需求分析
首先我们需要明确两个功能的具体要求:
- 统一序列号管理:无论列表项处于第几层嵌套,都需要按照遍历的顺序生成从1开始的连续序号,比如第一层第一个是1,它的第一个子项是2,第一个子项的子项是3,第一层第二个是4,以此类推。
- 键盘导航:支持上下方向键在相邻列表项间移动焦点,左方向键收起当前展开的子列表或跳转到父级项,右方向键展开当前项的子列表或跳转到第一个子项。
数据结构设计
我们先定义嵌套列表的原始数据结构,每个列表项包含id、label和可选的children字段:
// 嵌套列表数据示例
const listData = [
{
id: '1',
label: '列表项1',
children: [
{ id: '1-1', label: '列表项1-1' },
{ id: '1-2', label: '列表项1-2', children: [
{ id: '1-2-1', label: '列表项1-2-1' }
]}
]
},
{ id: '2', label: '列表项2' }
];
统一序列号生成实现
生成统一序列号的核心是深度优先遍历(DFS)整个嵌套列表,在遍历过程中维护一个自增的序号计数器。我们可以写一个递归函数来处理:
let serialNumber = 1;
// 深度优先遍历生成带统一序号的列表数据
function traverseWithSerial(list, result = []) {
list.forEach(item => {
// 给当前项添加统一序号
const newItem = { ...item, serial: serialNumber++ };
// 如果有子项,递归处理子项
if (item.children && item.children.length > 0) {
newItem.children = [];
traverseWithSerial(item.children, newItem.children);
}
result.push(newItem);
});
return result;
}
// 生成带序号的列表数据
const listWithSerial = traverseWithSerial(listData);
经过上述处理,每个列表项都会多出一个serial属性,存储全局唯一的连续序号,后续渲染时直接使用这个属性即可。
键盘导航实现方案
键盘导航需要我们先收集所有可交互的列表项引用,然后监听键盘事件,根据按下的按键更新焦点位置。我们可以把整个嵌套列表的所有项拍平成一个数组,方便根据序号查找相邻项。
拍平列表项
在生成带序号的列表后,我们再写一个函数把嵌套结构拍平为一维数组,方便后续查找:
// 拍平嵌套列表为一维数组
function flattenList(list) {
const result = [];
function traverse(items) {
items.forEach(item => {
result.push(item);
if (item.children && item.children.length > 0) {
traverse(item.children);
}
});
}
traverse(list);
return result;
}
const flatList = flattenList(listWithSerial);
键盘事件监听与焦点切换
我们在最外层的列表容器上绑定键盘事件,然后根据按键类型处理逻辑:
import { useRef, useEffect, useState } from 'react';
function NestedList() {
const [expandedIds, setExpandedIds] = useState([]); // 存储展开的子列表id
const [activeSerial, setActiveSerial] = useState(1); // 当前焦点的序号
const containerRef = useRef(null);
// 获取当前所有可见的列表项(展开的才会被纳入导航范围)
const getVisibleItems = () => {
const visible = [];
function traverse(items) {
items.forEach(item => {
visible.push(item);
if (item.children && expandedIds.includes(item.id)) {
traverse(item.children);
}
});
}
traverse(listWithSerial);
return visible;
};
// 键盘事件处理
const handleKeyDown = (e) => {
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(item => item.serial === activeSerial);
if (currentIndex === -1) return;
const currentItem = visibleItems[currentIndex];
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
if (currentIndex > 0) {
setActiveSerial(visibleItems[currentIndex - 1].serial);
}
break;
case 'ArrowDown':
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
setActiveSerial(visibleItems[currentIndex + 1].serial);
}
break;
case 'ArrowRight':
e.preventDefault();
if (currentItem.children && currentItem.children.length > 0) {
if (!expandedIds.includes(currentItem.id)) {
// 未展开则展开
setExpandedIds([...expandedIds, currentItem.id]);
} else {
// 已展开则跳转到第一个子项
const firstChild = currentItem.children[0];
if (firstChild) {
setActiveSerial(firstChild.serial);
}
}
}
break;
case 'ArrowLeft':
e.preventDefault();
if (currentItem.children && expandedIds.includes(currentItem.id)) {
// 有子项且展开则收起
setExpandedIds(expandedIds.filter(id => id !== currentItem.id));
} else {
// 否则跳转到父级项
const parentItem = findParent(listWithSerial, currentItem.id);
if (parentItem) {
setActiveSerial(parentItem.serial);
}
}
break;
default:
break;
}
};
// 查找父级项的辅助函数
const findParent = (list, targetId, parent = null) => {
for (const item of list) {
if (item.id === targetId) return parent;
if (item.children) {
const result = findParent(item.children, targetId, item);
if (result !== undefined) return result;
}
}
return undefined;
};
// 组件挂载时聚焦容器
useEffect(() => {
if (containerRef.current) {
containerRef.current.focus();
}
}, []);
// 渲染列表项的递归组件
const renderItem = (item) => {
const isActive = item.serial === activeSerial;
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedIds.includes(item.id);
return (
<li
key={item.id}
tabIndex={-1}
style={{
paddingLeft: `${item.serial.toString().length * 10 + 10}px`,
backgroundColor: isActive ? '#e6f7ff' : 'transparent',
outline: 'none'
}}
>
<span>{item.serial}. {item.label}</span>
{hasChildren && (
<span
onClick={() => {
if (isExpanded) {
setExpandedIds(expandedIds.filter(id => id !== item.id));
} else {
setExpandedIds([...expandedIds, item.id]);
}
}}
style={{ marginLeft: '8px', cursor: 'pointer' }}
>
{isExpanded ? '收起' : '展开'}
</span>
)}
{hasChildren && isExpanded && (
<ul>
{item.children.map(child => renderItem(child))}
</ul>
)}
</li>
);
};
return (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
style={{ outline: 'none', width: '400px' }}
>
<ul style={{ listStyle: 'none', padding: 0 }}>
{listWithSerial.map(item => renderItem(item))}
</ul>
</div>
);
}
export default NestedList;
注意事项
- 容器的
tabIndex要设置为0,保证可以接收键盘事件,同时列表项的tabIndex设置为-1,避免按Tab键时打乱导航逻辑。 - 计算列表项左侧缩进时,可以根据序号的长度动态调整,让层级更清晰。
- 焦点切换时通过修改背景色等方式给用户明确的视觉反馈,提升体验。
- 如果列表数据量很大,可以考虑优化拍平和查找的逻辑,避免不必要的遍历。