树形控件设计
递归数据、展开/折叠、多选、层级导航的 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 缓冲