先说一个我观察到的现象:很多前端新人写样式时,:hover、:active这类用得挺顺手,但一碰到::before、::after就开始犯迷糊——要么内容死活不显示,要么分不清到底该用单冒号还是双冒号。去面试的时候被问一句“伪类和伪元素有什么区别”,当场卡壳的更是一抓一大把。我自己刚入行那阵子,也在这个地方栽过跟头,后来带新人才发现,这几乎是所有人都绕不过去的一个坎。这篇速通指南不整虚的,就把CSS3伪类和伪元素这块从底层逻辑到高频实战,再到我这些年踩过的坑一次性理清楚,保证看完就能直接用。
1. 伪类和伪元素到底差在哪:先搞定这件最容易被问懵的事
很多人把伪类和伪元素混着叫,其实它俩是两条完全不同的技术路线。理解它们背后的设计逻辑,比死记硬背一百个写法都有用。
1.1 伪类:元素在特定状态下的“快照”
伪类的核心是“状态”。它描述的并不是元素本身,而是元素处在某种状态时的样子。
比如一个按钮,它是普通状态、鼠标悬停状态、还是聚焦状态,这就是不同的“状态快照”。CSS里用:hover去命中悬停状态,用:focus去命中聚焦状态,用:disabled去命中禁用状态——这些都不会产生新的元素,只是给同一个元素在不同时刻穿上不同的衣服。
专业一点的说法是:伪类选择器选中的始终是真实存在的DOM元素,它的本质是一种根据状态、位置、结构关系进行匹配的选择器。所谓“伪”,在于它不是通过类名、ID等方式去指定,而是通过“条件”去命中。
理解这一点后,面试官问“伪类有哪些”,你就不会傻傻只报:hover了。状态类的、结构类的、表单类的、逻辑类的,每一类你都能说出一串(下一章我会逐个拆解)。
1.2 伪元素:虚拟出来的“假”节点
伪元素就不一样了,它是真正在文档流里“创建”了一个肉眼可见、但不存在于HTML里的元素。
最典型的就是::before和::after。你写div::before { content: "你好" },浏览器会在div内部的起始位置“塞进”一个内容为“你好”的虚拟盒子。这个盒子可以设置宽度、高度、背景、边框,能参与布局,能变形,能动画——在渲染层面它跟一个真实子元素几乎没有区别。
但它在DOM树里并不存在,你用document.querySelector找不到它,给父元素设置overflow: hidden能裁剪它,但从JS层面你无法直接给它绑定事件(这个坑后面详细讲)。
所以一句话总结:伪类命中“状态”,伪元素生成“虚拟节点”。状态不改变结构,虚拟节点会改变渲染结果但不改变DOM结构。
1.3 单冒号和双冒号的兼容底线
CSS3规范里,伪类用单冒号(:hover),伪元素用双冒号(::before)。这样设计是为了从语法上就把两者区分开。
但历史遗留问题在于——IE8及更早的浏览器不认识双冒号写法,只支持:before这种单冒号写法。所以很长一段时间,前端为了兼容IE8都会写单冒号。现在这个时代,除非你的项目还在维护古董级浏览器,否则一律推荐双冒号写伪元素,语义清晰,也不会被代码审查的人唠叨。
注意:
::before和:before在CSS2时代是同义的,但CSS3规范中单冒号仅用于伪类。哪怕你为了兼容性写单冒号,浏览器也会按伪元素处理,只是写法上过时了。
2. 高频伪类逐个拆:状态、结构、表单三大应用场景
伪类这个家族数量非常多,但日常工作里高频出现的就那十几个。我按使用场景把它们分成三类,每个类别给到能直接抄的写法。
2.1 状态类:交互反馈的“开关”
状态伪类最常见的就是:hover、:active、:focus、:focus-within、:focus-visible这几个。
:hover:鼠标悬停时触发,适合按钮变色、卡片阴影浮起。:active:元素被按下的瞬间触发,适合模拟按钮按下去的效果。:focus:元素获得焦点时触发(点击、Tab键切换),适合输入框高亮边框。:focus-within:自身或后代元素获得焦点时触发,这个很实用——一个表单区域里任意输入框被聚焦,整个区域都能高亮。:focus-visible:只有键盘导航(比如Tab键)触发的焦点才生效,鼠标点击不触发。做无障碍优化时这个是标准答案,避免鼠标点击后输入框出现多余的focus环。
.input-wrapper:focus-within { border-color: #409eff; box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2); } button:focus-visible { outline: 2px solid #f56c6c; outline-offset: 2px; }再补充一个很多人不知道的:target——当URL的锚点指向某个元素时触发。比如#section1,那么id="section1"的元素就能被命中。做单页文档站、FAQ折叠面板时,这个伪类可以省掉一堆JS判断。
2.2 结构类::nth-child系列是重灾区
结构伪类里,:first-child、:last-child、:nth-child()、:nth-of-type()是最常用的,也是出错率最高的。
典型的斑马纹表格:
tbody tr:nth-child(odd) { background: #f9f9f9; } tbody tr:nth-child(even) { background: #fff; }nth-child()支持的关键字有odd(奇数)、even(偶数),也支持公式,比如2n+1、3n、-n+3(选取前三个)。
但这里有个非常经典的坑:nth-child是数“所有兄弟元素”的位置,而不是数“同类型元素”的位置。如果列表里混了div和p,p:nth-child(2)可能选中的不是第二个p,而是第二个兄弟节点(哪怕它是div)。
这时候要换成:nth-of-type(),它是按“同类标签”来计数的。
/* 选择第二个<p>标签,中间即使混着<div>也不影响 */ div.container p:nth-of-type(2) { color: red; }还有个容易忽略的:empty,匹配没有任何子节点(包括文本节点)的元素。比如用Vue、React渲染列表时,空状态提示可以用它做,不用在模板里写v-if判断。
2.3 表单类:交互状态全靠它
表单是伪类出现密度最高的地方,配合HTML原生属性可以省下一堆JS。
:checked:单选框、复选框被选中时触发。:disabled:表单元素被禁用时触发。:required/:optional:表单元素有required属性时触发(:optional是没加的)。:placeholder-shown:输入框的placeholder可见时触发,这个做浮动标签(float label)效果非常好,比JS监听input事件优雅得多。:valid/:invalid:配合HTML5表单验证,内容合法/不合法时触发。
input:invalid { border-color: #f56c6c; } input:valid { border-color: #67c23a; }不用写一行JS,浏览器自带校验和样式反馈,这个放在表单页里非常省心。
3. ::before 和 ::after 实战:从清除浮动到动态提示
这两个伪元素是前端日常使用频率最高的“虚拟工具人”,很多经典布局和组件都靠它们来实现。
3.1 所有伪元素的地基:content 属性
::before和::after有一个强制要求——必须设置content,否则根本不渲染。哪怕你只想用它画一个空盒子,也必须写content: "",把内容置为空字符串。
.card::before { /* 漏了content,整个伪元素直接消失 */ content: ""; width: 100px; height: 100px; background: red; }这是新人踩得最多的坑之一:样式辛辛苦苦写了一大堆,忘了content,打开浏览器一看——啥也没有,然后怀疑是选择器写错了。
content可以不只是空字符串。它可以填普通文本、字符、属性值(attr())、计数器(counter()),甚至可以引用图片content: url(...)。但这些用法里,attr()和counter()是最能体现伪元素价值的。
3.2 清除浮动:每个前端都写过的 clearfix
以前用float布局的年代,父元素高度塌陷是家常便饭。业界标准的解法就是::after:
.clearfix::after { content: ""; display: block; clear: both; }原理是:clear: both会让这个虚拟块元素跑到浮动元素的下方,从而把父容器的高度“撑开”。现在flex和grid已经是布局主流,这个方法用得没那么频繁了,但在某些仍依赖float的场景(比如某些邮件模板、老项目维护)依然是标准答案。
3.3 attr() 函数:让伪元素变成“动态标签”
attr()可以在CSS里读取当前元素的HTML属性值,并作为文本渲染出来。这意味着不需要改DOM结构,就能给一个元素增加动态的文字内容。
最常见的场景是Tooltip(提示框):
<button class="btn-tip">.btn-tip { position: relative; } .btn-tip::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.75); color: #fff; padding: 4px 8px; font-size: 12px; border-radius: 4px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.2s; } .btn-tip:hover::after { opacity: 1; }这里>.bell { position: relative; width: 24px; height: 24px; } .bell::after { content: ""; position: absolute; top: -2px; right: -2px; width: 8px; height: 8px; background: #f56c6c; border-radius: 50%; border: 2px solid #fff; }
再比如卡片顶部的多彩色条、列表项的序号数字、边框高光的渐变遮罩——这些全是::before/::after的舒适区。写伪元素时有一个核心心法:它是“装饰层”,不是“内容层”。真正语义化的内容应该放在HTML里,视觉装饰才交给伪元素,这样对屏幕阅读器也更友好。
4. 高级组合玩法:伪类伪元素一起用的高阶写法
单独使用伪类或伪元素只能解决基础需求,把它们组合起来才能发挥真正的威力。这一章讲的都是面试里加分、实战里真香的组合玩法。
4.1 :not() 排除选择器,减少冗余类名
:not()可以把不符合条件的元素排除掉。以前要实现“最后一行的间距处理”,常见做法是给最后一个元素加个特殊类名,现在直接写:
/* 除了最后一个,其他都加margin */ .list-item:not(:last-child) { margin-bottom: 16px; }列表的竖线分隔符也是个经典场景:
.nav-item:not(:last-child)::after { content: "|"; margin: 0 12px; color: #ccc; }:not()在CSS3里只支持简单选择器,但在CSS Selectors Level 4里已经支持复杂参数了(比如:not(div p)),主流浏览器基本都兼容。需要注意的是,:not()不参与优先级计算,它内部的参数才参与——这句话很多人不理解,后面章节细说。
4.2 :is() / :where():选择器的“分组语法糖”
:is()和:where()都是“选择器分组”的语法糖,区别在于**:is()的优先级取参数中最高的那个,:where()的优先级永远是0**。
比如你想同时给h1、h2、h3的标题统一加一个伪元素装饰,不用写三遍:
h1::before, h2::before, h3::before { content: "◆"; margin-right: 8px; color: #409eff; }写三遍还不算离谱,但如果每个候选选择器都很长,比如header nav ul li a::before这种,重复写就非常痛苦。:is()可以这样吸一口:
:is(h1, h2, h3)::before { content: "◆"; margin-right: 8px; color: #409eff; }:where()更适合做“基础样式重置”,因为它优先级为0,后面任何一条具体样式都能轻松覆盖它。如果你搭建过UI组件库,reset.css里用:where()写默认样式,可以省掉一大堆!important。
4.3 用 :checked + 兄弟选择器实现纯CSS开关
要说伪类组合玩法里最经典的例子,非“checkbox hack”莫属。原理非常朴素:
- 用一个隐藏的
<input type="checkbox">记录状态。 - 用
:checked监听选中状态。 - 用兄弟选择器(
+或~)把状态映射到视觉元素上。
这里需要一个兄弟选择器配合:+选择紧跟在后面的那个兄弟元素,~选择后面所有的兄弟元素。
一个纯CSS的开关按钮:
<input type="checkbox" id="switch" class="switch-input"> <label for="switch" class="switch-label"></label>.switch-input { display: none; } .switch-label { display: inline-block; width: 48px; height: 26px; border-radius: 13px; background: #ccc; position: relative; cursor: pointer; transition: background 0.3s; } .switch-label::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform 0.3s; } .switch-input:checked + .switch-label { background: #409eff; } .switch-input:checked + .switch-label::after { transform: translateX(22px); }这里面用到了两个关键点:一是:checked伪类;二是+兄弟选择器,让label跟随checkbox的状态变化。整个交互没有一行JS,全靠CSS状态驱动,这就是伪类的精髓——用声明的方式描述UI在不同状态下的表现,而不是用命令的方式告诉浏览器怎么做。
同样的思路还可以做折叠面板、Tab切换、移动端抽屉菜单,原理都是隐藏的radio/checkbox + 兄弟选择器 + 伪类。
4.4 :has() 到来:一个能选“父元素”的伪类
:has()是这个家族里的新贵,堪称“父亲选择器”。它可以选择“包含某个后代”的元素,甚至可以根据后代的多少来匹配。
最经典的用法是:当卡片里有一张图片时,改变卡片的布局。
.card:has(img) { padding: 0; }再比如表单里,当输入框有错误提示时改变整个表单区域的样式:
.form-field:has(input:invalid) .error-hint { display: block; }:has()能匹配的不止是后代,还可以匹配兄弟状态。比如:
label:has(~ input:checked) { color: #409eff; }这意味着以前必须用“checkbox hack”绕好几层的写法,现在可以比较直接地表达。目前在主流现代浏览器中:has()已经可以被使用了,新项目可以放心用,但在维护老项目的时候还是先查一下兼容性再说。
5. 避坑实录:我踩过的和新人最爱踩的那些坑
这一章是正文的重点。速度和套路再多,坑踩不住,全白搭。以下每一条都是真实项目的血泪史。
5.1 ::before 不显示,先检查 content
前面已经提过第一次,但这里值得再强调一次,因为它太容易发生了。
我见过一个同事排查了一下午为什么::before不显示,各种选择器优先级、层叠上下文都翻了一遍,最后发现是content: ""写成了content: ""少了一个引号、或者直接没写。真事。
排查顺序可以参考:
- 有没有写
content?没写直接GG。 display是否设置?如果伪元素里写的是图片或绝对定位盒子,不需要额外设置;但如果是普通文本或空盒子,默认display: inline,宽度高度可能不生效,需要改block或inline-block。- 是不是被
overflow裁剪了?:before是在元素内部的起始位置,父元素如果有overflow: hidden可能把绝对定位出去的伪元素裁掉。 - 是不是被其他层遮盖了?给伪元素加个
z-index试一下。
5.2 为什么伪元素点不了?因为它是“影子”
伪元素虽然在视觉上像子元素,但它不是真实DOM节点。这意味着:
- 不能用
document.querySelector('::before')去选中它; - 不能直接给它绑定
click事件; - 在部分场景下,伪元素的视觉区域会阻挡鼠标事件穿透到下面的真实元素。
针对第三点有个非常经典的坑:Tooltip提示框的::after悬浮气泡默认也跟着hover触发,但鼠标移动到气泡上方时,由于气泡是伪元素不属于任何真实DOM节点,在某些浏览器里mouseleave可能不会被正确触发,导致气泡关不掉。
解决办法是在伪元素上设置pointer-events: none(上面Tooltip例子里的代码就写了这一行),让点击事件直接穿透伪元素,作用到真实元素上。
.tooltip::after { pointer-events: none; }这是我能给出的最实用的防坑建议之一,伪元素纯粹当作视觉层,事件一律穿透,不要让它“接住”鼠标。
5.3 :nth-child 和 :nth-of-type 的区分场景
前面提过这个坑,这里给一个具体的对比例子。假设HTML是这样的:
<div class="list"> <p>第1行</p> <span>分隔</span> <p>第2行</p> <p>第3行</p> </div>:nth-child(2)选中的是<span>,因为它是父元素下的第二个子节点;而p:nth-of-type(2)选中的是“第2行”的那个<p>,因为它按<p>标签单独计数是第2个。
如果规则是p:nth-child(3),那选中的是“第2行”的<p>——因为它是第三个子节点。
这里的经验是:当你想要“第三个同类元素”时,永远用:nth-of-type;当你想要“第三个子节点”时,用:nth-child。当列表里元素类型混乱时,两者的差异会直接导致样式错乱,调试时完全懵掉。
5.4 伪元素优先级:参与计算的权重比你想象的高
很多新人以为伪元素是“虚拟”的,优先级应该低一些。实际上伪元素和真实元素一样参与CSS优先级计算,它的权重就是一个元素选择器的权重。
举个例子:
/* 完全相同的结构 */ .box::after { background: red; } .box { background: blue; }这两个选择器的特异性分别是0,0,1,1和0,0,1,0,因为.box::after多了一个伪元素(相当于一个元素选择器)。所以上面的规则会让伪元素的背景生效——这没问题。
但如果你认为“伪元素是后加的应该低优先级”,那排查样式冲突的时候就会很纠结。记住一个口诀:伪元素当“真实元素”看待,伪类当“状态类名”看待。伪元素参与权重计算,伪类的权重跟类名一样(0,0,1,0),这对层叠和覆盖逻辑影响很大。
5.5 旧浏览器兼容:单冒号还是双冒号
现在的浏览器基本都支持双冒号了,但如果你需要兼容老版本浏览器(比如某个项目的后台系统用的是老内核浏览器),那么:
- 伪类统一使用单冒号写法。
- 伪元素推荐写成单冒号以兼容旧浏览器,但要注意:CSS3新增的一些伪元素没有单冒号写法,比如
::selection。
下面这个速查表可以帮你快速把握兼容性和优先级:
| 写法 | 类型 | 兼容底线 | 优先级 |
|---|---|---|---|
:hover | 伪类(状态) | IE4+ | 相当于类名 |
::before(推荐) | 伪元素 | IE9+ | 相当于元素名 |
:before(旧兼容) | 伪元素 | IE8+ | 相当于元素名 |
::selection | 伪元素 | IE9+(无单冒号版) | 相当于元素名 |
:nth-child() | 伪类(结构) | IE9+ | 相当于类名 |
:not() | 伪类(逻辑) | IE9+ | 不参与自身权重计算,参数参与 |
:has() | 伪类(关系) | Chrome 105+/Safari 15.4+ | 同伪类 |
作为普通前端开发,知道这些兼容边界就够了。真正写代码时如果不是老项目,闭眼用双冒号就行。
5.6 伪元素打印时莫名消失,注意media print
伪元素在打印样式里容易表现异常,最常见的就是打印时::before/::after的内容不显示。
有次我调试一个打印功能,页面上有“打印专用水印”,用::after在页面四角输出公司名,浏览器里看着好好的,一打印就没。查了半天发现是打印样式里写了background: white,而水印是背景图加文本,文本虽然没问题,但背景图被浏览器打印的默认设置丢掉了(浏览器默认不打印背景图)。这个不能怪伪元素,是背景打印的坑。
如果业务要求打印时必须显示某些内容,建议直接放在真实DOM里,而不要用伪元素实现;如果必须在打印里保留,给伪元素设置-webkit-print-color-adjust: exact; print-color-adjust: exact;试试。
另外,display: none元素上的伪元素不会渲染,这个好理解,但**visibility: hidden元素上的伪元素也不会显示**,因为它的父元素都不可见了。
顺着前面的思路,实战中还有一个技巧可以提一下:伪元素是“状态驱动”设计思路和组件化思维结合的绝佳例子,尤其在写UI组件的时候,能用CSS状态表达的行为就不要用JS去操作DOM类名。
我个人在实际开发中的体会是:遇到一个视觉和交互需求,先想“这个能不能用伪类/伪元素实现”,而不是直接上JavaScript。很多悬浮、焦点、选中、校验反馈,其实CSS自身就能完成,代码更轻、性能更好、逻辑也更明确。等你习惯了这个思考方式,再回头看看那些“为什么别人CSS写这么精简”的代码,你就能懂了。
最后分享一个我一直在用的小习惯:每次新建项目,我都会在全局样式文件最顶上写一段极简的reset,里面用:where()给所有伪元素定一个默认样式,比如::before, ::after { box-sizing: border-box; },这样能避免大量盒模型计算上的意外,反正:where()优先级是0,不会影响后面的具体样式。这种小地方看起来不起眼,累积下来能帮你省掉特别多的调试时间。