ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ul和li标签用法详解:语义化、样式控制与实战避坑

ul和li标签用法详解:语义化、样式控制与实战避坑 入行做前端这些年我面试时经常问一个问题ul和li这两个标签你到底用对了吗说实话不少写了好几年页面的人都会当场卡壳。HTML里最基础的无序列表标签恰恰是页面结构中最容易被低估的骨架。无论是导航菜单、卡片列表、下拉选项还是Tabs标签页、面包屑、消息流几乎每个页面都离不开ul和li的组合。这篇文章我不打算罗列标签大全而是从一个实际开发者的角度把ul和li的用法、样式控制、实战场景和踩坑记录一次性讲透适合前端初学者、自学HTML的零基础玩家也适合准备把基础知识重新梳理一遍的进阶同学。顺便提醒一句搜索“ul”的时候你可能会搜出来一堆UL认证、线缆型号之类的东西那跟咱们前端说的ul完全是两码事。这篇只聊HTML里那个列表容器标签以及它和li之间那些真正值钱的细节。1. ul和li标签的基础认知与核心语义1.1 列表标签的本质是什么ul是unordered list的缩写翻译过来叫无序列表li是list item表示列表项。这两个标签的组合用法很简单ul负责声明“这是一个列表容器”li负责声明“这是容器里的其中一项”。ul li苹果/li li香蕉/li li橙子/li /ul浏览器渲染出来的样子就是三行文本每行前面带一个小圆点。这里有个很容易被忽略的规则li的直接父级必须是ul、ol或者menu不能单独存在也不能放在div或p里面。反过来ul里面也不能直接放文本或者其他元素只能放li。如果实在想放别的正确做法是在li内部去嵌套。另一个重要特性是列表可以嵌套。一个li内部再放一个ul就能形成多级结构这也是最常见的手风琴菜单、多级导航、目录树的基础写法ul li前端 ul liHTML/li liCSS/li liJavaScript/li /ul /li li后端/li /ul不过嵌套层级一旦超过三层页面结构就会变得很臃肿。我见过不少项目把四五个层级的嵌套list硬堆在DOM里结果样式调整时层层依赖改一个padding就会引发连锁反应。遇到这种情况我的建议是优先用三级以内的嵌套再深就用树组件去做而不是强行用ul套娃。1.2 为什么推荐用列表而不是一堆div这个问题看起来很基础但确实能筛掉不少候选人。很多人写页面时习惯性用div包一切因为div本身没有任何语义样式上也最“干净”。但实际项目里列表类内容用ulli要靠谱得多。首先是语义化。搜索引擎和读屏软件能通过标签语义理解页面结构知道这是一组并列的数据项。而一堆div在机器眼里就是一堆没有任何含义的盒子只能靠class名去猜。其次是浏览器默认样式ul自带缩进和列表标记即便没有任何CSS页面也能保证基本可读这在裸HTML或网页邮件里特别重要。第三是可维护性看到ul就知道是列表看到li就知道要循环输出团队协作时这种隐性共识能省下大量沟通成本。当然也不是所有东西都要硬塞进ul。很多新手会走进另一个极端页面上任何一组链接、任何一行文字都套上ulli。这样做的结果是语义被稀释读屏软件会把原本是段落的内容念成“列表共12项”反而干扰体验。具体什么场景不该用我在第5部分专门展开细说。1.3 ul、ol、dl三者怎么选HTML里跟列表相关的其实有三组标签很多新手容易混ul无序列表、ol有序列表、dl定义列表。它们的使用场景有明确的区分。标签中文名默认标记适用场景ul无序列表实心圆点项目之间没有先后关系如菜单、分类、商品列表ol有序列表数字编号项目之间有顺序关系如步骤、排名、目录dl定义列表无标记术语和描述的配对如参数说明、键值对展示实际开发中导航菜单、功能列表、卡片列表都用ul操作步骤、排行榜、嵌套目录用ol更合适而像“用户名张三”“年龄25”这种键值对用dldtdd在语义上最准确。还有个小细节值得记住li内部是可以直接放块级元素的比如div、p、h3甚至table因为列表项本身就像一个容器它承载的内容理论上可以非常复杂。但反过来ul外面不能包在p里因为p是短语内容不能包裹列表这种流式内容写了浏览器也会自动做DOM修正但代码就变得很难控了。2. 样式控制进阶玩法从原生圆点到完全自定义2.1 list-style三件套type、position、image列表默认的小圆点并不是什么玄学它是通过list-style系列CSS属性控制的。这一组属性有三个子项list-style-type控制标记的形状list-style-position控制标记的位置list-style-image则可以直接用一个图片作为标记。ul { list-style-type: square; list-style-position: inside; list-style-image: url(marker.png); }list-style-type最常用的值有disc实心圆、circle空心圆、square实心方块、decimal数字、lower-alpha小写字母以及none无。list-style-position有两个值outside和inside。默认是outside标记绘制在li内容的外侧所以你会看到圆点在文字缩进的更左边改成inside之后标记会贴着文字内容看起来更紧凑。我在实际项目里主要用two个组合场景一是要保留序号时用ol加自定义数字二是在图标列表里把标记换成iconfont或者字符。直接用list-style-image的情况反而很少因为图片尺寸不好控制圆点和文字的对齐也很难微调用伪元素::before生成标记反而更灵活。这一块下面会写到。2.2 去掉圆点和缩进的正确姿势如果说ulli最常用的一个样式需求一定是“把列表默认的小圆点和缩进去掉”。刚接触CSS的同学很容易只写一行list-style: none结果发现圆点确实没了但缩进还在或者圆点没了但排版看起来还是不对劲。原因在于浏览器的默认样式不是一个属性单独作用出来的。以Chrome为例ul默认有margin-top和margin-bottom通常是1em同时还有padding-left: 40px而列表标记就绘制在这个内边距区域附近。所以光清掉list-style是远远不够的。真正干净的reset长这样ul { margin: 0; padding: 0; list-style: none; }这组代码建议养成肌肉记忆。凡是做导航、菜单、列表组件第一件事就是把它写上去然后再加自己的样式。否则不同浏览器对ul的默认样式细节有细微差异上线后经常出现“我本地好好的测试环境多了几个像素缩进”这种问题。有一点需要提醒当ul处于flex或grid容器内时列表标记默认不会显示因为li变成了flex itemlist-item的标记机制会被抑制。这时候你即使不写list-style: none也不会看到圆点。但为了代码健壮性该写的reset还是写上别依赖这种隐式行为。2.3 用CSS计数器做自定义序号如果你需要给li加序号但嫌ol默认数字样式太死板可以用CSS计数器来实现完全自定义的编号。计数器非常适合做步骤条、教程列表、带编号的图文介绍。.steps { counter-reset: step; list-style: none; margin: 0; padding: 0; } .steps li { counter-increment: step; position: relative; padding-left: 40px; } .steps li::before { content: 步骤 counter(step); position: absolute; left: 0; top: 0; background: #06c; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; }关键点在于counter-reset声明在ul上counter-increment声明在li上然后通过li::before读取counter(step)的值。这样每多一个li编号就自动加一而且编号的样式完全由自己控制想做成圆形徽标、彩色标签甚至动画都行。如果做多级编号还可以在计数器里追加counter(step, lower-alpha)这种第二参数生成“1-a、1-b”这类效果。这个技巧在面试或者做组件库时也算一个加分点。2.4 横向导航菜单的两代实现思路ul的天然方向是纵向排列但页面上几乎所有导航都是横向的。老一代做法是给li设置float: left然后父级ul清浮动。这样做能跑但副作用不少父元素高度塌陷、所有li必须设置固定宽度或浮动布局、还要处理clearfix。.menu { list-style: none; margin: 0; padding: 0; } .menu li { float: left; } .menu li li { margin-left: 20px; } .menu::after { content: ; display: block; clear: both; }现在主流做法是直接用flex。flex几乎是横向列表的默认答案不需要清浮动不需要处理浮动带来的高度问题间距控制也更灵活.menu { list-style: none; margin: 0; padding: 0; display: flex; gap: 20px; }这里有几个细节值得注意gap属性在flex布局里可以直接控制子元素间距不需要给每个li加margin也不会出现最后一个li多出一截右边距的问题。对于需要换行的场景可以在ul上加flex-wrap: wrap然后配合gap实现网格效果比逐项设置margin-bottom再到最后一项清零省心得多。3. 高频实战场景从导航菜单到完整组件3.1 场景一PC端导航菜单最经典的ulli使用场景就是导航菜单。一个标准导航菜单通常由header、nav、ul、li、a组成结构上推荐li包裹a而不是a包裹li。这样语义更合理点击区域也更可控因为li和a都可以分别设置样式。header classsite-header nav classmain-nav aria-label主导航 ul classmenu lia href/首页/a/li lia href/docs文档/a/li li classactivea href/blog aria-currentpage博客/a/li lia href/about关于/a/li /ul /nav /header.menu { display: flex; list-style: none; margin: 0; padding: 0; } .menu a { display: block; padding: 10px 16px; text-decoration: none; color: #333; border-radius: 6px; } .menu a:hover { background: #f0f0f0; } .menu .active a { color: #fff; background: #06c; }这里推荐两个容易被忽略的细节。一是给当前页的菜单项加aria-currentpage读屏软件可以播报“当前页”同时也可以用它做样式钩子不用额外加class。二是a标签一定要设置display: block或者inline-block这样padding才能撑开整个点击区域。否则用户只能点到文字那一小块移动端尤其容易误触。3.2 场景二图文卡片与消息列表消息通知、评论列表、商品列表这些都是ulli大展拳脚的场景。每个li内部的结构往往不是简单一行文字而是一张图片加标题、摘要、时间的组合。ul classnews-list li img srcuser.png alt用户头像 classavatar div classinfo h4系统升级公告/h4 p今晚22:00到23:00进行系统维护期间服务可能短暂不可用。/p time2024-05-20/time /div /li li img srcuser.png alt用户头像 classavatar div classinfo h4周报提交提醒/h4 p请各部门在周五下班前完成本周周报提交。/p time2024-05-19/time /div /li /ul.news-list { list-style: none; margin: 0; padding: 0; } .news-list li { display: flex; gap: 12px; align-items: flex-start; padding: 16px 12px; border-bottom: 1px solid #eee; transition: background-color 0.2s; } .news-list li:hover { background: #fafafa; } .news-list .avatar { width: 40px; height: 40px; border-radius: 50%; } .news-list h4 { margin: 0 0 4px; font-size: 15px; }这个结构里比较关键的样式点是li使用了display: flex来排布内部信息。注意给li设置display: flex之后li本身的列表角色会被影响但因为我们通常已经把list-style清掉了所以视觉上并没有损失。反而是内部图片和文字的垂直对齐方式通过align-items来控制才是最方便的。3.3 场景三用divulli模拟下拉框搜索热词里有一个特别典型的实际需求用divulli去模拟下拉框而不是使用原生select。这个做法很常见因为原生select在不同平台下的样式差异很大option的样式几乎没法统一而且移动端会跳出系统自带的滚动选择器交互完全不可控。很多团队会自己封装一个基于ulli的下拉组件。基础结构可以这样写div classselect idcity-select button classselect-trigger typebutton aria-haspopuplistbox aria-expandedfalse 请选择城市 /button ul classselect-dropdown rolelistbox hidden li roleoption>const select document.querySelector(#city-select); const trigger select.querySelector(.select-trigger); const dropdown select.querySelector(.select-dropdown); const options dropdown.querySelectorAll(li); trigger.addEventListener(click, () { const expanded trigger.getAttribute(aria-expanded) true; trigger.setAttribute(aria-expanded, String(!expanded)); dropdown.hidden !expanded; }); options.forEach((option) { option.addEventListener(click, () { trigger.textContent option.textContent; trigger.setAttribute(aria-expanded, false); dropdown.hidden true; }); });这里的几个细节是我自己踩过坑之后总结出来的。第一打开和关闭状态用aria-expanded管理截图自动化测试和读屏工具都能读到状态第二列表用hidden属性控制显隐比直接用display:none写在CSS里更好因为JS里只需要切换hidden属性不用去背class名第三每个option带上data-value属性后续传递实际业务值的时候直接从dataset里取不需要再去匹配文本内容。3.4 场景四Tabs标签页Tabs标签页同样是ullidiv三者的组合ul负责标题栏每个li是一个tabdiv负责内容面板。它们的联动逻辑本质上跟下拉框类似就是状态切换和显隐控制。div classtabs ul classtabs-nav roletablist li roletab classactive aria-selectedtrue全部/li li roletab aria-selectedfalse待审批/li li roletab aria-selectedfalse已通过/li /ul div classtabs-panel section全部内容区域/section section hidden待审批内容区域/section section hidden已通过内容区域/section /div /div.tabs-nav { display: flex; list-style: none; margin: 0; padding: 0; border-bottom: 2px solid #e5e5e5; } .tabs-nav li { padding: 10px 20px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; } .tabs-nav li.active { border-bottom-color: #06c; color: #06c; }这里有个常见的微交互tab标题栏的激活态通常会和下面的内容区分开做法就是给ul加一个底部边框再用li的border-bottom去覆盖它配合margin-bottom: -2px让激活项的边框和容器的边框重叠。这样看起来就是一个连贯的横线下面的面板被选中项“压住”了。如果你用的是Vue3并且想在Element Plus这类组件库里去改Tabs的样式一般会用到:deep()去穿透组件的内部class。但如果公司没有强制要求组件库自己用ulli实现一个Tabs反而更容易掌控样式和交互这也是为什么很多中后台项目最终会沉淀自己的组件而不是完全依赖UI库。3.5 场景五响应式移动端列表移动端是ulli用得最多的地方因为大多数移动端页面本质上都是列表流。这里有几个适配上的关键点。第一个是点击区域。li内部如果放a标签或者绑定点击事件整体的最小点击区域建议做到44x44px以上不然在手机上很难点准。第二个是列表间距。在flex布局里用gap控制间距是最干净的不用关心最后一项的margin问题。第三个是纵向导航到横向导航的切换。移动端菜单通常会堆叠成纵向排列而桌面端是横向排列用媒体查询加一个flex-direction的切换就行。.menu { display: flex; flex-direction: column; list-style: none; margin: 0; padding: 0; } .menu li li { margin-top: 12px; } media (min-width: 768px) { .menu { flex-direction: row; } .menu li li { margin-top: 0; margin-left: 20px; } }这种写法在内容型站点里很常见。注意我用了li li选择器来控制间距而不是给每个li统一加margin-bottom再去掉最后一个这样结构上更干净也不会给列表项增加多余的类名。3.6 锚点导航与一键返回顶部还有一个特别实用的场景是用ul做锚点导航。很多长文档页面、帮助文档、博客目录都会用一个列表展示章节点击之后滚动到对应位置。配合CSS的scroll-behavior: smooth可以做到纯CSS的平滑滚动不需要JS。ul classanchor-nav lia href#section11. 项目背景/a/li lia href#section22. 核心实现/a/li lia href#top返回顶部/a/li /ulhtml { scroll-behavior: smooth; } .anchor-nav { list-style: none; margin: 0; padding: 0; position: sticky; top: 20px; }“一键返回顶部”在这里甚至不需要写算法href#top指向页面顶部的id就能实现。搜索热词里出现了“html一键返回顶部算法”指的往往是复杂一点的滚动距离判断和渐变动效但对大多数场景来说锚点加平滑滚动已经够用而且更符合“标签用法”的范畴。只有当你需要深色模式下返回顶部按钮的显隐控制时才需要引入IntersectionObserver或者scroll事件监听。4. 常见问题与排查实录4.1 圆点去不掉、缩进去不掉这是ulli最经典的翻车现场尤其是刚接触CSS的初学者最容易栽在这。常见的现象有两种一种是list-style: none写了但圆点还在另一种是圆点确实没了但列表还是比旁边的内容多出一块缩进。原因我在2.2里说过了浏览器默认样式不是一个属性单独生效的ul自带margin和padding。所以排查顺序就是先看list-style是否真的设置到了对应的ul上再看padding和margin是否清零了。如果项目里用了reset.css或者normalize.css还要注意样式的优先级问题比如某个全局样式给ul写了padding-left: 40px而你自己写的选择器优先级不够那就覆盖不掉。另外有一种比较隐蔽的情况就是给li单独设置了list-style-type而li的list-style会覆盖继承自ul的值。这时候哪怕你在ul上写了list-style: none只要li上还有list-style-type: disc圆点照样会出现。最保险的做法是ul和li上都做一次清除ul, li { margin: 0; padding: 0; list-style: none; }4.2 li之间有间隙这个问题的经典场景是把li设置成display: inline-block实现横向排列时两个li之间多出一条肉眼可见的缝隙。这个缝隙不是任何margin产生而是HTML源码里li标签之间的换行符和空格被浏览器渲染成了空白字符。解决办法有很多种最常见的是给父级ul设置font-size: 0然后给li重新设置font-size。但这种写法比较hack之后每次在li里加文字都要记住恢复字号很容易漏。更好的解决方案是放弃inline-block改用flex布局因为flex布局根本不关心元素之间的空白符。这也是我强烈推荐用flex的原因之一少踩太多不必要的坑。4.3 浮动导致父容器高度塌陷老项目里横向列表经常用float: left实现然后就会遇到父元素高度塌陷的问题ul的背景色和边框显示不出来因为浮动的li脱离了文档流父元素的高度被计算成0。解决方式有三种一是在ul上设置overflow: hidden二是用clearfix伪元素三是干脆换用flex。/* clearfix 经典写法 */ .menu::after { content: ; display: block; clear: both; }我在实际开发中基本已经从float方案全面切换到了flex方案。除非是在兼容很老的项目或者邮件模板这种不支持复杂CSS的环境里才会继续用float加clearfix的做法。新项目如果还看到float做横向导航我建议尽快重构。4.4 li中的图片和文字对不齐li内部的图片和小图标经常和文字基线参差不齐看着特别别扭。最常见的修法是给img设置vertical-align: middle如果li用了flex布局更直接的方法是设置align-items: center让子元素整体居中。但要注意align-items: center对多行文本会有影响。如果右侧文字可能换行建议用align-items: flex-start然后给文字块内部自己处理间距。否则文字太长换行之后图片还是会垂直居中视觉上反而有点怪。4.5 自动化测试里定位不到下拉框选项这里正好接着3.3的模拟下拉框场景说。很多人用自动化测试工具去操作这种非原生下拉框时会遇到“元素定位到了但点击没反应”“明明visible却报element not interactable”这样的报错。原因往往是下拉列表处于隐藏状态此时元素在DOM里存在但是不可见。排查思路可以按下面几步来。第一步确认下拉有没有展开可以通过触发器上的aria-expanded属性判断第二步检查隐藏方式如果列表用的是hidden属性或者display: none那隐藏状态下的元素就是不可交互的必须等展开后再点击第三步用显式等待替代time.sleepfrom selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver.find_element(By.CSS_SELECTOR, #city-select .select-trigger).click() WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.CSS_SELECTOR, #city-select li[data-valuebeijing])) ).click()这里推荐给选项加上data-value属性还有一个重要原因自动化测试可以完全通过稳定的属性定位不依赖选项的文本内容。因为中文文本、空格、换行都可能造成匹配问题而data-value是可控的、唯一的键。4.6 键盘可访问性的遗漏模拟下拉框和Tabs标签页还有个通病鼠标点击没问题键盘操作完全失效。很多开发者习惯把点击事件绑定在li或div上却忘了给它们设置tabindex。li默认是不可以聚焦的键盘Tab根本走不到它上面读屏用户也没法操作。简单补救办法是给每个可交互的li设置tabindex0然后监听键盘事件比如Enter选中、Escape关闭。如果做更进一步的无障碍支持下拉框还要实现方向键上下选择成本会高不少。但至少先做到“能Tab到、能回车、能Esc关掉”这是底线。这个问题通常不会被视觉上的验收发现但在自动化测试和真实用户的无障碍需求面前早晚会暴露出来。5. 语义化、可访问性与框架实践5.1 什么时候不该用ul聊完用法也得泼泼冷水。ul不是万能容器以下几种情况强行用ul反而扣分。第一一段连续的文字被硬拆成多个li。比如“我们提供设计、开发、测试三种服务”这本质上是一句话用p加顿号更自然硬拆成列表会让读屏软件逐项播报阅读效率反而下降。第二具有表格含义的数据比如价格表、对比表、排班表应该用table而不是靠ulli硬拼。第三单纯的几个功能按钮并排用button就可以不需要套一层列表。第四页面最底部的版权信息和备案号用p也比ul更清晰。判断标准其实很简单把这段内容在真实场景中读出来如果它读起来像“项目列表”就适合用ul如果读起来像一段话、一行说明、一个按钮那就别用ul。5.2 框架中循环渲染的正确姿势在React和Vue项目里ulli的最常见搭档是循环渲染。React里用mapVue里用v-for代码看起来都很简单// React ul {items.map((item) ( li key{item.id}{item.name}/li ))} /ul!-- Vue -- ul li v-foritem in items :keyitem.id {{ item.name }} /li /ul但key的选取有很多讲究。最省事的写法是用index作为key但一旦列表支持删除、排序、中间插入用index就会导致状态错乱。比如一个list里每个li内部有一个输入框用户在第一行输入内容后删掉第一行第二行的输入框内容和index就会对不上。正确做法是使用业务数据里唯一存在的id如果没有现成的id可以在前端生成一个自增id或者uuid。另外当li内部包含图片、复杂组件时建议把循环生成的key放在li这一层而不是放在内部子元素上。这样整个列表项才能被框架正确复用而不是每次重新渲染整个子树。5.3 列表性能问题与事件委托当页面里的li数量特别大比如上万条消息记录一次性渲染出来整个页面会非常卡。这个问题的根源不在于ul本身而在于DOM节点数量过多。有几个比较实用的优化思路。第一个是事件委托。与其给每个li都绑定一遍click事件不如只在ul上绑定一次然后通过事件对象的target去判断实际点到了哪个li。这样做最大的好处是减少内存占用对于动态增删的列表尤其明显。第二个是分页或懒加载。列表很长时优先做滚动加载而不是把数据全部渲染出来。第三个是虚拟滚动当列表长度达到真正影响性能的程度可以考虑virtual list方案它只渲染可视区域的节点原理是固定每个列表项的高度然后根据滚动位置动态计算应该渲染哪一段。document.querySelector(#list).addEventListener(click, (event) { const li event.target.closest(li); if (!li) return; const id li.dataset.id; // 处理选中逻辑 });不过这里也要说一句如果项目里的列表只有几十条完全没有必要上虚拟滚动。过度优化会让代码复杂度上升收益却趋近于零。我在实际项目中一般以“页面帧率明显下降”或“列表项超过千级”作为是否需要介入的分界线。在实际开发里ul和li这两个标签的坑从来不在“怎么拼写”上而在“怎么用对”语义上要选对标签样式上要清楚默认机制组件化要考虑键盘和无障碍框架里要处理好key和事件。这几个点每一条我都在真实项目里踩过坑、填过坑写出来也是希望读者能少走一次弯路。如果你是自己封装下拉框、Tabs这类组件我的建议是务必把aria属性、键盘事件和data-*定位属性一次做齐——将来不管是接手同事的自动化脚本还是让产品体验更完善你都会感激当初这个决定。
返回列表