关键在于语义正确性:子ul必须作为父li的直接子元素,否则dom断裂导致js失效、键盘导航中断、屏幕阅读器误读;嵌套超3层宜用details;层级编号须用css counter实现。

嵌套多级列表结构,关键不是“能不能嵌套”,而是“嵌套是否语义正确、可访问、可维护”。ul 和 li 组合本身支持任意深度嵌套,但浏览器默认不显示层级编号(如 1.1、2.3.1),也不保证键盘导航或屏幕阅读器能正确识别父子关系——这些都取决于你是否把子 ul 放在父 li 的内部。
子 ul 必须作为父 li 的直接子元素
这是最常被破坏的规则。很多人写成两个平级 ul,或者把子菜单塞进同级 div,结果 DOM 结构断裂:
- ❌ 错误:
<ul><li>一级</li></ul> <ul><li>二级</li></ul>—— 第二个ul和第一个完全无关,不是子级 - ✅ 正确:
<ul><li>一级<ul><li>二级</li></ul> </li></ul>—— 子ul是父li的内容,语义连通 - 后果包括:JS 无法用
parentElement.querySelector('ul')找到子菜单;aria-expanded失去绑定目标;键盘 Tab 无法进入子项;屏幕阅读器读作“两个独立列表”,而非“一级项,展开后包含二级项”
嵌套超过 3 层时,details 比 ul 更合适
不是不能写 4 层 ul,而是它开始损害可访问性与用户认知负荷:
-
details原生支持键盘(空格/回车)、焦点管理、无需 JS,且语义明确表达“可折叠内容” - 必须把内层
details写在上层summary的内容中,例如:<details><summary>一级</summary><details><summary>二级</summary>…</details></details> - 不要用
details[open]强制全部展开——用户可能只想看某一层,初始全开反而增加滚动和理解成本 - 如果需要“全开/全关”联动或 hover 展开,则必须换回
ul/li+ JS,因为details不响应 hover,也无法批量控制状态
CSS counter 是实现 1.1、2.3.1 编号的唯一可靠方式
浏览器对嵌套 ol 的编号行为是重置而非继承:<ol type="1"><li>A<ol type="a"><li>x</li></ol>
</li></ol> 中的内层 ol 总从 a 开始,不会变成 A.a、A.b —— 这是规范行为,不是 bug。
- 要生成带分隔符的层级编号,必须用 CSS
counter-reset+counter-increment+counters() - 关键写法:
.multi-level > li::before { content: counters(section, ".") ". "; },其中"."可换成"-"或空字符串 - 注意:每个嵌套层级都要显式
counter-reset,否则子层会继承父层计数器导致错乱 - 纯 HTML
type属性(如type="A")只适用于单层有序列表,无法跨层联动
别把 li 当容器用,它本质是“可聚焦/可播报的边界标记”
很多样式问题其实源于语义误用:
-
li不是 div,它的作用是定义一个独立列表项单位。若一个li里塞进整段文字、按钮、图片、表单,会导致屏幕阅读器一次性朗读过长内容,键盘用户只能获得一次 Tab 焦点 - ul 的直接子元素只能是
li,放div、span或h3会触发 HTML5 验证错误,部分爬虫或无障碍工具可能跳过该列表 - 真正难的是判断“这里该不该用 ul/li”:纯图标导航、时间轴、卡片网格,表面像列表,但语义上往往更适合
nav+button、section+time或article
最容易被忽略的一点:嵌套结构是否通过右键 → “检查元素”确认了子 ul 确实位于父 li 标签内部?视觉缩进、CSS 定位、甚至 JS 动态插入,都不能替代这个 DOM 层级验证。










