写CSS伪类和伪元素这块内容,我其实挺有感触的。我记得自己刚接触前端的时候,对着:hover和::before这两个东西完全分不清,总觉得它们长得差不多,用起来好像都能往页面上加点东西,直到后来被一个::before配合content属性折腾了半天,又因为误用了:first-child踩了坑,才下定决心把这块知识彻底整理明白。CSDN上一堆文章光顾着列选择器名,不讲本质区别,结果读者背下来一道道格式,换个场景照样不会用。这篇博客我想换个思路,从“伪类和伪元素到底是什么、为什么存在、怎么去记、怎么用、踩过哪些坑”这个角度,把这块基础到不能再基础的知识掰开揉碎,给正在学CSS的朋友和工作中偶尔要用到这玩意的老手都留一份扎实的参考。
伪类和伪元素,说白了都是CSS选择器体系里非常依赖“描述状态或逻辑位置”的成员。它们俩一个修饰状态,一个创建虚拟元素,虽然只差一个字,使用起来却完全不同。CSS从诞生到现在,选择器从单一的标签、类名、ID,慢慢发展出属性选择器、组合器,再到伪类和伪元素,都是为了把“选中(目标)”这件事做得更精确、更优雅。这套知识如果你只停留在会用:hover改个颜色,那你已经掌握了大概20%的分量;剩下的80%,是它们如何配合DOM结构、如何影响渲染、如何计算优先级,以及如何在各种奇葩布局中成为救命稻草。
1. 伪类与伪元素:选择器家族里的两个隐藏角色
1.1 它们到底是什么,和普通选择器差在哪
普通的选择器,比如.box、#app、div,都是直接在现有DOM树上画圈,选中的是实际存在于HTML里的节点。伪类嘛,你可以理解成是“加了条件的普通选择器”,它不去创建新节点,而是给原来就有的元素挂上一套状态滤镜,比如用户正在点击、鼠标悬停、输入框处于聚焦状态、列表中的第几个孩子,这些都是元素本身的一种“身份标签”,但没给DOM结构添加任何东西。
伪元素就更有意思了,它是在原有元素上凭空“生造”出一个子级或片段出来,比如::before是在元素内部往前插一个虚拟的盒模型节点,::first-letter是把文字的首个字给提出来单独做渲染。这些虚拟节点原本不在DOM里,JS也拿不到,但浏览器渲染引擎会按它们计算布局,所以绘制结果真实存在。这是两者最大的分水岭:伪类是“给已有元素贴状态标签”,伪元素是“凭空生成一个可渲染的虚拟元素”。
把这个本质区别搞清楚,后面所有规则都能推导出来,不用死记。
1.2 单冒号与双冒号:不是纯粹的写法习惯
最早在CSS2的时代,伪类和伪元素统一都是单冒号的写法,比如:before、:after、:first-line。CSS3规范把规则改了,要求伪元素用双冒号::before、::after,为的是在语法层面跟伪类区分开,毕竟从语义上说这俩完全不是一个东西。为了兼容老页面,现代浏览器目前仍然允许你写单冒号版本的:before和:after,但像::selection、::placeholder这种CSS3往后新增的成员,就必须用双冒号,写在IE里也是认的,写反了就触发降级。
我习惯的项目里统一强制双冒号写伪元素,因为:
- 代码可读性更好,别人一眼就知道这是伪元素而不是伪类。
- 工程上ESLint/CSSLint也普遍要求双冒号,一处统一,全局省心。
- 老IE虽然要兼容,但比如
:before写法在IE8也认,现代项目基本不用太担心。
伪类到现在依旧是单冒号,比如:hover、:nth-child(2)、:focus-within,这个没有例外。你只要记住:单冒号,状态;双冒号,生造,就基本不会混淆写法了。
1.3 从“稀奇技巧”到“现代CSS基建”的角色变化
早期大家用伪元素,基本就是做做清除浮动,拿::after加一个clear: both的空块;或者给文本块加点首行缩进、首字下沉效果。现在情况完全变了,::before/::after已经成为制作纯CSS图标、装饰性边框、角标、加载动画、天气动效的核心素材。伪类更是不少现代布局方案的顶梁柱,比如:nth-child做表格斑马纹、:not(:last-child)去掉最后一个元素的下边框、:has()实现按子级反向控制父容器样式,这些都是我日常工作里的高频写法。
可以说,伪类和伪元素既是最简单的一层基础,也是前端组件库设计里最不能缺的一层“胶水”。任何一个现代框架,比如各种UI库的checkbox、switch开关、下拉箭头,底层几乎都靠它们去实现。
2. 伪类家族图谱:状态、结构、表单全覆盖
伪类成员很多,但我把它们分成四类来记,实用性极高。
2.1 状态类伪类:和用户交互瞬间绑定的样式开关
:link、:visited、:hover、:active、:focus这五个是最经典的“链接/交互五件套”。我们经常把它们一起写成一组规则,用起来有几个细节特别容易踩雷:
- 如果你想把五件套完整写出来,它的顺序是有讲究的,官方叫LoVe/HAte记忆法:
:link→:visited→:hover→:active。原因其实简单,:hover和:active应该覆盖:visited的样式,如果写反了,鼠标悬停到已经访问过的链接上时,hover生效不了,视觉反馈就失灵了。 :active不仅仅是作用于链接,任何元素在鼠标按下去的那一刻都会进入这个状态,我自己很喜欢用给按钮按下时做“嵌入”效果,非常直观。:focus常用于输入框、按钮、可聚焦元素,但也经常被新手忽略。现代网页的可访问性里,:focus-visible比:focus更好用,它能智能区分鼠标点击产生的焦点和键盘Tab产生的焦点,给键盘用户保留清晰的焦点环。
:focus-within是我心中的神仙伪类,它表示“当自身或后代任意一个元素获得焦点时匹配”。我最近做搜索页,希望用户点击页面内部的输入框后,整个卡片区域都能亮起边框和阴影,如果不用这个,我得用JS去监听冒泡事件,用上:focus-within,一行CSS就搞定:
.search-card:focus-within { border-color: #3575d9; box-shadow: 0 0 0 3px rgba(53, 117, 217, 0.2); }这个伪类常常能让JS代码消失得一干二净,减少不必要的监听器。
2.2 结构类伪类:给孩子排序的数学魔法
结构类伪类是CSS里“索引能力”的体现,主要围绕着child和of-type两个系列展开。先看两组最核心的区别:
:first-child:选中某个父元素下的第一个子元素,并且这个第一个子元素必须满足括号前的选择器条件。:first-of-type:选中某个父元素下同类型元素的第一个。
举个例子,如果有个HTML结构:
<div class="wrap"> <span>第一个span</span> <p>第一个p</p> <p>第二个p</p> </div>p:first-child是不匹配任何元素的,因为div下的第一个子元素是span,不是p。但p:first-of-type会选中第一个p,也就是“第二个p”的哥哥,因为它在p类型里排行老大。这个区别特别容易坑人,尤其是你从别的项目里复制一段样式过来,结构变了它就不生效了,还一脸懵。
:nth-child()和:nth-of-type()才是这两组的重型引擎,关键在括号里的公式。它支持整数、关键词odd(奇数)、even(偶数),也支持an+b形式。比如:
:nth-child(3n+1)选中的是第1、4、7个。:nth-child(-n+3)选中的是前3个。:nth-of-type(2n)选中的是同类型第2、4、6个。
记公式有个小技巧:n从0开始计数,整个式子代入算出来的值是选择器定位的索引,索引从1开始,算出来是0就是无效。所以:nth-child(2n)代入n=0得0无效,n=1得2,n=2得4,选的是偶数位。:nth-child(2n+1)代入n=0得1,后面3、5、7,就是奇数位。
还有:only-child(父下只有一个孩子)、:empty(没有任何子节点,包括文本和空格),以及几乎不用的:root(匹配文档根元素,HTML页面里就是html标签,常用它来定义全局变量)。:empty特别适合用来给空状态加提示样式,比如评论里一个空div,用.comment:empty::after { content: "还没有评论"; },很自然地就填充了占位文案。
2.3 表单类伪类:表单验证的状态灯
表单是前端绕不开的领域,状态类伪类负责交互,表单类伪类负责“这个输入框现在有没有填写、合不合法、可不可用”。常用的有这么几个:
:checked:选中状态的 radio、checkbox、option。配合+相邻兄弟选择器,能玩出完全脱离JS的自定义开关。:disabled和:enabled:控件有没有被禁用。:required和:optional:空间是否设置了必填属性required。:valid和:invalid:按约束验证规则判断输入合法与否。比如input[type="email"]填了个错误的邮箱地址,就会命中:invalid。:placeholder-shown:输入框有没有正在显示占位提示。这个在移动端表单里太方便了,能根据是否输入来改变label的浮动状态。
我自己反复用的一套是用:valid和:invalid给输入框加校验反馈,不用任何JS。比如给必填邮箱输入框加上绿色边框、红色边框的对照。不过要注意,部分浏览器对表单伪类支持程度略有不同,严格的表单校验在需要跨浏览器时我还是会配合JS,但视觉反馈层面它的体验已经足够流畅。
2.4 逻辑类伪类:选择器界的超集与降级
:not()、:is()、:where()、:has()是现代CSS选择器里非常有表现力的一组,我几乎每个项目都要用到。
:not(selector)是最常见的“排除法”。注意两点:
- 它的括号里现在支持选择器列表,比如
.item:not(.active, .disabled),既不是active也不是disabled的才命中。 :not的优先级与其参数中优先级最高的选择器相同,这点很反直觉,后面专门讲。
:is()是“匹配任意一个即可”,用来简化冗长的选择器列表。比如你在写主题样式时,h1:is(.title, #main-title, [data-type="title"])一条等于过去三条规则。它的优先级也是取参数里最高优先级,经常导致意想之外的权值提升,这是个大坑,后面细聊。
:where()和:is()功能类似,但优先级永远是0,这是个非常实用的特性。当你想写一个基础组件样式,又希望使用者能轻松覆盖时,用:where()包一层再合适不过,它把本身应该有的优先级完全交给后端使用者去决定。我在项目中有一个原子类工具类,底层就是用:where()给要封装的组件包一层,确保任意业务样式都能覆盖,彻底绕开后期加样式还得强制加!important的窘境。
:has()是重磅级的“父选择器”能力,:has(> img)表示“如果自身直接子级里存在img,就匹配”。目前浏览器支持已经非常好了。我拿它做过一个卡片组件:当卡片内包含图片时自动加上内边距和圆角,不包含图片时用另一套触底样式,代码干净程度拔高一个档次。
伪类到这里,常用的基本就这些。你不需要一次全记住,但要知道有这个东西存在,遇到场景能想起来“哦,好像有个伪类能处理它”,再回头查文档,这就是高手的工作方式。
3. 伪元素实操宝典:最常用的四个成员
伪元素里,::before和::after是绝对的主角,但剩下的成员也不容忽视,每个都有它发挥的场景。
3.1 ::before 与 ::after:核心永远是 content 属性
几乎所有关于::before和::after的坑,最后都能归结到三个点上。
第一,content 属性绝不能省略。就算你只想添加一个纯装饰背景,也必须写content: ""。不写它,伪元素直接消失,这是最多新手踩的坑。第二,这两个伪元素默认是行内元素,也就是说设置width、height、margin上边距这类盒属性,如果不先把它转成display: inline-block或display: block,就会毫无效果。第三,伪元素的 content 可以不是字符串,而是实际属性值。content: attr(data-icon)可以直接取元素上的>.clearfix::after { content: ""; display: table; clear: both; }
3.2 ::first-letter 与 ::first-line:排版里的“细节控”
::first-letter负责首字排版,比如段落第一个字放大下沉。传统做法是手动包一层<span>,非常呆,伪元素一行就能解决。它对中文同样生效,支持大部分字体、字号、颜色、边框属性,注意别过度使用,每行首字都放大,页面会变得很难读。
::first-line则是给块级元素的第一行上样式。这个大家在处理“首行缩进”的文案场景时会用,或者给长标题第一行设置不同的颜色。关键点在于:这两个伪元素只适用于块级容器内的文本。如果你想在浮动布局里给文本用,可能就失效了,这跟渲染引擎对“首行文本”的定义有关,不展开,但你要知道这个限制。
3.3 ::selection 与 ::placeholder:细节体验的加分项
::selection控制用户用鼠标选中文本时的背景色和文字色。很多人以为这只能由浏览器默认决定,实际上完全可以定制,而且支持的颜色属性有限,基本就是color、background、text-shadow这几个。克制地设置站点统一的主色调选中背景,非常出彩。
::placeholder修改输入框的占位提示样式。要注意它支持的属性范围也很有限,主要是字体相关、颜色。踩坑点在于,不同浏览器的::placeholder实现差异较大,某些老版本还要加浏览器前缀。做跨浏览器时最好把::-webkit-input-placeholder、::-moz-placeholder都写上,不过这主要历史债了,现代项目无需太过操心。
另外,::marker的实用程度可能被很多人忽略了,它控制列表项或summary元素默认的标记符。比如:
li::marker { content: "→ "; color: #e57373; }这条规则能把所有列表项前的小圆点换成箭头,直接逃离大量伪元素画形状态的重复劳动。
3.4 伪元素经典案例:从零实现一个自定义开关
纸上谈兵没用,放一个我能反复使用的案例实战:用 label 伪装成 checkbox,并用::after绘制勾选效果。
先说明一下,原生input[type="checkbox"]内部是不能放伪元素的,但我们可以把它视觉上藏起来,用label配合伪元素来实现自定义勾选。
HTML结构:
<label class="checkbox"> <input type="checkbox" class="checkbox-input"> <span class="checkbox-box"></span> 我同意用户协议 </label>CSS关键代码:
.checkbox { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } .checkbox-input { position: absolute; opacity: 0; width: 0; height: 0; } .checkbox-box { position: relative; width: 20px; height: 20px; border: 2px solid #cbd5e0; border-radius: 4px; background: #fff; transition: all 0.2s; } .checkbox-input:checked + .checkbox-box { background: #3575d9; border-color: #3575d9; } .checkbox-input:checked + .checkbox-box::after { content: ""; position: absolute; left: 6px; top: 1px; width: 5px; height: 10px; border-right: 2px solid #fff; border-bottom: 2px solid #fff; transform: rotate(45deg); }这里的:checked加在隐藏的input上,再用+相邻兄弟选择器控制后面盒子的样式,而最终的对勾则画在label的::after上。这个套路前后用了无数遍,就是:原生控件保留可访问性和键盘操作,伪元素负责视觉层。实践时要注意,隐藏input别用display: none,否则键盘Tab焦点和屏幕阅读器都可能读不到,用position: absolute; opacity: 0的视觉隐藏方案更稳。
4. 优先级计算与伪类伪元素的“隐藏权值”
4.1 选择器优先级里伪类伪元素占的分量到底是多少
如果你写过几行CSS,一定遇到过“明明写了样式怎么不生效”的灵异事件,原因多半出在优先级上。CSS优先级用三维向量表示:(内联样式,ID选择器数量,类/属性/伪类数量,元素/伪元素数量)。注意伪类和类选择器同样权重,伪元素和元素选择器同样权重。
举个直观的例子:
/* 优先级 (0, 1, 1) */ div .item:hover { color: red; } /* 优先级 (0, 2, 0) */ .item:first-child { color: blue; }第二条的类加伪类的权重是两个“类级”,压过第一条的“类+元素”,所以即便第一条后写在样式表里,最终也会是blue生效。这个很容易被人忽略,因为看起来div .item:hover好像包含了更多信息。我建议调试优先级问题时,心里快速按“位数”算一遍,而不是凭直觉猜。
4.2 :is() 与 :not() 的优先级陷阱,以及 :where() 的降级救赎
:is()和:not()有同一个非常反直觉的规则:它们的优先级由参数里优先级最高的那个选择器决定。举例:
:is(#app, .box) p { color: red; } .box p { color: blue; }第一条的优先级是 (1, 0, 1),因为:is()取到了#app这个ID层级,而第二条只有 (0, 1, 1),所以即便#app都没出场,只要文档里.box里的p被匹配,第一条无论写在前面还是后面,都会强行获胜。这就造成了很难解释的样式覆盖问题。
而:where()则完全相反,它的优先级恒定为0,括号里面塞再强的选择器也不影响最终权值。这句话的实际意义用大白话翻译就是:用:where()写的样式,极易被后续任何有优先级的规则覆盖。因此我强烈建议,写组件基础样式、希望使用者能灵活覆盖的客制化CSS,优先考虑:where();写需要强制兜底、不允许业务覆盖的系统级规则,就避免用它,转用:is()反而能获得“自动拔高权重”的效果。摸清这个差异,很多覆盖战争的死结就解开了。
4.3 选择器混淆时的调试方法:浏览器DevTools是最快解药
再说一个在实际项目里极其有用的手段。遇到样式不生效或者被覆盖,不要靠肉眼去猜,直接把Chrome DevTools打开,选中目标元素,在右侧“Styles”面板里可以看到所有命中该元素的CSS规则,每条规则旁边都会标注文件来源和行号,还能看到被划掉的样式以及原因。更高阶一点,DevTools的“Computed”面板里能直接计算最终生效的属性值。
重视这个小习惯,它能帮你快速定位优先级问题,比你把整个样式表翻一遍高效得多。我见过太多人调试样式的时候从头到尾改来改去,就是不开开发者工具看具体命中的规则,完全靠试错,那效率太低了。
5. 常见问题与排查技巧实录
5.1 疑难杂症速查表:为什么我的伪元素“消失”了
我把这些年遇到过的伪类伪元素翻车现场整理成一个速查表,方便你之后照着排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
::before/::after无效 | 忘写content,或想作用在img等替换元素上 | 加上content: "",换到可包裹元素上 |
| 伪元素设置了宽高没反应 | 伪元素默认inline,盒模型属性不生效 | 加display: block/inline-block |
:first-child怎么都不命中 | 前面有别的兄弟元素占着老大的位置 | 改用:first-of-type或:nth-child调整索引 |
:hover卡在移动端不消失 | 触屏设备上:hover会“粘住”元素 | 用媒体查询区分设备,移动端改用:active |
:not(.a, .b)无效 | 太老的浏览器不支持参数列表 | 拆开写:not(.a):not(.b) |
| checkbox自定义样式不显示 | 原生input内部禁用伪元素 | 用label+相邻兄弟选择器实现 |
| 样式被神秘覆盖 | :is()/:not()取到高优先级参数 | 检查优先级向量,必要时用:where()隔离 |
5.2 移动端适配与可访问性的几个实战细节
移动端是伪类伪元素最容易出幺蛾子的地方,我重点说几个实践心得:
第一,hover问题的本质。在触屏设备上,手指第一次触碰元素会触发:hover,但不会在抬手后立刻消失,常常“粘”在那里,直到你点击别的区域才变化。所以在移动端写交互反馈,我一般用:active或者JS的触摸事件来控制,:hover只在@media (hover: hover)的桌面设备上保留。CSS的hover查询条件可以精准区分这两种设备,是一种很规范的写法。
第二,可访问性层面,别把所有焦点样式粗暴地一刀切清零。过去很多同学喜欢outline: none一键去边框,但这会让键盘用户彻底失去焦点定位。现代方案是:用:focus-visible来控制焦点环的显示逻辑,鼠标点击时不显示,键盘Tab时显示。这样“好看”和“好用”能同时兼得。追求组件库质感时我特别推崇这个做法。
第三,隐藏input的方法。千万不要用display: none去隐藏原生的checkbox/radio,这会让元素彻底在可访问性树里消失,键盘用户、屏幕阅读器都没法操作。正确姿势是视觉隐藏,让它还在文档流里但不可见,具体写法可以搜“visually-hidden”,业内已经有非常标准的实现方案,核心是position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden;。
5.3 性能与维护的几点倡议
伪类伪元素用得好不仅不伤性能,反而能帮页面减重。这里我给几条务实的建议:
- 伪元素不产生真实DOM节点,所以画装饰性图层优先考虑它,能少写一个span就少写一个。我经常把角标、气泡尾巴、分割线全部用伪元素画,HTML干净得像刚擦过的白板。
- 尽量避免在大量列表项上动态变换伪元素的内容,比如对成百上千行的
nth-child去写动画,那是给浏览器渲染引擎上强度。 - 不要写过于宽泛的逻辑伪类选择器,比如
:has(*)这种,虽然现在支持度不错,但它的解析成本比普通选择器高不少,局部用无碍,全局瞎用会拖慢样式重算。 - 给伪元素做动画时,多利用
transform和opacity,像用:hover改变left值这种,会触发布局抖动,肉眼可见的掉帧。
我的实操习惯是:加减速和移动相关的伪元素动画,能上transform的绝不直接改top/left。先让页面顺滑,再谈功能丰满。
6. 进阶玩法:现代CSS里伪类和伪元素的联合表演
6.1 计数器:伪元素 content 的另一面
教科书里很少仔细讲counter(),但它在文档序号、标题编号、试卷自动编号这类场景里非常管用。原理很简单,先用counter-reset定义计数器初始值,再在某类元素上用counter-increment递加,最后通过伪元素的content渲染出来。
比如给文章标题按章节自动编号:
body { counter-reset: chapter; } h2::before { counter-increment: chapter; content: "第 " counter(chapter) " 章 "; color: #3575d9; }这样所有h2会自动依次显示“第 1 章”“第 2 章”……我要删掉中间某段,序号自动重排,根本不用去HTML里手工改数字。层级编号则可以通过counter-reset配counter(section) "." counter(subsection)做出多级效果,这在做周刊、文档站的时候格外好用。
6.2 纯CSS手风琴和抽屉::checked + 兄弟选择器 + ::after
做交互组件,动不动上JS反而是负担。比如一个简洁的tab切换或手风琴,用:checked配上基础选择器就能实现无JS联动,执行性能还很好。我先放出最经典的做法:
<section class="accordion"> <input type="radio" id="item1" name="acc" checked> <label for="item1" class="acc-item">第一项</label> <div class="acc-panel">内容一</div> <input type="radio" id="item2" name="acc"> <label for="item2" class="acc-item">第二项</label> <div class="acc-panel">内容二</div> </section>.acc-panel { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } input[id="item1"]:checked ~ .acc-panel, input[id="item2"]:checked ~ .acc-panel { max-height: 200px; }配合:checked状态控制面板的展开,再加上label上的伪元素做箭头旋转,整个过程完全无JS。这个方案在移动端非常轻量,展开动画配合transition能做到60fps顺滑反馈。注意max-height定得足够高,否则内容一长就会截断。
6.3 用 :has() 强化伪类伪元素之间的联合表达
:has()虽然本身是伪类,但它常常和伪元素组合出非常优雅的效果。比如我想在“包含图片的文章卡片”前自动加一个“图”字角标,可以这样:
.card:has(img)::after { content: "含图"; position: absolute; top: 8px; right: 8px; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; padding: 4px 8px; border-radius: 4px; }:has(img)负责判断,::after负责展示,两兄弟协作一把搞定。:has()的支持度已经足够主流,只要不维护太老的浏览器,这个能力就能放心用。
我会在工作里刻意训练这种“用伪类判断状态、用伪元素绘制视觉”的思维模式,它能大幅度减少JS对样式层的侵入,保持视图逻辑和状态逻辑的分离,这既是代码质量的一部分,也是个人习惯的培养方向。
7. 最后一个保命建议:先分类,再记忆,后实战
伪类和伪元素的知识,单独背列表是记不住的,因为成员太多,条件太碎。我的建议是:第一,先建立起“状态 vs 虚拟元素”的分类思维,这样遇到任何一个新选择器都能马上归位,推导使用方式。第二,把上面提到的常用成员做成自己的速查卡片,哪怕一开始贴在手边,遇到场景翻一翻,用多了自然就熟。第三,也是最关键的,一定要在真实项目或者自造的codePen实验页里多写多试,踩几个坑记住的比看十篇文章都牢。
最后再分享一个小技巧:如果你和我一样经常在写样式时被“这个选择器到底支不支持”绊住,建议直接用Can I Use查支持度,而不是记浏览器版本。现代浏览器迭代太快,旧acc的破事已经不是普通前端需要操心的了。真正决定你水平上限的,往往不是记住多少API,而是你能不能把选择器、状态、优先级这些基础概念组合成行云流水的解决方案。伪类和伪元素恰恰是这块基石中门槛最低、收益又最明显的一角,花点时间彻底吃透,怎么算都不亏。