入行做前端这些年,我面试时经常问一个问题: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> <li>HTML</li> <li>CSS</li> <li>JavaScript</li> </ul> </li> <li>后端</li> </ul>不过嵌套层级一旦超过三层,页面结构就会变得很臃肿。我见过不少项目把四五个层级的嵌套list硬堆在DOM里,结果样式调整时层层依赖,改一个padding就会引发连锁反应。遇到这种情况,我的建议是优先用三级以内的嵌套,再深就用树组件去做,而不是强行用ul套娃。
1.2 为什么推荐用列表而不是一堆div
这个问题看起来很基础,但确实能筛掉不少候选人。很多人写页面时习惯性用<div>包一切,因为div本身没有任何语义,样式上也最“干净”。但实际项目里,列表类内容用ul+li要靠谱得多。
首先是语义化。搜索引擎和读屏软件能通过标签语义理解页面结构,知道这是一组并列的数据项。而一堆div在机器眼里就是一堆没有任何含义的盒子,只能靠class名去猜。其次是浏览器默认样式,ul自带缩进和列表标记,即便没有任何CSS,页面也能保证基本可读,这在裸HTML或网页邮件里特别重要。第三是可维护性,看到ul就知道是列表,看到li就知道要循环输出,团队协作时这种隐性共识能省下大量沟通成本。
当然,也不是所有东西都要硬塞进ul。很多新手会走进另一个极端:页面上任何一组链接、任何一行文字都套上ul+li。这样做的结果是语义被稀释,读屏软件会把原本是段落的内容念成“列表,共12项”,反而干扰体验。具体什么场景不该用,我在第5部分专门展开细说。
1.3 ul、ol、dl三者怎么选
HTML里跟列表相关的其实有三组标签,很多新手容易混:ul无序列表、ol有序列表、dl定义列表。它们的使用场景有明确的区分。
| 标签 | 中文名 | 默认标记 | 适用场景 |
|---|---|---|---|
<ul> | 无序列表 | 实心圆点 | 项目之间没有先后关系,如菜单、分类、商品列表 |
<ol> | 有序列表 | 数字编号 | 项目之间有顺序关系,如步骤、排名、目录 |
<dl> | 定义列表 | 无标记 | 术语和描述的配对,如参数说明、键值对展示 |
实际开发中,导航菜单、功能列表、卡片列表都用ul;操作步骤、排行榜、嵌套目录用ol更合适;而像“用户名:张三”“年龄:25”这种键值对,用dl+dt+dd在语义上最准确。
还有个小细节值得记住:<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 去掉圆点和缩进的正确姿势
如果说ul+li最常用的一个样式需求,一定是“把列表默认的小圆点和缩进去掉”。刚接触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 item,list-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端导航菜单
最经典的ul+li使用场景就是导航菜单。一个标准导航菜单通常由header、nav、ul、li、a组成,结构上推荐li包裹a,而不是a包裹li。这样语义更合理,点击区域也更可控,因为li和a都可以分别设置样式。
<header class="site-header"> <nav class="main-nav" aria-label="主导航"> <ul class="menu"> <li><a href="/">首页</a></li> <li><a href="/docs">文档</a></li> <li class="active"><a href="/blog" aria-current="page">博客</a></li> <li><a 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-current="page",读屏软件可以播报“当前页”,同时也可以用它做样式钩子,不用额外加class。二是a标签一定要设置display: block或者inline-block,这样padding才能撑开整个点击区域。否则用户只能点到文字那一小块,移动端尤其容易误触。
3.2 场景二:图文卡片与消息列表
消息通知、评论列表、商品列表,这些都是ul+li大展拳脚的场景。每个li内部的结构往往不是简单一行文字,而是一张图片加标题、摘要、时间的组合。
<ul class="news-list"> <li> <img src="user.png" alt="用户头像" class="avatar"> <div class="info"> <h4>系统升级公告</h4> <p>今晚22:00到23:00进行系统维护,期间服务可能短暂不可用。</p> <time>2024-05-20</time> </div> </li> <li> <img src="user.png" alt="用户头像" class="avatar"> <div class="info"> <h4>周报提交提醒</h4> <p>请各部门在周五下班前完成本周周报提交。</p> <time>2024-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 场景三:用div+ul+li模拟下拉框
搜索热词里有一个特别典型的实际需求:用div+ul+li去模拟下拉框,而不是使用原生select。这个做法很常见,因为原生select在不同平台下的样式差异很大,option的样式几乎没法统一,而且移动端会跳出系统自带的滚动选择器,交互完全不可控。很多团队会自己封装一个基于ul+li的下拉组件。
基础结构可以这样写:
<div class="select" id="city-select"> <button class="select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false"> 请选择城市 </button> <ul class="select-dropdown" role="listbox" hidden> <li role="option">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标签页同样是ul+li+div三者的组合:ul负责标题栏,每个li是一个tab,div负责内容面板。它们的联动逻辑本质上跟下拉框类似,就是状态切换和显隐控制。
<div class="tabs"> <ul class="tabs-nav" role="tablist"> <li role="tab" class="active" aria-selected="true">全部</li> <li role="tab" aria-selected="false">待审批</li> <li role="tab" aria-selected="false">已通过</li> </ul> <div class="tabs-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。但如果公司没有强制要求组件库,自己用ul+li实现一个Tabs,反而更容易掌控样式和交互,这也是为什么很多中后台项目最终会沉淀自己的组件而不是完全依赖UI库。
3.5 场景五:响应式移动端列表
移动端是ul+li用得最多的地方,因为大多数移动端页面本质上都是列表流。这里有几个适配上的关键点。
第一个是点击区域。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 class="anchor-nav"> <li><a href="#section1">1. 项目背景</a></li> <li><a href="#section2">2. 核心实现</a></li> <li><a href="#top">返回顶部</a></li> </ul>html { 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 圆点去不掉、缩进去不掉
这是ul+li最经典的翻车现场,尤其是刚接触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.sleep:
from 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-value='beijing']")) ).click()这里推荐给选项加上data-value属性还有一个重要原因:自动化测试可以完全通过稳定的属性定位,不依赖选项的文本内容。因为中文文本、空格、换行都可能造成匹配问题,而data-value是可控的、唯一的键。
4.6 键盘可访问性的遗漏
模拟下拉框和Tabs标签页还有个通病:鼠标点击没问题,键盘操作完全失效。很多开发者习惯把点击事件绑定在li或div上,却忘了给它们设置tabindex。li默认是不可以聚焦的,键盘Tab根本走不到它上面,读屏用户也没法操作。
简单补救办法是给每个可交互的li设置tabindex="0",然后监听键盘事件,比如Enter选中、Escape关闭。如果做更进一步的无障碍支持,下拉框还要实现方向键上下选择,成本会高不少。但至少先做到“能Tab到、能回车、能Esc关掉”,这是底线。这个问题通常不会被视觉上的验收发现,但在自动化测试和真实用户的无障碍需求面前,早晚会暴露出来。
5. 语义化、可访问性与框架实践
5.1 什么时候不该用ul
聊完用法,也得泼泼冷水。ul不是万能容器,以下几种情况强行用ul反而扣分。
第一,一段连续的文字被硬拆成多个li。比如“我们提供设计、开发、测试三种服务”,这本质上是一句话,用p加顿号更自然,硬拆成列表会让读屏软件逐项播报,阅读效率反而下降。第二,具有表格含义的数据,比如价格表、对比表、排班表,应该用table,而不是靠ul+li硬拼。第三,单纯的几个功能按钮并排,用button就可以,不需要套一层列表。第四,页面最底部的版权信息和备案号,用p也比ul更清晰。
判断标准其实很简单:把这段内容在真实场景中读出来,如果它读起来像“项目列表”,就适合用ul;如果读起来像一段话、一行说明、一个按钮,那就别用ul。
5.2 框架中循环渲染的正确姿势
在React和Vue项目里,ul+li的最常见搭档是循环渲染。React里用map,Vue里用v-for,代码看起来都很简单:
// React <ul> {items.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul><!-- Vue --> <ul> <li v-for="item in items" :key="item.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-*定位属性一次做齐——将来不管是接手同事的自动化脚本,还是让产品体验更完善,你都会感激当初这个决定。