ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

原生CSS嵌套全解析:语法、踩坑与实战重构指南

原生CSS嵌套全解析:语法、踩坑与实战重构指南 1. 从“选择器地狱”说起为什么样式组织需要嵌套1.1 平铺写法的高重复与低可读性做前端这些年我维护过不少“历史悠久”的项目。打开 CSS 文件经常看到这样的内容.card { border-radius: 8px; } .card .card-header { padding: 16px; } .card .card-header .card-title { font-size: 18px; } .card .card-header .card-close { width: 24px; } .card .card-body { line-height: 1.6; } .card .card-body .card-content { color: #333; } .card .card-footer { border-top: 1px solid #eee; }这套写法本质上没有错但问题也明摆着.card-header和.card-title这种前缀要反复手写一旦某个类名调整就要全局搜索替换读代码的时候眼睛得从后往前倒推才能搞清楚一个选择器到底要命中页面上的哪个元素。说白了CSS 选择器本身是一种“平铺”的语言而我们的页面结构是“嵌套”的两者之间存在天然的偏差。嵌套规则CSS Nesting解决的正是这个偏差。它允许你在.card的规则集内部再写一层选择器让 CSS 的结构和 HTML 的层级保持一致。这个系列前两篇聊了基础选择器、盒模型和常见布局这篇我集中把嵌套规则讲透——包括怎么用、哪些坑容易踩以及实际项目里该怎么组织代码才不容易翻车。1.2 从 Sass 到原生嵌套并不是预处理器的专利很多人第一次接触“嵌套”这个概念是在 Sass 或 Less 里。我以前也是这样项目里用 Sass 写嵌套确实爽过一段时间——类名只用写一遍结构看过去一目了然。但 Sass 带来的不止是便利还有整套构建链路的重量要么用 Node 脚本编译要么交给打包器处理新成员入职第一件事就是装环境、配路径。所以当我看到原生 CSS 也开始支持嵌套规则时心情还是比较复杂的高兴的是终于可以在纯 CSS 项目里用上这种写法不用再为了一个嵌套功能引入整套预处理器遗憾的是原生语法和 Sass 的嵌套之间存在一些关键差异直接从 Sass 习惯“平移”过来会踩不少坑。这个特性到今天已经到了可以放心在生产环境使用的阶段。Chrome 112、Edge 112、Safari 16.5、Firefox 117 从 2023 年起陆续支持原生嵌套规则到目前主流浏览器已经覆盖得相当完整。就算团队里还有一些旧浏览器用户也可以通过构建工具自动降级处理这部分我在后面单独讲。1.3 嵌套规则的本质让选择器“追认”父级在深入语法之前先理解一个核心逻辑嵌套规则并不是什么黑魔法它在浏览器解析的时候会被“展开”成普通的选择器。比如你在.card里写了一个.title的嵌套规则最终浏览器的渲染引擎会把它当作.card .title来处理。这不只是写法上的简化更重要的是代码组织的思路发生了变化——从“每个选择器独立存在”变成了“以组件为边界内部结构集中表达”。想清楚这个逻辑后面看各种语法细节就会轻松很多。你不需要把嵌套想象成一种新的语言它只是给平时写的那套选择器语法加了一种“内嵌快捷方式”。2. 原生CSS嵌套的语法拆解从最简单的父子关系开始2.1 后代选择器的嵌套省略 的简写最简单的嵌套就是在一个规则集内部直接写另一个规则集.nav { display: flex; gap: 8px; .nav-item { color: #333; } }这段代码等价于.nav { display: flex; gap: 8px; } .nav .nav-item { color: #333; }注意内部的.nav-item前面不需要写原生语法允许省略。浏览器会自动帮你把它理解为“父选择器的后代”。这是最基础的用法也是大多数人记住的用法。可以这样想外层选择器相当于一个“作用域”内部声明的样式默认都作用于这个作用域内层的元素。写代码的时候我通常在脑内把嵌套层级和 HTML 的 DOM 层级做映射嵌套深度不要超过四层超过四层就要考虑是不是结构本身设计得有问题了。2.2 符号的三种典型用法自身、同元素状态、组合器符号在嵌套规则里代表“父选择器本身”。它最基本的价值是让你能表达“同一个元素上的其他状态或附加条件”。第一种典型用法是状态与伪类.btn { background: #1677ff; color: #fff; :hover { background: #4096ff; } :disabled { opacity: 0.5; cursor: not-allowed; } }这里:hover会被展开为.btn:hover命中的是按钮本身的鼠标移入状态。如果不用嵌套你得把.btn:hover单独写在另一个地方跟背景色、文字颜色的声明离得很远嵌套之后状态样式直接贴在基础样式旁边一眼就能看懂“这个按钮 hover 之后会变成什么样”。第二种典型用法是追加类名。同样一个元素只有在同时存在某个附加类时才生效的样式可以这样写.card { border: 1px solid #eee; .card--active { border-color: #1677ff; box-shadow: 0 4px 12px rgba(22, 119, 255, 0.15); } }.card--active展开为.card.card--active表示该元素同时拥有这两个类。这里需要注意.card--active和.card .card--active是两回事前者是同一个元素上的两个类后者是祖先与后代关系。第三种典型用法是配合组合器使用比如子代选择器和相邻兄弟选择器.menu { .menu-item { border-bottom: 1px solid #f0f0f0; } .tip { color: #999; } } .menu-item等于.menu .menu-item .tip等于.menu .tip。我个人建议只要用了组合器都明确写出一方面可读性好另一方面也避免某些浏览器或构建工具解析时的兼容性问题。2.3 媒体查询和条件规则应该写在哪嵌套规则不只适用于选择器还可以嵌套media、supports、container这类条件规则。最常见的是把媒体查询放进组件内部.grid { display: grid; grid-template-columns: 1fr; media (min-width: 768px) { grid-template-columns: 1fr 1fr; } }展开后等价于.grid { display: grid; grid-template-columns: 1fr; } media (min-width: 768px) { .grid { grid-template-columns: 1fr 1fr; } }这种写法的价值在于一个组件的所有响应式规则都被封装在同一个代码块里改组件的时候不用翻到文件底部的“媒体查询专区”去找对应代码。我以前维护过一个项目每个组件的桌面样式、移动端样式分散在十几个media块里改一个间距要同时动三四个位置。迁移到嵌套写法之后至少“一个组件一块区域”的目标实现了。条件规则内部同样可以使用.badge { media (min-width: 768px) { :hover { transform: scale(1.05); } } }这里依然代表父选择器.badge展开结果为media (min-width: 768px) { .badge:hover { transform: scale(1.05); } }2.4 一套常见的组件嵌套模板把上面几个知识点组合起来一个典型组件文件大概长这样.alert { padding: 12px 16px; border-radius: 6px; border: 1px solid transparent; .alert--success { background: #f6ffed; border-color: #b7eb8f; } .alert--error { background: #fff2f0; border-color: #ffa39e; } .alert-title { font-weight: 600; } .alert-desc { margin-top: 4px; color: rgba(0, 0, 0, 0.65); } media (max-width: 640px) { padding: 8px 12px; } }这套模板的核心思想是以组件根类名为边界内部所有结构和状态都在这个“作用域”里表达。看一眼.alert的代码块整个组件的结构和响应式行为就都清楚了。3. 初用者最容易踩的四个坑优先级、拼接、元素选择器与nest3.1 并不能像 Sass 那样拼接类名这是我从 Sass 迁移过来时栽过的第一个跟头。在 Sass 里你可以用拼接类名比如.card { __title { font-size: 18px; } }这段代码会编译为.card__title。这种“BEM 风格下的拼接式子元素命名”在 Sass 项目里非常常见。但到了原生 CSS 里直接照搬是行不通的.card { __title { font-size: 18px; } }这段代码在浏览器里不会被解析成.card__title而是会被当作一个无效选择器整条规则直接失效。原因是原生嵌套语法中必须作为组合器或选择器的一个组成部分不能参与类名的字符串拼接。那该怎么办方法无非两种要么回到普通的嵌套写法.card { .card__title { font-size: 18px; } }要么干脆不用嵌套保持类名扁平.card__title { font-size: 18px; }这个差异值得在迁移前就跟团队成员说清楚否则会有一堆人写出表面看起来能跑、实际完全没生效的代码。3.2 元素选择器不能直接裸写嵌套第二个常见坑是在嵌套块里直接写元素选择器.content { p { line-height: 1.8; } }原生 CSS 嵌套规范规定嵌套规则必须以、.、#、[、:、::、、、~等符号开头不能以类型选择器也就是div、p、span这类元素名开头。所以上面这种写法在原生 CSS 里是无效的。想命中父元素内部的元素选择器必须显式加上.content { p { line-height: 1.8; } }或者用:is(p)这种伪类函数包一层。不过实际项目中我建议优先给需要单独设置样式的元素加类名少用元素选择器嵌套这样选择器的优先级和可读性都可控。3.3 嵌套规则会被 :is() 包装优先级并不是简单相加这是嵌套规则里隐藏最深的细节。浏览器在处理嵌套规则时并不是直接做字符串替换而是会把父选择器包装进:is()里再参与优先级计算。:is()的优先级取参数列表中优先级最高的那个。举个例子#app .card { .title { color: #333; } }展开后实际等价于:is(#app .card) .title { color: #333; }这条规则的总优先级是:is(#app .card)的优先级1 个 ID 1 个类再加上.title的优先级1 个类最终是“1 个 ID 2 个类”。这和直接手写#app .card .title的优先级一样所以大多数场景下不会有问题。真正需要留意的是父选择器里有逗号分隔的情况.text-primary, .text-secondary { .icon { width: 16px; } }展开后等价于:is(.text-primary, .text-secondary) .icon { width: 16px; }:is()的优先级取两个参数中更高的那个也就是 1 个类。如果将来你给.text-secondary前面加个 ID 变成#sidebar .text-secondary那整个嵌套规则的优先级都会被拉高。这种隐式的优先级提升平时不容易察觉但一旦出现样式覆盖不生效的问题排查起来就很费力。所以我的建议是嵌套时保持父选择器简单尽量避免在一组嵌套规则里用复杂的逗号选择器如果必须用心里要对:is()的优先级规则有数。3.4 和 nest 之间的历史账如果你看过比较早的 CSS 嵌套规则相关文章可能会见到nest这个写法。这是早期版规范里的方案当时的设计是“不带 的嵌套规则必须放在 nest 里”。但最终发布的规范把nest整个去掉了直接允许符合条件的嵌套规则本身。现在你在新代码里不需要写nest写了反而会影响解析。如果团队里有成员是从老教程里学到的nest写法记得提醒他们更新认知。浏览器对nest的支持也更多是基于旧草案的兼容处理不建议依赖。4. 和 Sass、BEM、平铺写法的对比什么场景该用谁4.1 四种方案的关键差异对照同为“组织样式”的手段原生嵌套、Sass 嵌套、BEM 扁平命名、纯平铺写法各有侧重。我把它们放在一起对比维度原生CSS嵌套Sass嵌套BEM扁平命名纯CSS平铺是否依赖构建工具否是否否与DOM结构对应关系强强弱弱类名长度中等中等较长中等选择器优先级控制需注意:is()相对直观容易控制容易控制团队上手成本低中中低组件内部状态表达方便方便需要额外类名分散从表格可以看得很清楚原生嵌套最大的优势是不需要额外依赖同时又比纯平铺写法更能表达结构关系。BEM 的优势在于优先级完全可控但代价是类名偏长、书写偏繁琐。它们之间不是谁取代谁的关系而是能不能配合好的问题。4.2 从 Sass 迁到原生嵌套时除了语法还要注意什么如果团队决定从 Sass 迁移到原生嵌套除了前面说的拼接类名不能用之外还要注意几个隐性差异。Sass 的嵌套通常伴随着变量、混入mixin这些能力的依赖。原生 CSS 只有var()变量没有 mixin。把旧代码迁移过来时如果大量使用了mixin那就不是简单改嵌套语法的事得先想清楚这些逻辑怎么替换——比如用重复声明、用 CSS 变量或者用 PostCSS 插件在构建阶段做处理。另一个差异是 Sass 编译是“提前完成”的你看到的最终 CSS 是构建后产物而原生嵌套是浏览器实时解析的理论上对性能有微乎其微的影响。我在实际项目中用性能面板对比过日常组件规模下这个差异基本感知不到不必为了这一点纠结。比较务实的迁移策略是“边际迁移”新写的组件直接用原生嵌套老组件在重构时顺手改。不要把几个月的存量代码一股脑全部翻一遍风险大收益有限。4.3 嵌套和 BEM 可以共存块命名保持扁平内部关系用嵌套很多人误以为“用了嵌套规则 放弃 BEM”。实际上两者完全可以配合而且配合好了两边优势都能拿到。BEM 的核心是“块、元素、修饰符”的命名约定它希望每个类名能独立表达含义这样选择器优先级始终维持在 1 个类左右。嵌套规则的核心是“代码组织方式”它不影响你给元素起什么名字。所以你可以这样写.button { padding: 8px 16px; __icon { width: 16px; height: 16px; } _primary { background: #1677ff; color: #fff; } :hover { opacity: 0.85; } }等等——这段代码里__icon和_primary是拼接写法原生 CSS 是不支持的。所以我实际会用这样的写法.button { padding: 8px 16px; :hover { opacity: 0.85; } } .button__icon { width: 16px; height: 16px; } .button_primary { background: #1677ff; color: #fff; }这是我在项目里比较常用的一种混合风格块本身的样式和状态用嵌套组织BEM 命名为了一大串但优先级很低。简单的规则是嵌套负责表达“当前块内部的状态和结构”BEM 负责给命名一个稳定的语义。两者不冲突。4.4 用 :where() 和 :is() 配合嵌套控制特异性前面讲了嵌套会把父选择器包进:is()优先级取最高。如果想让嵌套规则完全降级不增加整体优先级可以手动使用:where()因为:where()的优先级恒为 0。比如你希望某个基础组件的内部结构样式可以被外部轻易覆盖就可以这么写.widget { :where() .widget-title { font-size: 18px; } }这里:where()把父选择器.widget的优先级归零于是整条规则的实际优先级只来自.widget-title自身。外部项目里只要写一个同优先级的规则就能覆盖它。这个技巧在发布通用组件库时特别有用日常业务项目里用得不多但知道有这条退路总是好的。5. 实战把一个弹窗组件从平铺重构为嵌套规则5.1 重构前的平铺代码我拿一个典型的弹窗组件举例。它的 HTML 结构大致是div classmodal div classmodal__header div classmodal__title提示/div button classmodal__close×/button /div div classmodal__body div classmodal__content这里是内容/div /div div classmodal__footer button classmodal__btn确定/button /div /div原先的 CSS 是这样的平铺结构.modal { position: fixed; inset: 0; z-index: 1000; background: rgba(0, 0, 0, 0.45); } .modal .modal__header { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid #f0f0f0; } .modal .modal__header .modal__title { font-size: 16px; font-weight: 600; } .modal .modal__header .modal__close { width: 24px; height: 24px; border: none; background: transparent; font-size: 20px; cursor: pointer; } .modal .modal__body { padding: 16px; } .modal .modal__body .modal__content { line-height: 1.6; color: rgba(0, 0, 0, 0.85); } .modal .modal__footer { display: flex; justify-content: flex-end; gap: 8px; padding: 16px; border-top: 1px solid #f0f0f0; } media (max-width: 640px) { .modal .modal__body { padding: 12px; } }你数一下.modal这个单词出现了多少次在这个不算复杂的组件里光完整路径就写了 8 次以上。修改的时候要把鼠标停在每个选择器上逐个确认命中范围。这不是功能性问题而是维护体验问题——改错一个层级样式就不生效。5.2 重构后的嵌套结构用嵌套规则重写一遍之后长这样.modal { position: fixed; inset: 0; z-index: 1000; background: rgba(0, 0, 0, 0.45); .modal__header { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid #f0f0f0; .modal__title { font-size: 16px; font-weight: 600; } .modal__close { width: 24px; height: 24px; border: none; background: transparent; font-size: 20px; cursor: pointer; } } .modal__body { padding: 16px; .modal__content { line-height: 1.6; color: rgba(0, 0, 0, 0.85); } media (max-width: 640px) { padding: 12px; } } .modal__footer { display: flex; justify-content: flex-end; gap: 8px; padding: 16px; border-top: 1px solid #f0f0f0; } }重构后的结构跟 HTML 的结构几乎一一对应先看到.modal的基础样式然后是头部、内容区、底部分别嵌套在各自块下面。媒体查询也被挪进了.modal__body内部修改移动端间距时不用再跳到文件底部去找。这里有个细节我保留了.modal__header这样的 BEM 类名而不是把它简化成.header或裸节点。原因前面说过——嵌套改变的是代码组织结构不是元素的语义命名。保留带块前缀的类名后续不管在哪里使用这个组件都不会因为父级类名不同而意外串样式。5.3 重构中哪些地方值得留意重构这个组件时有几个点是我实际踩过之后才反应过来的。第一个是“局部重构”和“全局改动”的边界。比如.modal .modal__close这行换成.modal__close嵌套后要确认页面里没有其他地方复用了modal__close这个类。如果有改动前先把公共样式的归属想清楚否则会出现样式突然被缩窄作用域的回归问题。第二个是不要让嵌套层级无限加深。弹窗这种三层结构已经是比较极限的深度了。如果再在.modal__content里面写.modal__content .info { }甚至更深阅读难度反而会上升最后无奈只能靠强大的记忆力和选择器优先级来压人。我给自己定的规则是嵌套最多四层超过四层先重构 DOM 层级。第三个是media嵌在中间与嵌在外层的差异。把媒体查询挪进.modal__body之后整个文件的响应式分布会从“集中一处”变成“分散在各组件内部”。对组件复用来说是好事但如果你习惯了全局媒体查询的集中管理刚开始会有一种“找不到断点在哪”的错觉。我的做法是组件内部的响应式样式用嵌套media全局布局的断点仍统一放到一个layout.css里维护两者分工。5.4 用 DevTools 验证展开后的真实选择器写完嵌套代码建议在浏览器里打开 DevTools 的 Elements 面板选中目标元素看看 Styles 标签里显示的“最终生效规则”。拿上面的.modal__content举例正常情况下你会看到类似这样的记录.modal .modal__content { line-height: 1.6; color: rgba(0, 0, 0, 0.85); }这个展开后的选择器就是浏览器实际使用的。如果某些规则没生效优先检查两点一是嵌套里的类名是否拼写正确、是否真的属于父节点内部二是优先级是否被其他规则压住。嵌套规则的优先级常常不是一眼就能看出来的尤其是有:is()参与的情况这时候 DevTools 里显示的“优先级权重”比人脑推算可靠得多。我在排查“样式不生效”类问题时有个比较高效的流程先在 Elements 面板里看命中元素的样式列表如果属性被划掉了说明有更高优先级或更靠后的同名规则覆盖如果在样式列表里根本没出现这条规则那大概率是选择器没命中或解析报错。嵌套语法刚推广时很多“不生效”其实是因为拼接类名这种不合法写法浏览器默默忽略了整条规则当时排查了好一会儿才定位到原因。6. 团队协作中的维护策略以及我用了大半年之后的一点体会6.1 嵌套深度和命名规范应该写进代码规范原生嵌套好用但也容易被滥用。编辑器里代码缩进越叠越深视觉上很好看维护时才发现问题。所以我在团队里推行了两条硬性约定一条是嵌套深度不超过四层。四层已经能覆盖绝大多数“组件根类名 区块 元素 状态”的场景超过四层的组件要么 DOM 结构能优化要么选择器写得太复杂应该停下来先拆组件而不是继续叠嵌套。另一条是嵌套内依然延续 BEM 或其他命名约定不要因为有了嵌套就随手写.card .content .main .title这种无语义路径。嵌套解决的是“代码放在哪”的问题命名解决的是“这个类代表什么”的问题两者不能混为一谈。配合 Stylelint 的max-nesting-depth规则可以在推送前就把超深嵌套拦下来。我第一次给项目加这条规则时还是有不少代码需要调整的但跑通之后就再也没有人为嵌套深度的问题吵过架。6.2 兼容性兜底与自动降级方案虽然现代浏览器对原生嵌套的支持已经很好但如果你维护的站点还有一定比例的老浏览器访客稳妥的做法是在构建阶段做自动降级。我目前用 Lightning CSS 来跑 CSS 处理它在构建时会自动把嵌套语法展开成普通选择器。PostCSS 的postcss-nested插件或者定位更准确的csstools/postcss-nesting插件也可以达到类似效果。使用这些工具后源代码里可以放心写嵌套线上产物则是完全平铺的老式 CSS不需要手写两份。降级方案要注意一点不要同时开两个插件做同一件事。比如项目里已经用了 PostCSS 的某种嵌套插件又在考虑换 Lightning CSS就会出现嵌套被反复处理、选择器被二次包装的情况。选一条链路配置清楚剩下的交给构建工具。6.3 我踩过几次坑之后留下的几条实操建议用原生嵌套大半年踩过上面说的所有坑也慢慢摸出了一些让自己舒服的用法。第一父子结构优先用省略的后代选择器但涉及状态、组合器时一定补上。这样写出来的代码最接近 HTML 结构同时也不会出现丢失或多余的歧义。第二一个组件文件就是一个“作用域盒”。组件根类名写在最外面内部状态、子元素、响应式规则都收进这个盒子里。打开文件的人不需要滚动来滚动去就能看到这个组件的全貌改一个组件也基本只需要改这一个文件。第三遇到“规则没生效”的怪问题时先不要急着加!important。嵌套规则的优先级可能不是你预期的DevTools 比直觉可靠。尤其要警惕父选择器里有 ID 或:is()列表时优先级会出现向上取高的情况此时最简单的方案往往是把嵌套拆掉一层让选择器回到透明的状态。最后分享一个小技巧如果你在用 VS Code可以在设置里把 CSS 的css.lint.validProperties打开或者直接安装样式校验插件写嵌套代码时就能在编辑阶段发现类似__title这种非法拼接不用等浏览器静默吞掉。我早期要是早一点开这个校验大概能省下好几个小时的排查时间。
返回列表