正确树形结构需用 ul/li 嵌套实现语义化层级:子菜单必须作为父 li 的 ul 子元素,用 padding-left 缩进,配合 aria-expanded/aria-hidden 保障可访问性,禁用绝对定位与非语义容器。

用 ul 和 li 实现可递归的树形结构
HTML 本身不提供“递归组件”概念,但树形菜单的本质就是嵌套的 ul → li → ul …… 这种自然嵌套关系。关键不是“怎么递归渲染”,而是确保 DOM 结构正确:每个子节点必须包裹在父 li 内部的 ul 中,不能平级放在外层 ul 里。
常见错误是把子菜单直接塞进同级 li 的 div 或 span,导致 CSS 缩进失效、键盘导航断裂、屏幕阅读器无法识别层级。
- 正确结构:
<li><a>文件</a><ul><li><a>新建</a></li></ul></li> - 错误结构:
<li><a>文件</a><div class="submenu"><a>新建</a></div></li>(脱离语义嵌套) - 后端或模板引擎生成时,务必让子数组作为当前
li的子元素插入,而非追加到外层ul
用 CSS padding-left 控制缩进而非 margin 或 text-indent
缩进层级靠的是视觉位移,但必须选对属性:用 padding-left 作用于 ul,而不是给 li 加 margin-left 或 text-indent。前者破坏列表项的点击热区和焦点框连续性,后者只缩进第一行文本、对多行菜单项失效。
层级缩进值建议用固定步长(如 24px),避免用百分比或 em——否则嵌套越深,缩进越不可控,且与字体大小解耦更干净。
立即学习“前端免费学习笔记(深入)”;
-
ul ul { padding-left: 24px; }是最简可靠写法 - 不要写
li { margin-left: 24px; }—— 会导致子ul相对于父li错位,折叠/展开动画易撕裂 - 禁用
text-indent:它不移动整个a元素,只缩进文字,图标、复选框等会悬空
用 aria-expanded 和 aria-hidden 支持可访问性折叠
纯视觉缩进不等于可操作树形菜单。用户需要知道某节点是否可展开、当前是否展开、子菜单是否可见。这些必须靠 ARIA 属性驱动,不能仅靠 CSS display: none 隐藏。
典型错误是只改样式,却不同步更新 aria-expanded="false" 或漏掉 aria-hidden="true",导致屏幕阅读器读不出状态变化,键盘用户按空格无法触发切换。
- 初始状态:
<li aria-expanded="false"><a>编辑</a><ul aria-hidden="true">...</ul></li> - 展开后:
aria-expanded="true"+aria-hidden="false"(或移除该属性) - JavaScript 切换时,必须同时操作两个属性,顺序不重要,但缺一不可
- 不要用
visibility: hidden替代aria-hidden:前者仍占用无障碍树,后者才真正隐藏语义
避免用 position: absolute 做缩进或层级定位
有人试图用绝对定位把子菜单“挪”到右侧来模拟缩进,这是危险做法。它切断了 DOM 层级与视觉层级的映射,导致焦点管理失效、滚动容器内定位偏移、打印样式错乱,且与 CSS prefers-reduced-motion 冲突。
真正的缩进应完全由流式布局完成:父 ul 设 padding-left,子 ul 自然继承块级流,无需任何定位干预。
- 绝对定位子菜单 → 焦点从父项跳转时会“跳过”整个子树,键盘用户无法进入
- 当菜单放在
overflow: auto容器中,position: absolute子菜单会脱离滚动上下文,悬停在视口边缘 - 响应式断点切换时,绝对定位需额外重算 left 值,而 padding 方案自动适配
最常被忽略的其实是键盘交互链路:Enter 展开/收起、→ 键进入子菜单、← 键返回父级、Tab 顺序必须严格按 DOM 深度优先遍历。这要求 HTML 结构零误差,CSS 不干扰流式布局,JS 状态与 ARIA 属性完全同步——三者缺一,树形菜单就只是个好看但不好用的装饰。



















