CSS3伪类和伪元素速通指南:核心区别、实战技巧与避坑要点
2026/9/23 2:44:22 网站建设 项目流程

先说一个我观察到的现象:很多前端新人写样式时,: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+13n-n+3(选取前三个)。

但这里有个非常经典的坑:nth-child是数“所有兄弟元素”的位置,而不是数“同类型元素”的位置。如果列表里混了divpp: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**。

比如你想同时给h1h2h3的标题统一加一个伪元素装饰,不用写三遍:

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”莫属。原理非常朴素:

  1. 用一个隐藏的<input type="checkbox">记录状态。
  2. :checked监听选中状态。
  3. 用兄弟选择器(+~)把状态映射到视觉元素上。

这里需要一个兄弟选择器配合:+选择紧跟在后面的那个兄弟元素,~选择后面所有的兄弟元素。

一个纯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: ""少了一个引号、或者直接没写。真事。

排查顺序可以参考:

  1. 有没有写content?没写直接GG。
  2. display是否设置?如果伪元素里写的是图片或绝对定位盒子,不需要额外设置;但如果是普通文本或空盒子,默认display: inline,宽度高度可能不生效,需要改blockinline-block
  3. 是不是被overflow裁剪了?:before是在元素内部的起始位置,父元素如果有overflow: hidden可能把绝对定位出去的伪元素裁掉。
  4. 是不是被其他层遮盖了?给伪元素加个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,10,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,不会影响后面的具体样式。这种小地方看起来不起眼,累积下来能帮你省掉特别多的调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询