开篇:选择器才是 CSS 的地基,别急着记属性
翻了翻前面的笔记,居然一直没系统讲 CSS 选择器,这确实不太应该。前端开发里,布局可以靠 Flex 和 Grid 撑起来,动效可以抄现成库,但选择器这东西,你绕不过去——它决定了你的样式能不能准确命中页面上的元素,也决定了你的样式表是能被后人轻松维护还是改一个地方崩三处。
这一期先把基础夯实:核心语法规则、常见选择器分类。不追求一次讲完所有冷门用法,但凡是日常开发高频能用到的,我会尽量把语法、适用场景、避坑点都交代清楚。
这套笔记定位是 H5 前端开发实战记录,面向的读者包括刚入行正在补基础的初学者,也包括写了几年 CSS 但靠记忆硬背选择器、从来没过一遍底层规则的开发者。哪怕你已经能熟练写.box > .item,我还是建议把优先级和层叠规则这一节扫一遍,很多“为什么我的样式不生效”的坑,根源就在那里。
1. 核心语法规则:先搞懂一条规则是怎么组成的
1.1 规则结构与选择器的位置
CSS 的完整样式规则长这样:
@media screen and (max-width: 768px) { .card .card-title:hover { color: #ff6b6b; font-weight: 600; } }拆开来看,@media screen and (max-width: 768px)是条件规则(@规则),它包裹的{}里面是可以有无数条普通规则集的。而普通规则集的结构就是:选择器 + 声明块。
- 选择器:
.card .card-title:hover,它告诉浏览器“我要给谁设置样式”。 - 声明块:
{ color: #ff6b6b; font-weight: 600; },它告诉浏览器“我要设置什么样式”。 - 每条声明由属性名和属性值组成,中间用冒号隔开,属性之间用分号隔开,最后一条声明后面的分号建议也写上,避免后面加代码时漏打分号导致整条声明失效。
这里有一个初学者容易模糊的地方:选择器表达式里的每一个部分,并不是都要“同时匹配”同一个元素。比如.card .card-title:hover表示的是“祖先元素有.card类名的一个.card-title元素,且它处于 hover 状态”,而不是“一个既有card又有card-title类名还在 hover 的元素”。前者是后代结合符,后者是.card.card-title:hover(中间没有空格)。这个区别不搞清楚,写复杂嵌套组件时候经常翻车。
1.2 结合符与选择器列表
选择器表达式里的空格、>、+、~这些符号,专业术语叫结合符,它们定义了两个简单选择器之间的位置关系。
| 结合符 | 含义 | 示例 | 匹配目标 |
|---|---|---|---|
| 空格 | 后代选择(所有层级) | ul li | ul内任意层级的li |
> | 子代选择(仅第一层级) | ul > li | ul的直接子元素li |
+ | 相邻兄弟(紧接其后) | h2 + p | 紧跟在h2后面的第一个p |
~ | 通用兄弟(同层级其后全部) | h2 ~ p | h2后面所有同层级的p |
另一种常见语法是选择器列表,就是指用逗号隔开:
.title, .subtitle, .desc { line-height: 1.6; }这个写法等价于分别给三个选择器写一样的声明块。要注意的是,选择器列表里任意一个选择器解析出错,会导致整条规则被丢弃(老浏览器尤其如此),所以现在很多团队会在构建阶段用 Stylelint 配合selector-list-comma-newline-after规则强制每个选择器单独一行,避免出现逗号位置引起的可读性问题。
1.3 优先级与层叠:为什么写了不生效
这一节建议反复看几遍。很多人写 CSS 头疼的原因就是“明明我后面写的样式覆盖不了前面的”,或者“内联样式和类名样式打架”。其实规则就两条:层叠决定最终生效的样式,优先级和来源决定层叠顺序。
浏览器层叠顺序(从低到高)大致是:
- 浏览器默认样式(User Agent Stylesheet)
- 用户样式表中的普通声明(正常情况很少用到)
- 作者样式表中的普通声明——也就是我们写的 CSS
- 作者样式表中的
!important声明 - 动画中的样式(在切换关键帧时参与计算)
- 过渡中的样式(过渡运行时覆盖普通声明)
在实际开发时,我们通常只需要关心第 3 档内部的规则。同档竞争看两点:
优先级(Specificity):四个维度,常记作
(内联, id, class, 元素)。分别统计内联样式、ID 选择器、类/属性/伪类选择器、元素/伪元素选择器的数量,从左到右比较,某个维度数值大的优先级高。- 内联样式:
style="...",优先级最高,形如(1, 0, 0, 0)。 - ID 选择器:
#app,形如(0, 1, 0, 0)。 - 类选择器、属性选择器、伪类:
.btn、[type="text"]、:hover,形如(0, 0, 1, 0)。 - 元素选择器、伪元素:
div、::before,形如(0, 0, 0, 1)。 - 通配符
*和组合符(>、+、~、空格)不占优先级。
- 内联样式:
源码顺序:当优先级完全相同时,后出现的规则覆盖前面出现的规则。
一个很典型的例子:
/* 第1条 */ .item { color: blue; } /* 第2条 */ .div .item { color: red; }DOM 是这样的:<div class="div"><span class="item">文字</span></div>。第 2 条优先级是(0, 0, 2, 0),高于第 1 条的(0, 0, 1, 0),所以最终文字是红色。哪怕你把第 2 条规则写到前边,只要优先级更高,它依然生效。新手常有的误区是“后面的样式覆盖前面的”,这只有在优先级相同的时候才成立。
!important的使用要慎重。它的优先级权重高到可以直接压过内联样式(用户代理样式里的!important除外),一旦在项目里大面积使用,后面排查样式问题时你会面对“改不动、覆盖不了、不知道从哪冒出来”的恐怖局面。我的经验是:尽量在覆盖第三方库样式时配合 BEM 或者更具体的选择器一起用,而不是单独把!important挂在自定义类名上。
2. 基础选择器分类:从元素、类、ID 到属性
2.1 元素选择器与重置样式
元素选择器直接写标签名,比如div、p、a、li。它匹配页面中所有该类型的元素。
a { text-decoration: none; color: inherit; } ul { margin: 0; padding: 0; list-style: none; }这是 CSS Reset / Normalize 里的常见用法。用元素选择器做全局默认样式是合理的,但有个坑要注意:元素选择器优先级低,容易被其他任何类名选择器覆盖,所以它只适合做“默认值”,不能指望用它锁定最终样式。现代项目我通常建议直接引 Normalize.css 或者用现代 CSS Reset(类似box-sizing: border-box全局设置),避免自己手写重置时遗漏某些元素。
2.2 类选择器与 ID 选择器
类选择器.box是开发中利用率最高的,它匹配所有带有class="box"的元素。一个元素可以同时带多个类名,比如<div class="box card active">,而.box.active表示同时有box和active两个类名的元素。多个类名的顺序不影响匹配,因为类选择器只管“是否有这个类”,不管它在 class 属性里的排列顺序。
ID 选择器#app只匹配一个元素(正常情况下 ID 在文档中唯一)。从优先级看,ID 权重远高于类选择器,所以同样的属性,ID 写的样式一定会压过类名写的样式。实际开发中我并不建议把 ID 作为样式钩子来用,原因有两个:
- ID 语义化上更适合 JS 交互和锚点定位,和样式分离更利于维护。
- 一旦用 ID 写了样式,想覆盖它要么用更高的优先级(比如再加一个 ID,或者
!important),非常不灵活。
在 Vue / React 项目里,很多组件根节点会用 ID 标记(比如#app),但那只是作为挂载点,样式尽量别挂上去。
2.3 属性选择器:精确匹配与模糊匹配
属性选择器通过[attr]语法选中带有某属性的元素,比类名的定制性更强。常用的有下面几类:
| 语法 | 含义 | 示例 | 说明 |
|---|---|---|---|
[attr] | 含有该属性 | [disabled] | 匹配所有带disabled属性的元素 |
[attr="value"] | 属性值完全等于 | [type="text"] | 匹配type值为text的元素 |
[attr^="value"] | 属性值以指定值开头 | a[href^="https"] | 匹配链接以 https 开头的a标签 |
[attr$="value"] | 属性值以指定值结尾 | a[href$=".pdf"] | 匹配链接以.pdf结尾的a标签 |
[attr*="value"] | 属性值包含指定值 | [class*="icon"] | 匹配类名中包含icon的元素 |
[attr~="value"] | 属性值是以空格分隔的完整单词之一 | [class~="active"] | 匹配类名中独立包含active的元素(相当于.active) |
| `[attr | ="value"]` | 属性值等于 value 或以 value- 开头 | `[lang |
属性选择器在表单校验样式、文件链接图标、语言相关样式等场景非常实用。比如给外链加个小图标:
a[href^="http"]:not([href*="mysite.com"])::after { content: "↗"; margin-left: 2px; }这样不用在 HTML 里手动加 class,就能自动识别外链并附加视觉提示。优先级上,属性选择器和类选择器同一等级,都是(0, 0, 1, 0)。
2.4 通配选择器
*匹配页面里所有元素。用法上最常见的场景是:
* { box-sizing: border-box; } *::before, *::after { box-sizing: border-box; }这类全局设置没问题,但切忌用*给所有元素加边距、字体等重量级属性,因为通配匹配所有元素意味着浏览器要计算全部节点的开销,在大型页面里性能上有损耗。另外*的优先级是零,任何选择器都能覆盖它。
3. 组合选择器与结构性伪类
3.1 后代、子代、兄弟:DOM 结构匹配实战
组合选择器在实际页面中非常常见,下面用一段真实的列表结构说明:
<ul class="list"> <li class="item">项目一</li> <li class="item"> <div class="inner"> <span>嵌套内容</span> </div> </li> <li class="item">项目三</li> </ul>.list li:选中ul内部所有li,包括嵌套在里层的。.list > li:只选中.list的直接子元素li,不会选中嵌套在.inner里的其他li。.item + .item:选中紧跟在另一个.item后面的.item(这里就是第 2、3 个 li)。.item ~ .item:选中.item后面所有同层级的.item,效果和+在这个例子里一样,但在隔了别的元素时~更宽泛。
实际写组件库样式时,我习惯用子代选择器约束作用范围:
.card > .card-header { border-bottom: 1px solid #eee; }这样不仅语义清晰,也不会误伤.card里嵌套子组件的.card-header。特别是做嵌套卡片、折叠面板这类复杂结构时,>能挡掉非常多的样式污染问题。
3.2 伪类选择器:状态与位置
伪类以冒号:开头,匹配的是元素的某种状态或位置关系。高频使用的有:
- 交互状态:
:hover、:active、:focus、:focus-within、:checked、:disabled。 - 结构性位置:
:first-child、:last-child、:nth-child(n)、:nth-of-type(n)、:not(selector)。
其中:nth-child()的括号里可以填数值、关键字或者公式。公式形式是an + b,n 从 0 开始计数。举个例子:
/* 选中奇数行 */ li:nth-child(odd) { background: #fafafa; } /* 选中第2、第5、第8...个 */ li:nth-child(3n + 2) { border-left: 2px solid #1890ff; } /* 选中前3个 */ li:nth-child(-n + 3) { font-weight: 600; }这里容易踩坑的是:nth-child和:nth-of-type的区别。:nth-child(n)是“父元素下的第 n 个子元素”,重点在位置;而:nth-of-type(n)是“同类型标签里的第 n 个”。举个例子:
<div class="wrap"> <p>第一段</p> <span>第一个span</span> <p>第二段</p> </div>.wrap p:nth-child(2):第 2 个子元素是<span>,不是p,所以这个选择器不匹配任何元素。.wrap p:nth-of-type(2):在p类型里数第 2 个,也就是第二个<p>,匹配成功。
这个区别在列表项、卡片循环渲染里经常导致“为什么我选中的不是想要的那个”,值得留意。
3.3 伪元素选择器:双冒号的正确用法
伪元素用双冒号::表示(老规范用单冒号也能兼容 IE,但现代开发统一用双冒号),用来给元素创建“虚拟子元素”或“虚拟片段”。最常用的是::before、::after、::placeholder、::selection。
::before和::after必须要配合content属性使用,哪怕 content 里是一个空字符串,不然不会渲染。
.btn::after { content: ""; display: inline-block; width: 8px; height: 8px; margin-left: 4px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); }这个技巧可以实现在按钮文字后面加一个箭头图标,完全不需要额外写 HTML 标签。但要注意,::before/::after虽然可以加背景、边框、布局属性,但默认是一个内联元素,需要显式设置display才能撑开宽高。另外它们不是真正的 DOM 元素,不能被爬虫读取,也不能被屏幕阅读器稳定识别,所以关键文案不要放在 content 里,它只适合装饰性视觉内容。
4. 原生选择器与 H5 场景的实战结合
4.1 表单和交互组件里的选择器组合
H5 页面里,表单是绕不开的场景。点击输入框、勾选选项、切换开关这些交互,用伪类选择器能写出非常漂亮的纯 CSS 反馈。
一个经典的自定义复选框:
.checkbox { position: relative; width: 20px; height: 20px; appearance: none; border: 2px solid #ccc; border-radius: 4px; outline: none; cursor: pointer; } .checkbox:checked { background: #1890ff; border-color: #1890ff; } .checkbox:checked::after { content: ""; position: absolute; left: 6px; top: 2px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }这里用到了appearance: none去掉系统样式,:checked控制选中态的视觉变化,::after画出对勾。原生 input 加上这几个选择器组合,就能避免引入整套表单组件库。
H5 开发中还有个很实用的伪类是:focus-within。当表单容器内任意子元素获得焦点时,它都生效:
.form-group:focus-within { border-color: #1890ff; box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1); }这样整个输入框组的边框会跟着内部输入框的聚焦高亮,比直接监听 JS 事件省事得多,适配性也好。
4.2 UI 列表与卡片布局中的选择器技巧
移动端 H5 常见的是卡片列表、消息列表这类结构。用选择器做分隔线、圆角裁剪,比在 JS 里判断“这是不是最后一个”要干净得多。
.card-list .card + .card { margin-top: 12px; }这条规则用相邻兄弟选择器,让除第一个卡片外的所有卡片都有上边距,既不会在顶部多出一段空距,也不需要给每个卡片加mt-12这种工具类。类似的还有列表项的边框:
.list-item + .list-item { border-top: 1px solid #f0f0f0; }用+做“除第一个外都加上边框”,是消息列表的经典处理方式。另外配合:last-child或:not(:last-child)也能做到同等效果,但+不依赖去掉最后一个元素的样式,更符合渐进增强的思路。
4.3 响应式与动态 class 场景下的选择器规划
响应式开发里,媒体查询和选择器经常配合使用。很多团队用 visible 工具类控制移动端某个模块的显隐,这时选择器的作用范围要控制好,不能出现“桌面端隐藏的模块在移动端意外显示”。
一个相对稳妥的写法是:
@media (max-width: 768px) { .mobile-only { display: block; } .desktop-only { display: none; } } @media (min-width: 769px) { .mobile-only { display: none; } .desktop-only { display: block; } }注意这里要给两个区块设置相反的初始值,而不是只写一边。否则某些情况下旧的显示状态没有清除,会出现“两个都显示”的 bug。
在 Vue 的v-for渲染列表里,数据驱动 class 绑定现在很普遍::class="{ active: index === currentIndex }",选择器层面只需要写.item.active即可。关键点是不要用位置类选择器去依赖 DOM 结构顺序,比如.item:first-child,因为增删数据后 DOM 顺序会变,动态 class 更可控。
5. 优先级机制深度解析与实战避坑
5.1 从“一列四位数字”理解权重
很多同学背过优先级计算公式,但实际用的时候还是混乱。我习惯把它想象成“四级比较”,从左到右逐级看:
| 权重等级 | 包含内容 | 记忆提示 |
|---|---|---|
| 第一级 | 内联样式style="..." | 写在标签上,优先级最强 |
| 第二级 | ID 选择器#id | 带#的,权重一下高一个档次 |
| 第三级 | 类、属性、伪类 | .、[ ]、: |
| 第四级 | 元素、伪元素 | 标签名、:: |
比较时,先看第一级,相同再看第二级,以此类推。比如:
#app .content p { color: red; } /* (0, 1, 1, 1) */ .container .content p { color: blue; } /* (0, 0, 2, 1) */第一个选择器里有一个 ID,第二个没有 ID,所以无论第二个写在多后面,#app .content p都会生效,文字是红色。这就是为什么给组件根节点加 ID 会让样式覆盖变得异常困难。
还有一个容易忽略的细节::not()伪类本身不计入优先级,但它括号里写的选择器是计入的。例如:
div:not(.active) { color: gray; }这个选择器的优先级不是.active的权重,而是div+.active的权重,即(0, 0, 1, 1)。如果后面有.text { color: black; },优先级是(0, 0, 1, 0),反而低于前者,结果div:not(.active)的灰色会胜出。
5.2 从一列四位数字理解权重
优先级相同的场景,靠的是源码后面的覆盖。很多项目维护变难,就是因为“同一层级的选择器写了太多遍”。我常用的骨架是这样的:
/* 基础形态 */ .component { ... } /* 状态变化 */ .component.is-active { ... } .component:hover { ... } /* 局部调整 */ .component .icon { ... }这样优先级自然递进,不需要反复调整顺序。如果你发现“加了一行样式不生效,非得在后面再补一行才管用”,那大概率是选择器层级写得太浅了,不如直接改选择器。
5.3!important的正确使用场景与替代方案
!important一旦用了就相当于打破优先级规则。我对团队的要求是:能用选择器优先级解决的就不要用,能用嵌套层级解决的就不要提权。它的合理使用场景确实存在,主要集中在:
- 覆盖第三方组件库的样式,且组件内部选择器把优先级抬得很高。
- 样式被内联样式影响,且不方便修改组件根节点标签属性。
- 工具类中强制保证规则不被打断,比如:
.hide { display: none !important; }但即便是工具类,也有更好的方案,例如用更高一层的命名[hidden]、[data-hidden="true"]属性选择器,或者增加.hide出现位置的优先级。大部分情况下,先检查是不是选择器写得太窄(比如漏了父级路径),再考虑!important。
还有一种调试技巧很有用:浏览器 DevTools 里选中元素后,右侧样式面板会把所有命中的规则按优先级排序打勾展示。如果你看到一条规则排在更上面却被划掉,说明优先级不足,这时不是补!important,而是该检查选择器权重了。
6. 常见错误与踩坑记录
6.1 列表中常见的选择器选中错误
列表结构里最容易犯的错误是把:nth-child当成“同类型里的第几个”来用。前面已经举过wrap的例子,这里再强调一下,因为它在消息列表、图文列表里真的能让人排查半天。
比如要做一个“每行 3 个卡片,第 3 个卡片有右边距”,新手常常写成:
.card:nth-child(3) { margin-right: 0; }如果卡片之间还夹杂了分隔符、标题等非.card元素,nth-child(3)可能选中的根本不是一个卡片。这里应该用:nth-of-type,但nth-of-type对类名不敏感,它按标签类型来计数,所以更灵活的方式是直接给每个卡片加style="margin-right"或者用动态 class。混合场景下最好就别依赖纯 CSS 的位置计数了。
6.2 选择器逗号与空格混用的坑
写选择器列表时,经常有人把后代选择器的空格和多个选择器的逗号搞混:
/* 正常的后代选择器:找 .wrap 内部的 .item */ .wrap .item { ... } /* 写错了:变成了同时选中 .wrap 和 .item */ .wrap, .item { ... }这两个表达式的含义天差地别。前者只命中.wrap内部的.item,后者会命中页面上所有.wrap元素以及所有.item元素。这种错误经常在压缩代码里出现,但在源码里用格式化工具和 Stylelint 基本能拦下来。
6.3 伪元素 content 与引号问题
content属性的值如果是字符串,一定要加引号;但如果你用的是 CSS 计数器或者attr()函数,就不用加引号。写content: "";和content: ;的区别,前一个是空字符串,后一个是语法错误。这个错误极其隐蔽,因为代码检查工具不一定能识别,最终表现就是伪元素完全消失。
我个人的习惯是:在任何::before/::after里第一行就写content: "";把占位定好,然后再去写视觉样式,不然很容易出现“样式写了但不显示”的灵异事件。
6.4 动态绑定 class 和选择器优先级冲突
Vue / React 项目里,动态 class 绑定是常规操作:
<div :class="['card', { active: isActive }]"></div>.card { color: #333; } .active { color: #fff; }当这两个选择器优先级都是(0, 0, 1, 0)时,生效颜色取决于样式表中谁写在后面。如果写的是.card.active { color: #fff; }这种更高优先级的复合选择器,才会稳定覆盖.card。很多人在业务代码里看到“加了 active 但颜色没变”,先去查 JS 绑定,其实大概率是选择器优先级不够和倒置的问题。处理方法是先检查 DevTools,再看样式来源和优先级。
7. 性能与维护视角下的选择器最佳实践
7.1 选择器解析方向与性能误区
浏览器解析选择器的方向是从右向左的,也就是先找最右边的关键选择器,再沿着 DOM 向上匹配。所以.container .text p实际是先在页面里找出所有p,再判断它有没有符合条件的祖先。
这个机制带来一个优化思路:关键选择器越具体,匹配的候选元素越少,性能越好。尽量少写div、span这种宽泛标签加长路径的表达式,多写类名。不过说实话,现代浏览器的样式计算引擎已经很快了,常规 H5 页面选择器性能问题基本感知不到。真正会导致卡顿的是在全局选择器上挂复杂嵌套,比如.list .item .inner .text ...,尤其是配合大量 DOM 节点时。
7.2 组件化开发中的选择器命名策略
现在前端组件化已成主流,选择器命名直接影响维护成本。业界常用方案是 BEM,形如.card__title、.card__title--active;也有团队用 utility-first 的 Tailwind,选择器都是工具类,几乎不写自定义样式。
我用 BEM 时最看重的是它的可预测性:.card是块,.card__title是元素,.card--large是修饰符。这种命名让选择器优先级天然收敛在低层级,不同组件之间不会互相污染。关键是不要写无限制嵌套:
.card { .card__title { .icon { ... } } }嵌套过深会让生成的选择器变得很长,可读性和性能都会劣化。Sass 的嵌套功能好用,但要克制,最多两到三层就足够表达样式关系了。
7.3 样式隔离与选择器作用域
H5 项目如果是原生 HTML 或非工程化环境,不可避免会遇到样式全局污染的问题。除了用命名规范,还可以靠选择器作用域处理。CSS Modules 或者 Vue scoped 样式是工程化后的标准方案,它们通过给元素加 data 属性或哈希属性类名来隔离作用域。如果项目没法上工程化,我常用的临时方案是给页面容器加一个固定类名,比如.page-home,然后所有业务样式都挂在它下面:
.page-home .banner { ... } .page-home .footer { ... }这样虽然不及 CSS Modules 严格,但至少能防住组件间互相干扰的问题。注意不要滥用scoped属性下的deep选择器,没有特殊需要就不要穿透作用域。
个人经验总结
把这一期笔记从头再过一遍,我自己印象最深的是优先级和:nth-child这两个坑。前端开发写样式遇到“奇奇怪怪不生效”的情况,八成不是浏览器抽风,而是规则优先级、选择器匹配方向或者空格逗号这种细节没处理好。把选择器的核心语法规则掌握清楚,比多背几十个属性值更有价值。
平时写代码我给自己定了几个小约定:不用 ID 做样式钩子、嵌套最多三层、列表加分隔线优先用兄弟选择器、伪元素一定先写content: ""。这套约定陪我从 PC 端写到 H5,从原生 CSS 写到组件库二次开发,踩坑率明显下降。你们不一定要照抄,但可以结合自己的项目总结一套“选择器红线”,比零散地记几个技巧要管用得多。
下一期可以聊聊更多伪类和伪元素的高阶用法,以及把选择器和 CSS 变量结合起来做主题切换的实战方案。