ul+li嵌套是构建可访问树形菜单的唯一推荐方案,因浏览器和屏幕阅读器原生识别其层级语义,而div堆砌或js动态生成会破坏aria同步、键盘导航及seo;子ul必须为父li直接子元素,且需配合role="tree"/"treeitem"、aria-expanded/hidden同步更新与键盘焦点管理。

用 ul + li 嵌套是唯一推荐的 HTML 结构方案,其他方式(比如 div 堆砌、JS 动态生成 DOM 层级)会直接破坏可访问性、键盘导航和 SEO。
为什么必须用 ul+li 而不是 div 或自定义标签
浏览器和屏幕阅读器把 ul/li 当作原生层级容器识别,而 div 没有语义——即使加了 role="treeitem",DOM 树层级混乱时,NVDA 或 VoiceOver 仍可能跳过子项或重复朗读。更实际的问题是:CSS 缩进、悬停继承、:focus-within 行为在 ul 下天然可靠,在 div 里得靠 JS 模拟,且容易漏掉焦点管理。
常见错误现象:
- 用
div class="submenu"包住子项,导致按Tab键时焦点直接跳过整个子菜单 - 父
li里没放button,而是让文字本身响应点击,结果无法设置aria-expanded,辅助技术读不出“可展开”状态 - 子
ul不是父li的直接子元素,中间插了span或div,ARIA 层级链断裂
ul+li 结构中必须满足的嵌套规则
结构错一环,ARIA 就失效。关键约束只有三条,但每条都不可妥协:
- 最外层
ul必须设role="tree" - 每个可展开的
li必须设role="treeitem",且内部第一个子节点是button(带aria-expanded和aria-controls) - 子
ul必须是该li的**直接子元素**,不能包裹在div、span或其它标签里
示例正确结构片段:
aria-expanded 和 aria-hidden 同步更新的实操要点
只切 CSS 显隐不改 ARIA 状态,等于对屏幕阅读器撒谎。每次展开/收起必须同时操作两个属性:
- 展开时:
button.setAttribute('aria-expanded', 'true')+submenu.setAttribute('aria-hidden', 'false') - 收起时:
button.setAttribute('aria-expanded', 'false')+submenu.setAttribute('aria-hidden', 'true') - 别用
display: none单独控制隐藏——它不阻断屏幕阅读器读取,必须配aria-hidden="true" - 如果用了
max-height过渡动画,确保aria-hidden="true"在动画开始前就设上,否则 NVDA 可能在过渡中朗读半隐藏内容
键盘导航支持不能只靠 tabindex="0"
tabindex="0" 让所有节点都能被 Tab 键顺序聚焦,但这会让用户被迫逐个遍历每一层子项,完全违背树形菜单的设计逻辑。正确做法是:
- 所有
treeitem默认设tabindex="-1"(不可 Tab 进入) - 仅当前聚焦的节点设
tabindex="0",由 JS 控制焦点流 - 监听
keydown,拦截ArrowUp/ArrowDown在同级移动,ArrowRight展开已聚焦的父项,ArrowLeft先收起再跳到父项 -
Enter或Space触发展开/收起,而非跳转(除非该节点本身是链接)
最容易被忽略的一点:箭头键行为必须和视觉状态严格一致。比如按 ArrowRight 时,如果当前项没有子菜单,就不应触发任何动作——否则用户会误以为有下级内容。
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!











