
CSS 这个领域玩得越久越会觉得真正考验功力的往往不是会用多少框架而是能不能用最简单的结构做出最漂亮的效果。而伪元素和单标签恰好是这条路上最值得花时间研究的一对组合。所谓单标签之美不是强迫症式的炫技而是用一种极致的克制把布局、视觉、交互都浓缩在一个元素里让代码干净得像一件工艺品。我最初接触 ::before 和 ::after 的时候只觉得它们能省几个 div后来才发现这俩“看不见的孩子”能做的事情远超想象从图标、边框、动效到整块背景几乎无孔不入。这篇内容就是我个人把伪元素用到刀刃上的一些经验总结包含完整的思路拆解、可以直接抄走的代码以及几个我踩过很多次的坑适合已经有 CSS 基础、想进阶的同学也适合那些被项目里一堆无语义标签逼疯的开发者。1. 为什么要玩“单标签”伪元素带来的设计自由度1.1 从多余标签到极简结构少一层 DOM少一堆麻烦很多人写页面时遇到装饰性视觉第一反应是往上堆 div外面套一个容器里面放一个背景上面再盖一层遮罩时不时还需要一个专门用来画线条的 span。这样写当然能实现结果就是 DOM 层级越来越深页面结构像千层饼一样后期维护时想找一个元素得沿着父节点一层层剥开改个样式还得小心翼翼怕影响到兄弟节点。用伪元素替代装饰标签最直接的好处是让 HTML 回归内容本身。内容标签只负责内容视觉表现全部交给 CSS 承载比如 ::before 和 ::after 可以模拟出两个额外的“子盒子”不占 DOM 节点不增加 JS 查询成本也不影响屏幕阅读器对文档结构的理解。对性能来说更少的 DOM 节点意味着更小的渲染树和更快的重排速度尤其是在列表项这种重复渲染的场景里单标签方案能明显减少内存占用。从维护角度看伪元素有一个独一无二的优势它们天然和宿主元素绑定宿主元素被删除或隐藏伪元素自动消失不会留下孤立的空标签。比如写一个开关按钮只需要一个 input 或 button状态切换用伪元素表现“滑块”省掉包裹层和滑块层CSS 里加一个兄弟选择器或类名切换就能搞定。这种做法在移动端适配时尤其舒服因为你只需要关心宿主元素的尺寸伪元素跟着宿主走不需要同步修改多个节点的响应式规则。1.2 伪元素两员大将::before 与 ::after 的分工与配合既然是单标签主角自然是 ::before 和 ::after。这两个伪元素分别表示宿主元素的“第一个子节点”和“最后一个子节点”默认都是行内元素必须有 content 属性才会出现在渲染结果里。content 可以是字符串、属性值、计数器甚至为空字符串只创建一个占位盒子这是所有伪元素玩法的基础。它们最大的价值在于“一个管左、一个管右”的天然分工。最经典的场景是按钮双背景宿主元素负责底色::before 负责左边的渐变光晕::after 负责右边的描边或图标三者叠起来形成复杂层次。由于伪元素可以通过 position: absolute 完全脱离宿主的内容流你可以在一个 div 上同时实现背景层、装饰层和内容层相当于一个元素当三个元素用。这里有一个容易被忽略的点默认情况下::before 的层级在宿主内容之下::after 在宿主内容之上。利用这个特性不需要额外设置 z-index 就可以做很多分层效果比如卡片的光泽高光用 ::before浮层箭头用 ::after。不过一旦宿主元素自身创建了层叠上下文比如设置了 transform 或 filter层级规则会变得微妙在实际项目中最好用 z-index 显式控制避免出现“伪元素被内容盖住”这种莫名其妙的问题。1.3 单标签方案的适用边界什么时候该用什么时候别硬撑单标签不是万能的它的边界在于一个元素最多只有两个伪元素。如果视觉上需要三个以上独立控制的分层强行塞进 ::before 和 ::after 里只会让代码变成一团乱麻。我给自己定了一条规矩装饰不超过两层就用单标签如果超过两层优先考虑用 CSS 渐变和阴影合成而不是继续堆标签。另外要注意可访问性和交互热区。伪元素默认不响应鼠标事件除非显式设置 pointer-events: auto。如果你想让某个伪元素作为可点击区域比如一个纯 CSS 的开关滑块必须处理事件穿透。还有用伪元素承载文本内容时要谨慎屏幕阅读器不会朗读伪元素里的文字重要的提示信息还是应该写在 HTML 中伪元素只负责视觉补充。2. 涟漪光圈扩散效果单标签动画的核心实践2.1 效果拆解一个圆如何变成一圈圈扩散的波纹涟漪光圈扩散是我最早用单标签实现的动效之一也是面试和项目里经常出现的需求。先看最终要的效果点击按钮从点击位置向外扩散出一圈半透明的波纹像水面上荡开的涟漪扩散到一定大小后逐渐消失。如果用传统做法需要动态插入很多 div每个 div 负责一圈波纹看着就头大。用单标签加伪元素配合 CSS 动画可以非常优雅地实现。拆解一下一圈涟漪的本质是三个状态的变化半径从 0 变大到目标值透明度从 0.5 左右衰减到 0背景或边框保持圆环或渐变。如果只有一圈动画做完就没了观感不够丰富通常需要两到三圈并且每圈之间有延迟形成“后浪推前浪”的层次。在单标签限制下宿主元素自身可以当成第一圈::before 当第二圈::after 当第三圈三个动画节奏错开效果就能出来。关键动画属性是 transform: scale 和 opacity。为什么不直接改 width 和 height因为改变宽高会触发布局和重绘性能开销大而且很难保持圆形的中心点不变。scale 是合成器属性只触发合成不触发重排动画性能要好得多。涟漪的中心位置可以用 transform-origin 来控制默认是元素中心如果希望从手指点击的位置扩散还得先把伪元素平移到点击坐标。2.2 核心代码与参数计算三圈涟漪的节奏设计先给出一个可以直接复制的核心片段。假设宿主是一个 200px 见方的按钮涟漪从中心扩散到 300px 的半径动画持续 1.2 秒。.ripple { position: relative; width: 200px; height: 200px; border-radius: 50%; background: #3b82f6; overflow: hidden; cursor: pointer; } .ripple::before, .ripple::after, .ripple .ripple-core { content: ; position: absolute; left: 50%; top: 50%; width: 0; height: 0; border-radius: 50%; background: rgba(255, 255, 255, 0.3); transform: translate(-50%, -50%) scale(0); opacity: 0; pointer-events: none; } .ripple .ripple-core { animation: ripple-animation 1.2s ease-out infinite; } .ripple::before { animation: ripple-animation 1.2s ease-out 0.4s infinite; } .ripple::after { animation: ripple-animation 1.2s ease-out 0.8s infinite; } keyframes ripple-animation { 0% { transform: translate(-50%, -50%) scale(0); opacity: 0.5; } 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; } }这段代码里有一个核心计算scale 的终值。伪元素的初始宽高是 0涟漪扩散的最终半径是 300px。如果让伪元素的宽高直接设置为 200px和宿主一样那么 scale(3) 会让它的最终宽高变成 600px半径就是 300px正好覆盖目标范围。这个 3 是目标半径除以初始半径得到的倍数公式很简单scale 目标直径 / 初始直径。先把初始宽高写成一个方便换算的基准值比如 200px那么 scale 就是 3不需要去计算复杂的像素位移。关于动画时长的设置三圈涟漪之间的延迟我用了 0.4s。这个数值不是随便拍的要让三圈看起来均匀分布延迟应该约等于动画总时长除以圈数。1.2 秒的动画三圈均匀错开每圈间隔 0.4 秒这样在任何时刻画面上最多只有一圈在明显推进另外两圈分别处在早期和末期视觉效果最舒服。如果你想要更密集的波纹可以把延迟降到 0.3 秒但间距太小容易糊成一团建议以实际观感为准。2.3 踩坑笔记波纹卡顿、覆盖层级与事件穿透这个效果我在项目里用了不下十次每个版本都踩过不同的坑最典型的几个值得单独拿出来讲。第一个坑是卡顿。如果把伪元素的宽高设得很大同时直接修改 opacity 和 transform在高分辨率屏幕上会遇到肉眼可见的掉帧。解决办法是把动画属性限制在 transform 和 opacity 这两个合成器属性上同时给伪元素加 will-change: transform, opacity提前告诉浏览器这些属性会变化让它做优化。不过 will-change 不能滥用一个页面里同时有十几个波纹在动浏览器可能吃不消我通常只对正在播放动画的元素动态添加动画结束后移除。第二个坑是涟漪覆盖了按钮文字。默认伪元素虽然是 absolute 定位但如果没有设置 z-index::after 会盖在宿主内容上面。我的做法是在伪元素上统一加 z-index: -1让涟漪层退到宿主内容之下但如果宿主本身有背景色z-index: -1 的伪元素会被宿主背景盖住而看不见这时候就得在宿主上用 isolation: isolate 创建一个新的层叠上下文把负 z-index 的元素控制在宿主内部。具体写法是.ripple { isolation: isolate; } .ripple::before, .ripple::after, .ripple .ripple-core { z-index: -1; }第三个坑是点击穿透。虽然涟漪本身不需要响应事件但如果页面里还有其他元素需要被点击而涟漪层恰好覆盖在它们上方会造成点击事件无法触发。这里我习惯在伪元素上设置 pointer-events: none让涟漪层完全不参与命中测试。这个属性是单标签动画里最容易忘的忘了就会出现“点按钮没反应但其实按钮是被波纹挡住了”的诡异问题。3. 流光边框与渐变文字单标签的高级视觉玩法3.1 流光边框一个伪元素画出旋转的渐变光带流光边框是那种一眼看去很高级、但代码量少得惊人的效果。它的核心思路是用一个超大尺寸的渐变背景放在伪元素里然后让背景不断旋转制造出光线沿着边框流动的感觉。听起来复杂实际只需要一个 conic-gradient 加一个 keyframes 旋转。先看传统的实现方式需要一个包裹层负责边框区域一个内部元素负责内容一个伪元素或额外标签负责光带最后再用一个遮罩层盖住中间部分只露出边框一圈。这套组合拳下来至少三个元素起步。单标签怎么做用宿主元素当“遮罩”用它的 ::before 当光带。把宿主元素的背景设置为深色光带做成一个比宿主大一圈的正方形渐变然后通过 overflow: hidden 把光带多余的部分裁掉只露出边缘一圈。.shine-border { position: relative; width: 240px; height: 80px; border-radius: 12px; background: #1e293b; overflow: hidden; z-index: 0; } .shine-border::before { content: ; position: absolute; left: 50%; top: 50%; width: 400px; height: 400px; background: conic-gradient( from 0deg, transparent 0deg, #60a5fa 90deg, transparent 180deg, #a78bfa 270deg, transparent 360deg ); transform: translate(-50%, -50%) rotate(0deg); animation: shine-rotate 3s linear infinite; z-index: -1; } keyframes shine-rotate { to { transform: translate(-50%, -50%) rotate(360deg); } }这里有一个很关键的尺寸计算光带元素的边长必须足够大才能保证在旋转过程中始终覆盖宿主矩形的四个角。最小边长可以用勾股定理算等于宿主对角线长度。比如宿主是 240px 宽、80px 高对角线是 sqrt(240^2 80^2) ≈ 253px我直接取 400px给旋转留出充足余量。transform 中心用 translate(-50%, -50%) 和 left/top 50% 的组合保证旋转中心在宿主正中。流光边框的“流”字关键在于渐变角度的旋转。上面的动画是对整个伪元素做 rotate光带随之转动。如果只想让光带沿着边框转动而不旋转整个方块可以改成修改 conic-gradient 的 from 角度比如 from 0deg 到 from 360deg。但这样每一帧都需要重新计算渐变性能不如直接 rotate 好所以我更倾向于第一种写法配合 overflow: hidden 裁切效果几乎一致性能却更好。3.2 渐变文字与删除线伪元素里的文字也能玩出花很多同学以为渐变文字只能用 background-clip: text 配合一个背景渐变实现这个没错但有没有想过如果把渐变文字放在伪元素里能做出什么效果最常见的是标题上的斜体装饰字或者一句话里强调某个词而且这个强调词不用单独包 span直接用 ::after 的 content 从 data 属性里取出来。.title { position: relative; font-weight: 700; font-size: 28px; color: #334155; } .title::after { content: attr(data-highlight); position: absolute; left: 0; bottom: 4px; font-size: 18px; font-weight: 400; color: transparent; background: linear-gradient(90deg, #f97316, #ef4444); -webkit-background-clip: text; background-clip: text; text-decoration: line-through; text-decoration-color: rgba(239, 68, 68, 0.4); text-decoration-thickness: 2px; }伪元素里用 attr() 读取宿主元素的>.hover-card { position: relative; padding: 24px; background: #ffffff; border: 1px solid #e2e8f0; transition: transform 0.2s ease; } .hover-card::before { content: ; position: absolute; inset: -2px; border-radius: inherit; background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), transparent); opacity: 0; transition: opacity 0.3s ease; pointer-events: none; z-index: 0; } .hover-card:hover::before { opacity: 1; }注意这里的 inset: -2px 让伪元素比宿主稍微大一圈这样光晕边缘会从卡片边缘“渗”出来一点有一种光溢出的感觉。但是必须给宿主加 overflow: hidden 吗不一定。如果卡片本身有圆角和边框溢出部分会露在外面反而破坏整体感。我的做法是给伪元素一个足够大的 z-index 并配合 border-radius: inherit让光晕在卡片内部显示。另一个方案是直接把伪元素的背景做成一个外发光利用 box-shadow 来实现不过 box-shadow 无法做渐变光晕所以能不用就不用。还有一个容易被忽略的交互细节如果内容元素需要浮在光晕之上必须给内容加 position: relative 和一个 z-index。因为伪元素的 z-index: 0 可能会让内容默认在它上面但某些浏览器默认伪元素会遮挡内容稳妥的做法是让宿主内容统一提升层级。4. 常见问题与排查技巧实录4.1 最后两个元素不加伪类列表场景下的精准选择单标签思路经常用在列表上一个 ul 里每个 li 都是单标签但问题来了如何给列表的最后两个元素做特殊处理而不额外加类名比如一个两列布局的网格最后一行只有两个元素想让它们底部对齐或加底边框就需要精准选中倒数第一个和倒数第二个 li。我常用的选择器是:nth-last-child(-n2)。这个表达式里的-n2表示从后往前数的前两个正好能匹配最后两个元素。但需要注意如果列表总数是奇数最后一行只有一个元素这个选择器会把倒数第二个也选中导致一个元素也被误伤。这时候可以配合:not(:first-child)或:last-child做二次筛选。更精确的写法是针对“每行两个、最后一个可能会多余”的布局可以写成.grid-item:nth-last-child(-n2):last-child:nth-child(odd) { grid-column: span 2; }这段的意思是如果最后一个元素是奇数位说明它单独占一行就让它横跨两列保证底部对齐。关于“一行两个如何算最后两个元素”最直观的办法是通过元素的序号判断总数为奇数时最后一个元素独占一行倒数第二个和倒数第三个才是真正的同一行。所以更严谨的选择器要结合:nth-child(odd)来判断行位置。这里推荐一个思路先确定每一行的元素数量再用nth-child对行号取模最后结合nth-last-child处理边缘情况。4.2 伪元素内容为空但页面出现占位content 的坑一个非常常见的现象是明明伪元素里没有写 content但页面上多了一段空白或一个看不到的方块。原因是 content 属性即使设为空字符串content: 它也会生成一个盒子这个盒子的尺寸、内外边距、边框都会参与渲染。尤其当你给伪元素设置了 width 和 height但忘了设置 content浏览器会忽略所有尺寸结果伪元素直接不显示或者以默认行内盒子的形式出现。解决的办法有两个一是所有伪元素都必须显式写content: 哪怕是空字符串二是用content: none来彻底关闭伪元素。如果只是想要一个空的装饰层content 空字符串加 position: absolute 是最稳的。我还遇到过一种情况伪元素没有尺寸却占据了父元素的空间这通常是因为伪元素默认是 inline设置了 vertical-align 之后产生额外的行高间隙。解决方法是把伪元素设为 absolute 或给一个 display: block。4.3 伪元素与 CSS 变量、计数器的联动真正让单标签从“好玩”变成“好用”的是伪元素与 CSS 变量的组合。伪元素的 content 可以读取 attr()但 attr() 只能读字符串没法做计算。如果需要在伪元素里显示数字序号比如一个纯 CSS 的目录列表可以利用 CSS 计数器。.list { counter-reset: item; } .list-item::before { counter-increment: item; content: counter(item, decimal-leading-zero); }这里要注意计数器是加在伪元素上的而不是宿主元素。如果加在宿主元素上每次子元素递增时宿主也会被计数导致序号跳动。另外一个技巧是计数器可以配合变量做动态内容比如显示当前是第几个分页。只要在宿主元素的 style 属性里设置--index: 3伪元素 content 就可以写成content: 第 attr(data-index) 页不需要切换类名。伪元素里使用 CSS 变量也很实用因为变量可以继承。比如在宿主上定义--ripple-color: rgba(255,255,255,0.3)伪元素的 background 可以直接写var(--ripple-color)这样同一个单标签组件在不同的地方可以用不同的主题色而不需要复制整个 CSS 文件。这个特性在组件化开发里特别香改一个变量所有伪元素效果跟着变。4.4 屏幕穿出效果的近似实现有人在群里问过“CSS 能不能实现屏幕穿出来的效果”比如一个元素从屏幕边缘伸出来像从墙上探出头那样。严格来说CSS 只能做到视觉上的“穿出”不能真正让元素跑到浏览器窗口之外还能被感知因为超出视口的部分会被裁剪或产生滚动条。但用单标签可以模拟出“局部溢出”的效果给宿主元素设置 overflow: visible让伪元素从边缘伸出再配合负的 left 或 top 值就能让装饰层溢出到宿主之外。.device { position: relative; overflow: visible; } .device::after { content: ; position: absolute; left: -30px; top: 50%; width: 60px; height: 120px; transform: translateY(-50%); background: linear-gradient(90deg, #94a3b8, #e2e8f0); border-radius: 8px; }这种做法的关键是要让伪元素的溢出部分不会干扰页面布局。如果父级没有设置 overflow: hidden溢出元素会被其他内容覆盖或遮挡半透明效果会变得一团糟。另一个思路是使用负 margin但负 margin 会影响文档流不如绝对定位可控。如果想让“穿出”有透视感可以结合 perspective 和 rotateY 做一点倾斜比如 rotateY(-15deg)让伸出部分看起来有立体感不过这种 3D 变形会创建新的层叠上下文需要小心处理 z-index。5. 扩展应用单标签从动效到布局的实战5.1 五分钟写一个纯 CSS 加载动画加载动画是单标签最经典的实战场景。以常见的圆环旋转加载为例用宿主元素当圆环底色伪元素当旋转的弧线。核心是利用 border 的透明与颜色拼接再把伪元素旋转起来。.loader { width: 60px; height: 60px; border-radius: 50%; border: 4px solid #e2e8f0; border-top-color: #3b82f6; animation: spin 0.8s linear infinite; } keyframes spin { to { transform: rotate(360deg); } }这个写法其实只用到了一个元素本身连伪元素都没用上已经算单标签了。但如果想要更复杂的双环加载比如外环逆时针转、内环顺时针转就需要伪元素登场。把伪元素做成一个更小的圆环放在宿主内部然后反向旋转。这里有一个细节宿主旋转时伪元素也跟着转想让伪元素单独反向旋转可以在宿主旋转动画之外再给伪元素一个独立的动画。不过要注意伪元素如果设置在宿主内部它会被宿主的 transform 影响所以需要把伪元素做成“绝对定位 自身反向动画”的模式两个动画叠加起来观感才是内外反向。5.2 数字加载动画与倒计时文字的单标签实现数字滚动加载动画也是热词里常被提到的需求。用纯 CSS 实现数字递增其实靠的是 property 和 counter 的配合但兼容性有限。更务实的方法是用伪元素 content 配合 CSS 变量前端 JS 只修改变量值CSS 负责把数字渲染出来。单标签的好处是连 span 都不用加直接把数字放在宿主元素的>.count { position: relative; font-size: 0; } .count::after { content: attr(data-value); font-size: 48px; font-weight: 700; font-variant-numeric: tabular-nums; }这里设置 font-variant-numeric: tabular-nums 很有用它让数字等宽在数字从 9 跳到 10 或者从 99 跳到 100 时宽度不会跳变视觉上更平滑。如果要配合倒计时JS 只需要每秒更新一次>