本节摘要:选择器是样式与文档树之间的寻址系统:元素、类、id 三大基础,组合器表达树上的位置关系,伪类捕获状态与结构位置,属性选择器按配置筛选。本节按能力地图逐类讲透,给特异性速算法与工程取舍,并用一份"可悬停高亮条纹"的表格实战收尾。
阅读完本节,你应当能够:
浏览器拿到一条 CSS 规则后做的第一件事,是拿选择器去文档树上"点名"。选择器的全部学问就是这门寻址语言:基础选择器决定"按什么特征找人"(名字、类名、id),组合器决定"按什么位置关系找人"(谁的后代、谁的儿子),伪类决定"按什么状态或序位找人"(被悬停的、每行的第一个)。三套特征可以叠加,构成从粗到细的完整表达力。
<style> p { line-height: 1.7; } /* 按元素名:所有段落 */ .card { border: 1px solid #ddd; } /* 按类名:一组卡片 */ #toc { background: #eaf2fc; } /* 按 id:唯一目录 */ </style>
工程优先级顺序要背下来:类为主、元素为辅、id 基本不用于样式(第 1 章讲过 id 的三大问题:优先级过高、不可复用、易撞名)。类选择器是主力军——特异性适中、可复用、语义可控,一个元素还能同时挂多个类形成组合。
单个基础选择器不够用时,组合器把特征连起来。四种各有分工:
<style> /* 后代:空格。nav 里任何深度的 a,隔几层都算 */ nav a { color: #2980b9; } /* 子代:>。只认直接子元素,ul 的亲生 li */ .menu > li { border-bottom: 1px solid #eee; } /* 相邻兄弟:+。紧跟 h2 的那个段落,典型用途是"标题后首段不缩进" */ h2 + p { margin-top: 0; } /* 通用兄弟:~。h2 之后的所有同层级 p */ h2 ~ p { max-width: 42em; } </style>
后代与子代的分野最容易出事:.list li 会命中列表里嵌套子列表的所有 li(孙辈也算),.list > li 只认亲儿子。给多级导航或嵌套列表写样式时,写错一个符号,子菜单的样式就"莫名其妙"跟着变。子代选择器还有一层语义价值:它把规则的作用范围钉死在一层,避免规则顺着树"漏"到不该去的地方——组件化项目里尤其重要。
伪类的作用范围经常需要主动收紧。一个经典教训:给 article a 设了样式,结果评论区里的链接也中招(评论也在 article 后代里)。解法要么收紧选择器(article > section a),要么给正文区一个专用类。选择器的书写思路永远先问"我会命中谁",再问"我会不会多命中谁"。
伪类以冒号开头,分两大族。
状态伪类捕获用户的交互瞬间::hover 悬停、:focus 聚焦、:active 按下、:disabled 禁用、:checked 选中。表单的"聚焦时描边、禁用时变灰"全靠它们,零脚本实现状态外观:
input:focus { outline: 2px solid #2980b9; } input:disabled { background: #f0f0f0; }
有个可访问性要点藏在 :hover 里:悬停样式必须与 :focus 成对出现,否则键盘用户用 Tab 聚焦时看不到任何反馈——他们不知道焦点走到哪了。写成 a:hover, a:focus { ... } 是肌肉级习惯。
结构伪类按元素在树中的序位点名,最大的价值是"不加一个类名就表达规律":
/* 条纹表格:偶数行上底色,一个类名都不加 */ tbody tr:nth-child(2n) { background: #fafafa; } /* 列表首项去掉上边距 */ li:first-child { margin-top: 0; } /* 每个章节的第一个段落 */ section > p:first-of-type { text-indent: 0; } /* 空元素隐藏 */ p:empty { display: none; }
结构伪类与脚本渲染的动态列表是天生一对:列表由数据驱动生成,没人知道哪行是偶数行,nth-child 让"每两行一个斑马纹"成为纯 CSS 的事,脚本完全不管外观。first-child 有个出名的陷阱:它匹配的是"父元素的第一个孩子且恰好是它"——若 p 前面被塞进一个标题,p:first-child 突然集体失效,页面莫名其妙丢样式。排查这种"突然失效",先想结构变没变。
:not() 反向选择是简化代码的利器:.item:not(.active) 一行替代"先给全部、再给 active 掏回去"的两段式写法。
第 1 章讲过属性是元素的配置面,属性选择器直接按配置寻址,在不方便加类的场景(第三方标记、框架生成内容)很实用:
/* 有 disabled 属性的按钮 */ button[disabled] { opacity: .5; } /* type 为 submit 的输入 */ input[type="submit"] { font-weight: bold; } /* 链接目标以 https 开头才显示外链图标 —— 示例写法 */ a[href^="https"]::after { content: " ↗"; } /* 文件类链接按后缀加图标提示 */ a[href$=".pdf"]::before { content: "⬇ "; }
注意 ::before 与 ::after 这对伪元素——它们不是选择已有元素,而是"选中元素的前后插入一个装饰盒子",上面的图标、本册许多页面的引导符都靠它。两个冒号是新写法(伪元素用双冒号、伪类用单冒号),单冒号写法兼容但新代码用双冒号。
上一节给了"百十个"三位数算法,这里练到秒算。.menu a:hover = 类一、伪类一、元素一 = 0-2-1;#nav .item span = 1-1-1。工程上的三条纪律:
第一,特异性尽量低且平。全部用单类选择器(0-1-0)打底,需要覆盖时再加一层而不是叠三个类。特异性一旦在项目里"高高低低",后面每次覆盖都要先心算一遍,非常累。方法论里的 BEM(块-元素-修饰符)本质上就是"用扁平类名换特异性恒定"——不引入嵌套选择器,覆盖靠类名组合,特异性永远 0-1-0。
第二,少依赖位置关系。div div ul li span a 这种长链选择器脆弱无比:结构一调整就断,特异性还高得没法覆盖。长度超过两三段就要警觉,多半应该改成给目标元素加类。
第三,性能常识知道即可,别过度优化。浏览器从右往左匹配选择器,最右段(关键选择器)匹配量最大时开销才明显。现代引擎下这点差异在绝大多数页面可忽略,规范写好选择器、控制页面规模,收益远大于抠选择器性能。
写选择器不难,难的是"样式没落到预期元素上"时的排查。三个浏览器开发者工具的配合技巧值得练成习惯:
第一招,用控制台即时验证选择器。开发者工具的控制台里输入一段查询脚本,返回的元素数量就是你的选择器命中了几个人。命中数为零,选择器写错了;命中数比预期多,规则正在"误伤"。写复杂选择器前先这样验一遍,比改完样式刷新十次快得多。
第二招,元素面板看规则列表的灰与划。选中元素后,样式面板列出所有命中它的规则:不匹配的显示为灰色(可展开看它为什么不匹配),匹配但被覆盖的属性划横线并标注胜出者。顺着横线找到胜出的那条规则,特异性对决一目了然。
第三招,悬停选择器高亮命中范围。在样式面板里把鼠标悬停在某个选择器文本上,页面上所有命中的元素会被高亮描边。这一招专治"我以为它只命中一个"的错觉——尤其是后代选择器,悬停一次就能看清波及面。
这三招配合上一节的层叠三问,构成了样式调试的完整工作流:控制台验命中 → 元素面板看覆盖 → 悬停看波及。工作流的意义在于把"改一下刷新看看"的试错循环,换成"先定位再动手"的推理循环,同一个问题,两条路的时间差经常是十倍。
问:伪类和伪元素到底差在哪?
伪类描述"元素处于某种状态或序位"(被悬停、是第一个孩子),选中的仍是元素本身;伪元素描述"元素的某个部位或插入物"(首行、之前插入的装饰盒),选中的是原本不存在的虚拟部件。记法:单冒号配伪类、双冒号配伪元素,新代码严格区分。
问:通配符选择器能用吗,会不会拖慢页面?
能用。现代浏览器对通配符的处理相当快,本章布局节还会用它做全局 box-sizing 重置——那是它最著名的应用。真正拖慢页面的是"右端匹配量巨大的长链选择器"(比如页面上几万个元素都符合右段特征),与通配符本身无关。性能怀疑时先用开发者工具的性能面板量化,别凭印象优化。
问:选择器写多细才合适?
一个可操作的尺度:细到"只命中我想要的",粗到"不依赖超过两层的位置关系"。落到实践就是:绝大多数规则用单类选择器;少量全局默认用元素选择器;组合器只在"父容器已经语义化、子项不想逐个加类"时使用(比如本章实战里 thead th 与 tbody tr:nth-child)。超过三段的组合选择器、以及为了覆盖而堆叠的多类选择器,都是结构或命名出了问题的信号,值得回头修而不是硬写。
把本章工具串起来,只靠结构与属性选择器,给一张"可访问的排班表"上妆:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>选择器实战</title> <style> /* 表格基础:元素选择器 */ table { border-collapse: collapse; width: 100%; } th, td { padding: 8px 12px; text-align: left; } /* 表头:结构伪类 + 子代收紧范围 */ thead th { background: #2c3e50; color: #fff; } /* 条纹:nth-child 零类名实现 */ tbody tr:nth-child(2n) { background: #f7fafc; } /* 悬停反馈:hover 与 focus 成对出现 */ tbody tr:hover { background: #eaf2fc; } /* 首列强调:first-child 复用 */ tbody td:first-child { font-weight: 600; } /* 按属性给单元格上语义色,无需任何类名 */ td[data-state="满"] { color: #c0392b; } td[data-state="余"] { color: #1d8a45; } /* 相邻兄弟:表格后紧跟的说明收紧边距 */ table + p { font-size: 13px; color: #7f8c8d; } </style> </head> <body> <table> <thead> <tr><th>场次</th><th>时间</th><th>余票</th></tr> </thead> <tbody> <tr><td>周一早场</td><td>09:00</td><td data-state="余">充足</td></tr> <tr><td>周一午场</td><td>13:30</td><td data-state="满">售罄</td></tr> <tr><td>周二早场</td><td>09:00</td><td data-state="余">充足</td></tr> <tr><td>周二晚场</td><td>19:00</td><td data-state="余">少量</td></tr> </tbody> </table> <p>说明:条纹与状态色均由选择器实现,标记中未添加任何表现类名。</p> </body> </html>
整个样式表没有出现一个自定义类——结构语义(thead、tr、td)加属性(data-state)就足够寻址。存文件打开,再逐条删规则刷新,观察每条选择器各自负责哪块外观。然后把表头删掉一行试试,体会"结构变了样式跟着断"的双刃剑。
元素能被精确点名了,下一节讲点名之后的事——属性与布局引擎如何把声明变成版面。