
写CSS这么多年我最深的体会是很多“样式不生效”的坑根源往往不在属性而在选择器。你写了一个background、一个padding页面纹丝不动多半是根本没选中那个元素或者选是选中了被另一条权重更高的规则盖住了。CSS选择器是整个样式系统的入口和地图这块不扎实后面调布局、做动画、维护组件都会一直磕磕绊绊。这篇文章我打算把选择器从头到尾拆一遍从基础分类、组合关系、属性选择器、伪类伪元素再到优先级、性能、调试方法把网上大家常搜的那些“鼠标移入事件”“文本居中”“两行超出省略号”“优惠券圆切”“字体渐变”等场景也都串到选择器的语境里讲清楚。新手可以把它当一条进阶路线写过几年样式但总觉得“改不动别人代码”的同学也能在里面找到一些排查思路。1. 先把选择器分个类别一根通配符打天下1.1 五种基础选择器各管一片地盘很多初学者一上来就喜欢用id或者干脆给每个标签写一个class最后CSS文件像流水账一样改一个样式要滚三屏。其实CSS的基础选择器就那么几种每一种都有自己的定位和适用范围。标签选择器比如p、div、a权重最低但最适合写“全局默认值”。比如统一全站段落的行高、标题的边距直接写在标签选择器上就非常合理因为它天然具有“批量作用”的属性省得每个区块都重复写一遍。类选择器是日常开发中出场率最高的一个类名可以挂到任意多个元素上而且语义可以自己定义比如.card、.btn-primary、.nav-item看到名字基本能猜到它管什么。id选择器权重最高一个页面里理论上只允许一个元素使用同一个id适合定位唯一模块比如#header、#footer但不适合拿来做通用样式原因很简单id不可复用一旦以后第二个模块想用这套样式你就只能复制粘贴或者改用class。还有两种基础形式容易被忽略通配符和群组选择器。通配符*可以选择所有元素群组选择器则是把多个选择器用逗号连成一条规则比如h1, h2, h3 { margin: 0; }。这两种东西用得好能极大地压缩样式表体积。网上流传的“三行模式的css文件”很多就是靠这种合并写法再配合继承特性把冗长的reset和公共样式压到几行内。我见过有人分享一个三行CSS就能让页面排版瞬间整洁的案例其实拆开看就是* { box-sizing: border-box; }加上body、container两条规则。选择器合并的价值不只是省字符更重要的是让“重复样式只维护一次”。1.2 通配符不是万能的用之前先想清楚代价*选择器在重置样式里特别流行很多人写* { margin: 0; padding: 0; }确实在简单页面里见效快但它会把所有元素的默认边距都清零包括表单控件、按钮、下拉框这些本身就带着系统默认样式的元素。你能想象一个没经过样式处理的select下拉框突然连内边距都没了在浏览器里的观感有多奇怪吗我自己的习惯是* { box-sizing: border-box; }可以全局写因为盒模型计算方式统一是好事能避免一堆宽度对不上的问题但margin和padding的重置不建议用通配符一把梭要么用更精确的reset列表要么就明确写出body, h1, h2, p, ul这类集合。另外*还有一个性能隐患。浏览器对CSS选择器的匹配方向是“从右往左”的当右侧选择器是*时理论上它要把目标元素的所有祖先都检查一遍DOM越深开销越大。尤其是在大型列表页面里类似.list * { color: #333; }这种写法会让浏览器遍历.list下面所有层的所有节点。现代浏览器已经很努力优化了但写代码时还是别给引擎添乱。1.3 优先级解决“为什么我写的样式不生效”优先级是选择器里最让人头疼的点也是“样式不生效”的第一大原因。CSS优先级可以粗略看作一个四元组(内联样式id选择器数量类/属性/伪类数量标签/伪元素数量)。比较时从左往右逐位比而不是单纯“id大于class大于tag”。举个例子.btn对应(0,0,1,0)#submit对应(0,1,0,0)div .btn对应(0,0,1,1).btn.active对应(0,0,2,0)单看id肯定赢class但如果是两个组合选择器类数量多的会赢。比如.page .btn有两个类.btn只有一个类所以即使.btn写在后面依然会被.page .btn压住。这就是为什么很多人发现“我多套一个父级类名就能覆盖样式”底层逻辑就是增加了类选择器的数量。还有一个经常被混淆的点父级写color: red子元素没写颜色看到的是红色这是继承不是优先级问题。一旦子元素自己写了color: blue哪怕父级权重再高只要没用!important子元素的颜色一定是blue。!important是凌驾于正常优先级之上的特殊存在能不用就不用。我见过一个老项目里全篇大概有二十多个!important到最后谁都不敢删也不敢加新样式因为根本分不清哪个才是最终赢家。记住一个原则正常项目里出现!important就该怀疑是不是前面的选择器设计出了问题。2. 组合与层级选择器像地图导航一样找元素2.1 后代选择器与子选择器一字之差覆盖范围差一个数量级.container p会选择.container内部所有的p标签哪怕它被嵌套在五层div下面.container p只会选择.container的“亲儿子”p也就是第一层直属子元素。这两个选择器长得像语义却差很多用错了很容易出现“我想改一级菜单、结果二级三级全变了”的事故。我在整理导航菜单时遇到过最典型的案例结构是aside ul li a一级菜单和二级菜单都是a标签。如果写.aside ul li a { font-weight: 600; }那么进入二级菜单后子菜单里的a也会跟着加粗用户看到的就是好几个层级全是粗体视觉层次完全丢失。正确写法应该是.aside ul li a只命中一级菜单。这个“”符号看起来不起眼却是精准控制范围的利器。实际项目中我建议层级尽量控制在三层以内并且能使用子选择器就不使用后代选择器。原因有二一是避免无意中波及深层元素二是可读性好别人看到.header .nav .item一眼就知道结构是直的、扁平的而不是一团嵌套迷宫。一个经常被人吐槽的长选择器长什么样类似.main .content .list .item .link span这种链一旦写出来几乎等于在CSS里埋了一颗雷后续任何微调都很痛苦。2.2 相邻兄弟与通用兄弟左右布局和状态联动的秘密相邻兄弟选择器A B匹配紧跟在A后面的第一个B通用兄弟选择器A ~ B匹配A后面的所有B兄弟。这两个都是“横向关系”在左右布局、表单联动里特别好用。举个表单校验的例子输入框后面跟一个错误提示默认隐藏当输入框带上错误状态类时让提示显示出来。不用写JS只需这样.input-error .error-tip { display: block; }只要.input-error出现它后面的提示就自动显示一旦错误类被移除提示也跟着消失。这种联动比在JS里手动操作两个元素的class要省心得多而且状态变更的入口只有一个是否给输入框加了错误类。另一个场景是做待办清单。用input:checked label可以轻松实现“勾选后自动加删除线”input[typecheckbox]:checked label { text-decoration: line-through; color: #aaa; }这就是网上常搜的“css 删除线”的一种纯CSS实现方式不需要任何JavaScript参与。类似的联动思路还能用于手风琴菜单、选项卡、以及“点击外部区域关闭浮层”的某些简化场景。2.3 群组选择器同形组件批量管理的利器群组选择器虽然基础但在维护老项目时价值极大。很多老页面的HTML不能随意改动也没有组件化概念同一套视觉样式可能散落在不同区块里类名各不相同。如果你逐个复制粘贴样式改一个就要改五处。这时群组选择器能帮你“统一收编”.section-a .title, .section-b .title, .section-c .title { font-size: 18px; line-height: 1.4; }比在三个地方各写一遍强得多改字体大小只需要改一行。如果用:is()还能再进一步精简:is(.section-a, .section-b, .section-c) .title { font-size: 18px; }》:is()属于现代选择器后面讲伪类时会详细说这里先记住这个模式它可以替代群组选择器让选择器列表更清晰。3. 属性选择器不加class也能精准锁定目标3.1 七种属性选择器写法别再只会[typetext]属性选择器是很多前端开发者用得最少的“隐形神器”。它能根据元素携带的属性及属性值来匹配元素而不需要额外增加class。常见写法有七种[attr]只要带这个属性就命中[attrvalue]属性值完全等于value[attr^value]属性值以value开头[attr$value]属性值以value结尾[attr*value]属性值中包含value[attr~value]属性值以空格分隔的单词列表其中包含value[attr|value]属性值正好为value或以value-开头这些写法最大的价值在于“按特征选择”省去大量加class的工作。比如input[typenumber] { -moz-appearance: textfield; } a[href^https] { color: #1a73e8; } img[alt] { border: 2px solid #ddd; }第三个用法我特别喜欢给所有带alt属性的图片加边框相当于用样式倒逼自己写全alt对无障碍优化有一定帮助。你也可以用img:not([alt]) { outline: 2px solid red; }筛出所有漏写alt的图片在开发阶段快速自查。3.2 表单场景属性选择器与状态伪类的组合表单是属性选择器的高频使用地。后台管理系统里经常有一堆输入框如果都要写classHTML会变得很啰嗦。用属性选择器统一设置input:is([typetext], [typeemail], [typepassword]) { height: 36px; border: 1px solid #ccc; border-radius: 4px; }再配合:hover和:focus鼠标移入和聚焦时显示不同的边框颜色.login-form input:is([typetext], [typepassword]):focus { border-color: #4a90e2; box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); }这比在每个input上写一长串类名干净太多。:is()在这里把多个属性选择器合并成了一个整体代码短了一半逻辑也更清楚。3.3 从属性选择器延展出去打点、删除线、两行超出网上有人搜“css 打点”这里顺手把这个话题聊透。打点通常有两种场景一种是文本溢出时的省略号另一种是加载状态里的动态小点。省略号分单行和多行单行三件套固定是overflow: hidden; white-space: nowrap; text-overflow: ellipsis;多行超出后用省略号最常用的是“两行超出...”效果固定写法display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;很多人写多行省略号没效果十有八九是漏了overflow: hidden或漏了-webkit-box-orient: vertical。这两个属性看似不起眼但少一个浏览器就不认。这是网上提到的“三行模式”里非常实用的一种我几乎在每个列表页、卡片摘要里都会用到。动态打点则是另一个思路一般用伪元素加动画模拟“加载中...”的效果。比如给一个按钮加类.loading用::after生成三个点再配合keyframes让点循环变化。这里要注意伪元素content无法直接做“逐个点”的逐帧动画更简单的做法是让整个content做不透明度闪烁或者用三个子元素分别做延迟动画看具体需求。4. 伪类与伪元素的实战地图4.1 结构伪类nth-child别再糊涂了结构伪类是选择器里最有“编程感”的一部分可以用公式精确匹配元素在父级中的位置。最常用的是:first-child、:last-child、:nth-child(n)、:nth-of-type(n)。很多人分不清:nth-child和:nth-of-type。:nth-child的规则是“父元素下的第N个子元素且必须是这个标签”:nth-of-type则是“同类型标签里的第N个”。举个例子在一个列表里既有li又有spanul li1/li spanx/span li2/li li3/li /ulli:nth-child(2)匹配的是第二个子元素但它同时必须是li这里第二个子元素是span所以匹配为空。而li:nth-of-type(2)匹配的是“第二个li”也就是列表里的2。这两种写法的差异在混排结构中非常容易踩坑写之前一定要在心里数一遍DOM层级。:nth-child还支持公式写法比如li:nth-child(2n1)选中奇数项li:nth-child(3n)选中每三个一组的第三项。这个功能在做表格斑马纹、卡片隔行变色时非常实用不需要再给每行单独加class。另外:nth-child的计数只统计元素节点HTML注释不会影响计数。如果你发现某一行样式没按预期作用先检查是不是DOM层级里多了一个真实的元素节点。4.2 状态伪类hover、focus、active、focus-within鼠标移入事件在CSS里对应的就是:hover但只用:hover有点浪费。现代状态伪类是一个家族:hover是鼠标悬停:active是鼠标按下时:focus是元素获得焦点:focus-within是元素自身或其子孙获得焦点。做卡片效果时我给按钮和卡片的动效非常依赖这三种状态.card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12); } .card:active { transform: scale(0.98); }这里:active主要照顾移动端触摸设备因为触摸屏没有真正意义上的“悬停”但点击时:active会触发。所以给可点击元素加上:active反馈会让移动端手感好很多。:focus-within是一个被低估的选择器。它可以实现“当容器内的任意输入框聚焦时容器整体高亮”的效果非常适合做表单卡片。假设结构是.form-card .form-group input当输入框聚焦时想给整个卡片加边框.form-card:focus-within { border-color: #4a90e2; box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); }这个效果如果靠JS实现需要监听focus和blur事件然后沿着DOM往上找父级麻烦且容易漏。用:focus-within一行搞定还天然支持键盘Tab导航体验更完整。4.3 :not、:is、:where现代选择器三兄弟:not()以前只能传简单选择器现在可以传整个选择器列表用来排除一系列匹配项。比如input:not([typehidden]):not([typesubmit]) { border: 1px solid #ccc; }以后不用再写一长串input[typetext], input[typepassword]直接用排除法过滤掉不想处理的类型省心很多。:is()接受一个选择器列表匹配其中任意一个选择器匹配的元素并且优先级按列表里最高的选择器计算。这特性用来合并样式很舒服:is(h1, h2, h3, .section-title) { font-weight: 600; }:where()写法和:is()几乎一样唯一区别是优先级始终为0。这个“零优先级”看似反直觉其实是刻意设计。它的价值在于你可以用:where()写基础样式然后任何一条普通规则都能轻松覆盖它不会陷入优先级缠斗。比如:where(nav, footer) a { color: #333; }后面如果某个链接要改颜色直接写a { color: red; }就能赢不需要绞尽脑汁增加优先级。这种“能让基础样式让位”的特性在写组件库、主题系统时尤其好用。4.4 伪元素的实际魔法分割线、字体渐变、涟漪光圆::before和::after是两个不占用HTML标签的虚拟元素很多视觉装饰都靠它们完成。我经常用::after实现段落分割线效果是渐变淡出的细线比纯色hr精致很多.divider::after { content: ; display: block; height: 1px; background: linear-gradient(90deg, transparent, #ccc, transparent); }网上常搜的“css 字体渐变”严格说不是伪元素功能但经常和伪元素一起出现。实现文字渐变的经典写法是.gradient-text { background: linear-gradient(90deg, #ff8800, #ff0099); -webkit-background-clip: text; background-clip: text; color: transparent; }核心是background-clip: text把背景裁剪到文字范围内同时把文字颜色设为透明。这个效果和选择器关系不大但很多人从“CSS高级玩法”里搜过来顺便说明一下避免误会。“css 涟漪光圈扩散”可以用::after加动画模拟。核心思路是给按钮的伪元素设置一个圆形边框初始透明度较高、尺寸较小hover时通过动画放大并渐隐.ripple-btn::after { content: ; position: absolute; inset: 0; border-radius: 50%; border: 2px solid currentColor; opacity: 0; pointer-events: none; } .ripple-btn:hover::after { animation: ripple 0.6s ease-out; } keyframes ripple { 0% { transform: scale(0.6); opacity: 0.8; } 100% { transform: scale(1.4); opacity: 0; } }这里有一个我踩过好几次的坑伪元素如果不加pointer-events: none它会盖在按钮上层导致鼠标点击事件被拦截。尤其是你给按钮绑定了JS点击事件之后按钮没反应排查半天发现是伪元素在挡路。只要涉及可点击元素上的伪元素第一件事就把它加上。5. 优先级、性能、维护选择器工程的成熟度5.1 权重计算别只背口诀要看四元组前面已经提过四元组这里再补充几个容易忽略的细节伪类算类权重伪元素算标签权重。所以:hover和::before的权重不在一个级别:hover接近.class::before接近div。属性选择器[typetext]同样算类权重。内联样式在生产代码里我强烈不建议写除非你在做邮件模板这种特殊场景。它的优先级高过所有普通选择器一旦写进HTMLCSS这边基本没法正常覆盖只能靠!important硬拼最后两败俱伤。还有一个常见迷惑点父容器写了color: red子元素没写页面显示红色这不叫“选择器生效”而是继承了父级颜色。一旦子元素自己写了color: blue哪怕父容器优先级再高子元素也会显示蓝色。规则很简单继承是“没有自己的值”时才生效自己写了值就跟祖先无关了。真正难缠的是父容器写color: red !important子元素写color: blue此时子元素也斗不过父级的!important因为!important会参与继承。所以不要轻易给样式加!important会连继承链条一起破坏。5.2 选择器的性能浏览器为什么从右往左匹配浏览器解析CSS选择器是“从右往左”的也就是说.nav ul li a浏览器会先在页面里找到所有a然后逐个向上检查是否有li、ul、.nav包裹。这个机制决定了选择器越靠右的条件越有辨识度匹配性能越好如果右边是通配符浏览器就要把几乎所有元素都拉出来走一遍检查流程。我建议优化选择器性能时盯住四点避免写*作为最右侧选择器比如.box *尽量让最右侧的选择器是类名或id而不是标签名嵌套层级控制在四层以内更深了就该考虑是不是HTML结构有问题不要只为了“重置”就使用超长选择器优先级高还会让渲染变慢也许有人觉得这几个毫秒无所谓但在大型后台系统、移动端低端设备上渲染速度是一点点堆出来的。CSS没有“运行时报警”写的时候就要为后续性能负责。5.3 原子性CSS、三行模式与选择器的本质“原子性CSS”近年很火它的做法是把每一条声明都拆成一个类比如.mt16 { margin-top: 16px; }、.fz14 { font-size: 14px; }、.flex { display: flex; }。从选择器角度看它其实是在用“极简选择器大量复用”替代“复杂选择器树”。好处是样式优先级单一、渲染快、组合灵活坏处是HTML会堆满一堆看不清语义的类名可读性变差。Tailwind这类框架本质就是把原子性CSS推向极致。“三行模式的css文件”在热搜里出现频率不低我理解它是一种追求极简的思路用极少的规则搭建页面骨架。比如* { box-sizing: border-box; } body { font: 14px/1.6 system-ui, sans-serif; color: #333; } .container { max-width: 1200px; margin-inline: auto; padding-inline: 16px; }这三条规则已经把盒模型、字体基线、最大宽度布局都定住了后面再往里填样式就不会乱跑。所以选择器学的不是花哨语法而是“如何用最少、最准的规则表达设计意图”。6. 高频场景排查手册6.1 文本居中、input居中、table居中一网打尽网上搜“css中怎么把input居中”“文本垂直水平居中css代码”“让table水平居中css”的同学非常多。我把这几个问题归成一类居中。文本水平居中最简单text-align: center。单行文本垂直居中可以让line-height等于容器高度。多行文本或者块级元素要同时水平垂直居中推荐flex方案.parent { display: flex; align-items: center; justify-content: center; }这个方案几乎适合所有块级元素和文本内容比老式的position: absolute top: 50% transform: translateY(-50%)简洁得多而且不需要父级设置具体高度。input在容器里居中先明确一点input是行内替换元素直接写margin: 0 auto不一定生效。要么给input设置display: block要么让父容器用flex二选一即可。我建议直接flex因为容器的水平垂直居中需求往往不只是针对一个input。table水平居中写margin: 0 auto的前提是表格宽度要小于父容器宽度。如果table是width: 100%那它已经铺满父容器居中无从谈起。td内文字居中用text-align: center垂直居中用vertical-align: middle。顺带提一下“css样式固定在左右边怎么设置”这个热搜问题。它和选择器关系不大但很常用左右固定栏用position: fixed比如左侧固定导航.sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: 240px; }这类元素类名建议起得足够清晰并且尽量不参与复杂的选择器嵌套避免优先级纠纷。固定定位元素脱离文档流后对周围布局影响大选择器命中范围也最好收敛。6.2 鼠标移入与纯CSS Tab控件“css 鼠标移入事件”听上去像事件其实是:hover选择器。基础用法是鼠标移入换颜色进阶用法是配合过渡做平滑动画配合兄弟选择器做浮层菜单。最经典的几个交互我前面已经写过这里重点聊一下纯CSS Tab控件。网上有人想要“最酷的css tab控件”其实Tab的本质就是“点击某个标签切换对应内容面板”。纯CSS可以用radio加:checked实现把所有radio隐藏用label当作Tab头再用兄弟选择器控制对应面板显示。结构大致是.tab-group input[typeradio] { display: none; } .tab-group input#tab1:checked ~ .panel1, .tab-group input#tab2:checked ~ .panel2 { display: block; }这种方案完全不需要JavaScript而且状态天然互斥因为同一组radio只能选中一个。实现时记得让label与input通过for属性关联同时把.panel默认隐藏。唯一缺陷是刷新页面后状态不会保留除非你用localStorage记录但那又回到JS了。所以纯CSS方案适合简单展示正式复杂交互还是建议交给框架或JS。6.3 选择器调试三步走调试选择器问题我有一套固定的土办法屡试不爽第一步打开DevTools的元素面板选中目标元素看右侧Styles里有没有命中你写的规则。如果规则存在但被划掉了说明被更高优先级的规则覆盖如果规则压根没出现说明选择器没匹配上。第二步切换到Computed计算样式面板找到你关心的属性浏览器会列出最终值以及来源规则点进去就能看到是哪一条rule在生效。第三步在Console面板里直接运行一句document.querySelectorAll(你的选择器)看返回了几个元素。返回为空就是选择器写错返回一堆就检查它是不是你想找的那个。这个方法在执行复杂的后代选择器、兄弟选择器时特别有用能直观看到命中范围。还有一个调试布局的笨办法临时给所有元素加* { outline: 1px solid red; }一下子就能看到每个元素的边界和嵌套关系排查居中和定位问题非常高效。它的好处是不会像border那样改变盒模型尺寸用完删掉即可。6.4 选择器命名与维护的几条心得踩过足够多的坑之后我给自己定了几条选择器层面的规则定位元素优先用类名语义表达交给标签唯一模块才用id不为了权重去硬套父级类名那会让选择器越来越长越来越难懂!important必须给出注释写清楚为什么这里要打破优先级类名尽量带语义和层级信息比如BEM风格.block__element--modifier这样就算不写嵌套也能从类名上看出结构一个选择器链超过四个层级就要停下来反思HTML结构是不是该简化了这些规则不是规定而是从大量代码评审和项目维护中沉淀出来的。一个CSS文件好不好维护往往不是看你用了多少高级选择器而是看选择器好不好猜、好不好改、好不好删。我个人在实际操作中最深的一个体会是写样式前花十分钟理清页面结构比写完后花两小时调试更值得。先把页面拆成区块确定每个区块的关键类名思考哪些元素是动态变化的哪些视觉状态可以交给伪类再开始写CSS。这样写出来的选择器几乎不需要返工。选择器这东西短期看是技巧长期看是设计。你写选择器的方式其实就是你对这个项目结构的理解方式。希望这篇整理能让你以后写CSS时少一点“为什么不生效”的抓狂多一点“就该这么写”的笃定。