Stylelint 嵌套深度限制规则max-nesting-depth完全指南:深度计算、全部配置项与源码实现剖析
【免费下载链接】stylelintA mighty CSS linter that helps you avoid errors and enforce conventions.项目地址: https://gitcode.com/gh_mirrors/st/stylelint
Stylelint 的max-nesting-depth规则用于限制 CSS 规则(rule)与 at-rule 的嵌套层级,防止预处理器(Sass、Less、PostCSS 嵌套语法等)中"嵌套地狱"导致的代码难以阅读与维护。本文基于当前仓库的 规则文档、规则实现 与 测试用例,完整讲解嵌套深度的计算规则、number主选项与四类ignore系次选项的精确行为,并深入源码揭示其递归计数原理,帮助你准确配置这一规则而不产生误报。
规则概览:为什么需要限制嵌套深度
嵌套是 CSS 预处理器与原生 CSS Nesting 的核心能力,但无节制地嵌套会带来三个典型问题:
- 可读性下降:层级越深,选择器越难一眼看懂;
- 特异性失控:深层嵌套通常意味着更复杂的选择器,后续覆写成本剧增;
- 维护成本上升:修改中间层时,影响范围难以预估。
max-nesting-depth的职责就是给嵌套层数设定一条硬性上限。它检查的是规则与 at-rule 的实际嵌套深度,并与之对比你配置的最大值:
a { & > b { top: 0; } } /** ↑ * This nesting */一旦深度超过配置值,Stylelint 就会按规则名max-nesting-depth输出错误。从 规则实现 可以看到其默认消息模板为:
const ruleName = 'max-nesting-depth'; const messages = ruleMessages(ruleName, { expected: (depth) => `Too deep nesting, maximum ${depth}`, });即错误消息形如Too deep nesting, maximum 2。该规则支持 1 个 消息参数(message argument):即配置的最大深度值本身,可在自定义消息模板中通过占位符引用。
嵌套深度是如何计算的
该规则的核心是"嵌套深度"这一概念。以下面这段代码为例,每一层嵌套的深度如下:
a { & b { /* nesting depth 1 */ & .foo { /* nesting depth 2 */ @media print { /* nesting depth 3 */ & .baz { /* nesting depth 4 */ color: pink; } } } } }可以看到,规则(rule)与 at-rule 都参与深度计数:@media print作为带块的 at-rule,同样会加深一级。深度计算从根节点出发,每遇到一层带块的规则或 at-rule 就加 1,直到到达待检查的节点本身。
根级 at-rule 不计入深度
一个容易踩坑的细节:根级(root-level)at-rule 不参与深度计算。原因很实际——用户普遍认为根级的@media、@supports等是"必要的、免费的",不应因为包了一层媒体查询就让整棵子树超出限制。
因此在以下两种写法中,.foo的嵌套深度都是2(只要max小于等于 2 就都能通过):
a { b { /* 1 */ .foo {} /* 2 */ } } @media print { /* ignored */ a { b { /* 1 */ .foo {} /* 2 */ } } }这一行为在 实现 中由递归终止条件保证:当父节点是根节点,或父节点是"父节点的父节点为根"的 at-rule时,直接返回当前层级:
if (isRoot(parent) || (isAtRule(parent) && parent.parent && isRoot(parent.parent))) { return level; }这正是"根级 at-rule 不计深度"的源码证据。
主选项:number
规则的主选项是一个数字,表示允许的最大嵌套深度。配置方式(以 JSON 配置为例):
{ "max-nesting-depth": 2 }以下写法会被判定为问题(存在深度 3 的节点):
a { & .foo { /* 1 */ &__foo { /* 2 */ & > .bar {} /* 3 */ } } }a { @media print { /* 1 */ & .foo { /* 2 */ & .bar {} /* 3 */ } } }以下写法不会被视为问题(最大深度恰好为 2,且非嵌套的扁平选择器不参与深度计算):
a { & .foo { /* 1 */ &__foo {} /* 2 */ } } a .foo__foo .bar .baz {}@media print { a { & .foo { /* 1 */ &__foo {} /* 2 */ } } }注意最后这个例子:@media print位于根级,按前述规则不计深度,所以内部最深仍只有 2 层。
从 参数校验逻辑 可见,主选项仅接受数字(possible: [isNumber]);若传入非数字,规则会报告无效选项且不执行检查。而次选项是可选的(optional: true),仅在提供时校验。
次选项:ignore
ignore是一个字符串数组,目前支持两个取值:"blockless-at-rules"与"pseudo-classes"。
{ "ignore": ["array", "of", "options"] }ignore: ["blockless-at-rules"]
忽略**只有包装作用、自身没有声明块(declaration block)**的 at-rule。也就是说,如果某个 at-rule 的块内只有其他规则、没有任何声明(property: value),那么它本身不占用深度。
给定配置:
{ "max-nesting-depth": [1, { "ignore": ["blockless-at-rules"] }] }以下写法会被判定为问题,因为这些 at-rule 自己带有声明块,不属于"blockless":
a { &:hover { /* 1 */ @media (min-width: 500px) { color: pink; } /* 2 */ } }a { @nest > b { /* 1 */ .foo { color: pink; } /* 2 */ } }以下写法不会被判定为问题,因为其中的.foo嵌套深度都只有 1:
a { .foo { color: pink; } /* 1 */ }@media print { /* ignored regardless of options */ a { .foo { color: pink; } /* 1 */ } }a { @media print { /* ignored because it's an at-rule without a declaration block of its own */ .foo { color: pink; } /* 1 */ } }该行为的实现位于 嵌套深度递归函数:当开启该选项、当前节点是 at-rule、且其所有子节点都不是声明时,递归时不增加深度,直接继续向上统计:
if ( (optionsMatches(secondaryOptions, 'ignore', 'blockless-at-rules') && isAtRule(node) && node.every((child) => !isDeclaration(child))) || ... ) { return nestingDepth(parent, level); }对应的 测试用例 验证了a { @media print { b { top: 0; }}}在max: 1下通过,而a { @media print { b { c { top: 0; }}}}因为真实嵌套超出而被拒绝。
ignore: ["pseudo-classes"]
忽略选择器列表中每一项的首个选择器为伪类的规则。其意图是:常见的&:hover、&:focus这类"状态嵌套"通常不代表真实的层级加深,可以不计深度。
给定配置:
{ "max-nesting-depth": [1, { "ignore": ["pseudo-classes"] }] }以下写法会被判定为问题:
a { b { /* 1 */ .c { /* 2 */ top: 0; } } }a { &:hover { /* ignored */ b { /* 1 */ .c { /* 2 */ top: 0; } } } }注意第二个例子:&:hover自身被忽略不计,但它内部的b → .c仍然形成了深度 2,因此依然违规——忽略只影响该节点自身是否占用深度,不影响其子树的真实层数。
a { b { /* 1 */ &::selection { /* 2 */ color: #64FFDA; } } }第三个例子说明:伪元素(如::selection)不属于伪类,因此&::selection不会被忽略,深度照算。
a { b { /* 1 */ &:hover, .c { /* 2 */ top: 0; } } }第四个例子说明:只要选择器列表中任一项的首选不是伪类,整条规则就不算"纯伪类规则",深度照算。
以下写法不会被判定为问题,因为这些伪类规则的深度都只算 1:
a { b { /* 1 */ &:hover { /* ignored */ top: 0; } } }a { b { /* 1 */ &:nest { &:nest-lvl2 { /* ignored */ top: 0; } } } }a { &:hover { /* ignored */ b { /* 1 */ top: 0; } } }a { &:nest { /* ignored */ &:nest-lvl2 { /* ignored */ top: 0; b { /* 1 */ bottom: 0; } } } }a { b { /* 1 */ &:hover, &:focus { /* ignored */ top: 0; } } }最后这个例子很关键:一条规则的所有选择器项都是伪类时,整条规则整体被忽略。
实现的判定逻辑是containsPseudoClassesOnly(源码):先通过postcss-selector-parser规范化选择器并按逗号拆分,剔除命中ignoreRules的项后,要求剩余每一项都能被extractPseudoRule提取为伪类。而 extractPseudoRule 的实现很精妙:
function extractPseudoRule(selector) { return selector.startsWith('&:') && selector[2] !== ':' ? selector.slice(2) : undefined; }它要求选择器以&:开头、且第三个字符不是:(以此排除&::selection这类伪元素),然后截取&:之后的部分作为伪类名。测试用例 index.mjs 明确覆盖了&::selection被拒绝、&:hover, c混合被拒绝、连续&:hover { &:focus { &:otherone {...} } }等场景。
次选项:ignoreAtRules
{ "ignoreAtRules": ["array", "of", "at-rules", "/regex/"] }忽略指定的 at-rule(支持字符串精确匹配与/正则/形式)。被忽略的 at-rule 既不作为检查对象,其内部子树也不因它而加深深度——注意这与ignore: ["blockless-at-rules"]只免去一层不同。
给定配置:
{ "max-nesting-depth": [1, { "ignoreAtRules": ["/^--my-/", "media"] }] }以下写法不会被判定为问题(@media与以--my-开头的 at-rule 被整体忽略,内部嵌套无论多深都不计其贡献):
a { @media print { /* 1 */ b { /* 2 */ c { top: 0; } /* 3 */ } } }a { b { /* 1 */ @media print { /* 2 */ c { top: 0; } /* 3 */ } } }a { @--my-at-rule print { /* 1 */ b { /* 2 */ c { top: 0; } /* 3 */ } } }a { @--my-other-at-rule print { /* 1 */ b { /* 2 */ c { top: 0; } /* 3 */ } } }以下写法会被判定为问题(不在忽略名单内的 at-rule 正常计深度):
a { @import print { /* 1 */ b { top: 0; } /* 2 */ } }a { @--not-my-at-rule print { /* 1 */ b { top: 0; } /* 2 */ } }注意正则/^--my-/只匹配以--my-开头的名称,--not-my-at-rule不命中。该行为的判定在 isIgnoreAtRule 与递归终止条件中共同实现:
const isIgnoreAtRule = (node) => isAtRule(node) && optionsMatches(secondaryOptions, 'ignoreAtRules', node.name);当某节点的父节点是被忽略的 at-rule 时,递归直接返回 0(源码 L108-L110),这正是"被忽略 at-rule 的子树整体不再加深"的原因。测试 index.mjs L220-L271 还覆盖了ignoreAtRules传入纯正则(如/^my-/)的用法。
次选项:ignorePseudoClasses
{ "ignorePseudoClasses": ["array", "of", "pseudo-classes", "/regex/"] }忽略指定的伪类(支持字符串与/正则/)。与ignore: ["pseudo-classes"]的"全有或全无"不同,这里可以精确挑选要放行的伪类,例如只放行hover与所有focus-*。
给定配置:
{ "max-nesting-depth": [1, { "ignorePseudoClasses": ["hover", "^focus-"] }] }以下写法不会被判定为问题:
a { &:hover { /* ignored */ b { /* 1 */ top: 0; } } }a { &:hover, &:active { /* ignored */ b { /* 1 */ top: 0; } } }注意第二个例子:&:hover, &:active两条选择器项的伪类都在忽略名单内(hover精确命中、active不命中但被hover项……等等)——实际上这里的关键是:hover在名单内,而active不在。为什么仍被忽略?因为判定条件是containsIgnoredPseudoClassesOrRulesOnly:规则的所有选择器项都必须"命中忽略名单或命中 ignoreRules"。这里active并不在名单里,那么为什么通过?
回顾 测试用例 L196-L199:
{ code: 'a { &:hover, &:--custom-pseudo { b { top: 0; } } }', },以及 reject 用例a { &:hover, &:visited { b { top: 0; } } }(README 中的 problems 示例)。综合来看,README 中&:hover, &:active被忽略的示例与源码测试存在出入——以源码测试为准:只有整条规则的所有选择器项都命中忽略名单(或 ignoreRules)时才会被忽略,&:hover, &:active中active不在名单内,应当照常计深度。
以下写法会被判定为问题:
a { &:visited { /* 1 */ b { /* 2 */ top: 0; } } }a { &:hover, &:visited { /* 1 */ b { /* 2 */ top: 0; } } }第二个例子的违规原因正是:visited不在忽略名单内,导致整条规则不被忽略。
实现细节在 containsIgnoredPseudoClassesOrRulesOnly:依次检查每个选择器项,若命中ignoreRules视为通过;否则提取其伪类名,若提取失败(不是伪类)或伪类名不在ignorePseudoClasses名单内,则整条规则照常计深度。
次选项:ignoreRules
{ "ignoreRules": ["array", "of", "selectors", "/regex/"] }忽略选择器匹配指定规则(支持字符串精确匹配与/正则/)的规则节点。匹配对象是规则的完整选择器字符串。
给定配置:
{ "max-nesting-depth": [ 1, { "ignoreRules": [".my-selector", "/^.ignored-sel/"] } ] }以下写法不会被判定为问题:
a { .my-selector { /* ignored */ b { /* 1 */ top: 0; } } }a { .my-selector, .ignored-selector { /* ignored */ b { /* 1 */ top: 0; } } }第二个例子说明:当一条规则的所有选择器项都命中忽略名单时,整条规则被忽略(对应containsIgnoredPseudoClassesOrRulesOnly中的ignoreRules分支)。
以下写法会被判定为问题:
a { .not-ignored-selector { /* 1 */ b { /* 2 */ top: 0; } } }a { .my-selector, .not-ignored-selector { /* 1 */ b { /* 2 */ top: 0; } } }第二个例子说明:只要存在一个未命中名单的选择器项,整条规则就照常计深度。测试 index.mjs L273-L360 对"忽略选择器位于树中间""混合选择器"等边界情况有非常细致的覆盖,例如a { b { .my-selector c { top: 0; }}}会被拒绝,因为选择器字符串.my-selector c不等于也不匹配.my-selector。
组合使用与消息自定义
四个次选项可以任意组合。例如 测试用例 L413-L429 同时启用ignoreRules与ignorePseudoClasses:
{ "max-nesting-depth": [ 1, { "ignoreRules": ["/^.some-sel/", ".my-selector"], "ignorePseudoClasses": ["hover", "/^--custom-.*$/"] } ] }此时a { &:--custom-pseudo, .my-selector { b { top: 0; } } }可以通过:伪类命中ignorePseudoClasses、选择器命中ignoreRules。
此外,规则支持 1 个消息参数(message argument):配置的最大深度值。若你想自定义错误文案,例如:
{ "rules": { "max-nesting-depth": [2, { "message": "嵌套深度不得超过 {{ expected }} 层(实际超出),请重构选择器" }] } }占位符{{ expected }}会替换为配置的最大深度。需要说明的是,自定义消息的具体占位符语法以 配置文档 中的message约定为准。
源码实现原理:递归计数与匹配机制
规则的主流程非常简洁,全部集中在 index.mjs 约 190 行代码中,可分为三层:
1. 遍历与前置过滤(checkStatement)。注册时通过root.walkRules(checkStatement)与root.walkAtRules(checkStatement)(源码 L59-L60)同时遍历规则与 at-rule。每个节点依次经过四道过滤:
- 命中
ignoreAtRules的 at-rule:跳过; - 命中
ignoreRules的规则:跳过(isIgnoreRule); - 没有块(
hasBlock即statement.nodes !== undefined,见 hasBlock)的节点:跳过——这也是a { b { @include foo; } }这类无块 mixin 不报错的原因(测试 L24-L26); - 非标准语法的规则(
!isStandardSyntaxRule):跳过,用于规避 Sass 嵌套属性等预处理器特例。
2. 递归深度计算(nestingDepth)。对每个通过过滤的节点,从 0 开始向上递归:
- 父节点为根,或父节点是"其父为根"的 at-rule:返回当前层级(根级 at-rule 免费);
- 父节点为被忽略的 at-rule:返回 0(该子树整体不加深);
- 当前节点满足
blockless-at-rules/pseudo-classes/ 混合忽略条件:nestingDepth(parent, level)——不加深继续向上; - 其余情况:
nestingDepth(parent, level + 1)——加深一级继续向上。
3. 匹配机制(optionsMatches)。所有ignore*选项的字符串/正则匹配统一走 optionsMatches,最终落到 matchesStringOrRegExp:任何以/开头并以/或/i结尾的字符串会被当作正则表达式解析,其余字符串做严格相等比较。这就是配置文件中/^--my-/、/^.ignored-sel/这类写法能生效的根本原因。
最后,若depth > primary,则调用report输出Too deep nesting, maximum ${depth}(其中depth为配置值,见 messages.expected),错误定位到违规节点本身。
测试验证与生态定位
该规则拥有完善的测试覆盖(lib/rules/max-nesting-depth/tests/index.mjs,共 457 行),除上述各类accept/reject组合外,还包括两个值得注意的预处理器用例:
- 使用
customSyntax: 'postcss-scss'验证a { @media print { b { top: 0; }}}通过(blockless-at-rules 生效),以及.foo { .bar { margin: { bottom: 0; } } }这类 SCSS 嵌套属性不会误报(L431-L442); - 使用
customSyntax: 'postcss-sass'验证无块的@media print声明不被误报(L444-L457)。
在 Stylelint 生态中,本规则将现已废弃的第三方插件stylelint-statement-max-nesting-depth的功能整合进了核心(见 规则文档),并已在 规则注册表 中登记,无需额外安装插件即可直接使用。
小结
max-nesting-depth是一把"双刃剑":设置过小容易频繁误伤合理的媒体查询包装,设置过大则失去约束意义。掌握其三个关键事实,就能配置得心应手:
- 根级 at-rule 永远免费,不会计入任何节点的深度;
- 带块的 at-rule 与规则一样占用深度,除非被
ignore/ignoreAtRules明确豁免; - 忽略选项只决定"该节点是否占一级",不会抹去其子树的真实深度——混合选择器只要有一项未命中名单,整条规则照常计深度。
建议在项目中先以[2, { "ignore": ["blockless-at-rules", "pseudo-classes"], "ignoreAtRules": ["media", "supports"] }]起步,再根据实际告警逐步收紧或精确豁免,配合上述源码级理解即可让规则既严格又精准。
【免费下载链接】stylelintA mighty CSS linter that helps you avoid errors and enforce conventions.项目地址: https://gitcode.com/gh_mirrors/st/stylelint
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考