资讯动态

CSS3伪类和伪元素速通指南:核心区别、实战技巧与避坑要点

发布时间:2026/9/23 2:44:35 来源:尧图企业网站定制
先说一个我观察到的现象很多前端新人写样式时: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那么idsection1的元素就能被命中。做单页文档站、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偶数也支持公式比如2n1、3n、-n3选取前三个。但这里有个非常经典的坑nth-child是数“所有兄弟元素”的位置而不是数“同类型元素”的位置。如果列表里混了div和pp: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 classbtn-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 typecheckbox记录状态。用:checked监听选中状态。用兄弟选择器或~把状态映射到视觉元素上。这里需要一个兄弟选择器配合选择紧跟在后面的那个兄弟元素~选择后面所有的兄弟元素。一个纯CSS的开关按钮input typecheckbox idswitch classswitch-input label forswitch classswitch-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 classlist 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不会影响后面的具体样式。这种小地方看起来不起眼累积下来能帮你省掉特别多的调试时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价