Skip to content
返回设计中心

树形控件设计

递归数据、展开/折叠、多选、层级导航的 ARIA tree 角色。

组件模式高级

为什么

树是文件系统、组织图、嵌套分类的正确原语。带缩进的扁平列表伪装它但破坏键盘语义和屏幕阅读器层级播报。

复合组件模式被 Radix UI、Headless UI 和 shadcn/ui 采用 — 这是 2024 年采用最多的三个无头库。相比扁平组件 API,它们减少 70% 的属性逐层传递。

何时使用

用于 2+ 级层级且用户展开/折叠节点(文件浏览器、分类导航)。1 级用列表;仅同级导航用菜单。

如何做

1. ARIA — 容器 role="tree"、节点 role="treeitem"、aria-expanded、aria-level、aria-setsize、aria-posinset 2. 键盘 — 上下在可见项间、右展开或移到首子、左折叠或移到父 3. 数据 — 递归 { id, label, children?[] };展平为可见列表用于渲染+键盘导航 4. 选择 — 单选(点击选)或多选(每节点复选框,部分子选时父不确定) 5. 虚拟化 — >1000 节点时仅渲染可见窗口,用 react-window 或 @tanstack/virtual 6. 懒加载 — 首次展开时取子节点,解析前显示 spinner 7. 缩进 — aria-level 驱动 padding,非嵌套 DOM(扁平更快+可虚拟化)

示例

// 递归树展平为可见项(含展开节点的子节点) function flatten(nodes: Node[], expanded: Set<string>, level = 1): VisibleNode[] { return nodes.flatMap((n) => { const self = { ...n, level, hasChildren: !!n.children?.length }; if (!n.children || !expanded.has(n.id)) return [self]; return [self, ...flatten(n.children, expanded, level + 1)]; }); } function Tree({ nodes }: { nodes: Node[] }) { const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<string | null>(null); const visible = flatten(nodes, expanded); const onKeyDown = (e, id) => { const i = visible.findIndex((v) => v.id === id); if (e.key === "ArrowDown" && i < visible.length - 1) focus(visible[i + 1].id); if (e.key === "ArrowUp" && i > 0) focus(visible[i - 1].id); if (e.key === "ArrowRight") setExpanded((p) => new Set(p).add(id)); if (e.key === "ArrowLeft") setExpanded((p) => { const n = new Set(p); n.delete(id); return n; }); }; return ( <ul role="tree"> {visible.map((v) => ( <li key={v.id} role="treeitem" aria-level={v.level} aria-expanded={expanded.has(v.id)} aria-selected={selected === v.id} onKeyDown={(e) => onKeyDown(e, v.id)} onClick={() => setSelected(v.id)} style={{ paddingLeft: v.level * 16 }}> {v.hasChildren ? (expanded.has(v.id) ? "▾" : "▸") : "•"} {v.label} </li> ))} </ul> ); }

常见问题

  • ⚠嵌套 DOM 渲染 — 5 级深 = 5 嵌套 <ul>,慢且无法虚拟化
  • ⚠缺 aria-level/setsize/posinset — 屏幕阅读器无法播报深度或集合中位置
  • ⚠箭头键移 DOM 焦点但非 aria-activedescendant — 屏幕阅读器不跟随

最佳实践

  • ✓展平为带 aria-level 的可见列表 — 扁平 DOM 快且可虚拟化
  • ✓报 aria-setsize 和 aria-posinset — 屏幕阅读器播报 '7 中第 3,2 级'
  • ✓>1000 节点虚拟化 — 仅渲染可见窗口加小 overscan 缓冲

常见问题解答