组件状态和就近差异样式

写组件样式时,经常会遇到一个很小但很烦人的问题:组件本身进入了某个状态,真正变化的却是里面一组子元素。

比如一个上传组件进入已选择文件、上传中、上传失败这几种状态时,预览图、按钮、提示文案都会跟着变。问题是,这个状态到底应该放在哪里。

如果状态全散在子元素上,模板很快会变乱;如果样式全堆在文件底部,读代码时又要来回跳。更好的入口是组件根元素:组件自己的状态放在根元素上,子元素只负责根据这个状态改变表现。

要把这件事写清楚,不能一上来就选框架方案。&@at-root、空格和拼接这些选择器机制,主要来自 SCSS、Less、Stylus 这类预处理器,或 PostCSS 插件这类后处理器;React 和 Vue 只是把各自常用的样式隔离方式叠加在这套机制上。

BEM 在这里解决什么

BEM 是一套 class 命名方法:Block 表示组件或模块,Element 表示组件内部元素,Modifier 表示变体或状态。

放到上传组件里,大概就是这样:

  • upload-card 是 Block,表示整个上传组件。
  • upload-card__preview 是 Element,表示上传组件里的预览区域。
  • upload-card--is-ready 是 Modifier,表示上传组件进入已准备状态。

这里纠结 BEM,不是因为所有项目都必须写 BEM,而是因为这篇文章讨论的是“一个组件状态影响多个子元素”。这种场景里,状态 class 不能只是裸 is-ready,否则很难看出它属于哪个组件;子元素也不能只是 .preview.button,否则大项目里容易撞名,搜索时也看不出归属。

BEM 的价值是把归属写进 class 名里:upload-card--is-ready 影响的是 upload-card 这个 Block,upload-card__preview 也是同一个 Block 里的元素。后面讨论根状态和子元素联动时,才能明确知道这些选择器是在同一个组件边界里工作。

组件自己的状态放在根元素上

组件自己的状态指的是会影响整个组件表现的状态。它不一定改变根元素自己的样式,但它会让多个子元素同时变化。

比如上传组件里的这些状态:

  • 已选中文件:预览图出现,按钮文案变化,文件信息出现。
  • 上传中:预览图变淡,按钮禁用,进度提示出现。
  • 上传失败:提示文案变成错误态,按钮变成重试入口。

先只看状态入口和样式摆放,最常见的是三种放法。

第一种是每个子元素自己判断:预览图加一个状态,按钮再加一个状态,提示文案也加一个状态。

<section class="upload-card">
  <div class="upload-card__preview upload-card__preview--is-ready"></div>
  <button class="upload-card__button upload-card__button--is-ready">继续上传</button>
  <p class="upload-card__hint upload-card__hint--is-ready">文件已准备好。</p>
</section>
.upload-card__preview {
  opacity: 0;
}

.upload-card__preview--is-ready {
  opacity: 1;
}

.upload-card__button {
  border-color: #b8c4d6;
}

.upload-card__button--is-ready {
  border-color: #1976d2;
}

.upload-card__hint {
  display: none;
}

.upload-card__hint--is-ready {
  display: block;
}

这能工作,但真实模板里这些 --is-ready 往往都来自同一个条件。状态越多,模板里重复判断越多,状态入口也被拆散了。

第二种是把状态放到组件根元素上。

<section class="upload-card upload-card--is-ready">
  <div class="upload-card__preview"></div>
  <button class="upload-card__button">继续上传</button>
  <p class="upload-card__hint">文件已准备好。</p>
</section>
.upload-card__preview {
  opacity: 0;
}

.upload-card__button {
  border-color: #b8c4d6;
}

.upload-card__hint {
  display: none;
}

.upload-card--is-ready .upload-card__preview {
  opacity: 1;
}

.upload-card--is-ready .upload-card__button {
  border-color: #1976d2;
}

.upload-card--is-ready .upload-card__hint {
  display: block;
}

这比第一种好,状态入口已经集中到了根元素。但样式文件变成了“先写基础样式,再去别处找状态覆盖”。读到 upload-card__preview 时,看不全预览图自己的变化;状态多了以后,成组的状态覆盖选择器也会堆在一起。

第三种仍然把状态放在根元素上,但把差异样式放回被影响的子元素附近。

<section class="upload-card upload-card--is-ready">
  <div class="upload-card__preview"></div>
  <button class="upload-card__button">继续上传</button>
  <p class="upload-card__hint">文件已准备好。</p>
</section>
.upload-card__preview {
  opacity: 0;
}

.upload-card--is-ready .upload-card__preview {
  opacity: 1;
}

.upload-card__button {
  border-color: #b8c4d6;
}

.upload-card--is-ready .upload-card__button {
  border-color: #1976d2;
}

.upload-card__hint {
  display: none;
}

.upload-card--is-ready .upload-card__hint {
  display: block;
}

这才是后面要优化的基础写法:状态入口在根元素,子元素在自己的样式附近描述“根状态出现时我怎么变”。这一章先用标准 CSS 写完整选择器,让关系保持直观。

完整选择器的问题也很明显:每个差异都要重复写 .upload-card--is-ready 和对应的子元素 class。后面讨论的预处理器和后处理器能力,比如 &、嵌套和 @at-root,本质上都是在优化这个写法的维护成本:保持同样的选择器关系,但少写重复 CSS 代码,并让差异更适合就近维护。CSS Modules、scoped 和 BEM 则是在这个基础上继续处理局部作用域、命名归属和组件边界。

基于这个前提,样式问题才变成:根元素既是状态所在的当前元素,又是子元素的祖先元素;当子元素需要根据根状态变化时,选择器要能表达这层联动。

这也是后面讨论 &@at-root、拼接和空格的原因。它们不是单纯的写法偏好,而是在描述“当前元素状态”和“祖先元素状态”到底是不是同一个 DOM。

& 和 @at-root 的选择器输出

& 引用的是当前完整父选择器。它既可以参与后代选择器,也可以参与拼接选择器。两者差一个空格,语义完全不同。

先看 Less:

.outer {
  .inner {
    .is-b& {
      color: red;
    }

    .is-b & {
      color: blue;
    }
  }
}

输出是:

.is-b.outer .inner {
  color: red;
}

.is-b .outer .inner {
  color: blue;
}

第一段是拼接:.is-b 被拼到当前完整选择器的第一个 compound selector 上。第二段是后代:.is-b 是外部祖先。

这个区别是后面所有问题的根源。如果只是写后代选择器 .is-b &,其实没什么悬念;它明确表示外层有一个状态祖先。真正容易绕的是 .is-b& 这种拼接写法,因为它试图把一个状态 class 拼到当前 selector 上。

SCSS 不接受 .is-b&。按 Sass parent selector 的规则,& 不能放在 span& 这类 compound selector 后面。SCSS 要用插值和 @at-root

.outer {
  .inner {
    @at-root .is-b#{&} {
      color: red;
    }

    @at-root .is-b #{&} {
      color: blue;
    }
  }
}

输出同样分成拼接和后代:

.is-b.outer .inner {
  color: red;
}

.is-b .outer .inner {
  color: blue;
}

这时还没有框架,也没有 CSS Modules 或 scoped。这里只能得出一个基础结论:@at-root .is-b#{&} 是 SCSS 里的拼接写法,@at-root .is-b #{&} 是外部祖先写法。

再看一个更贴近组件的例子:

.root {
  @at-root .is-ready#{&} {
    border-color: #86efac;
  }

  .preview-image {
    @at-root .is-ready#{&} {
      opacity: 0.72;
    }
  }
}

输出是:

.is-ready.root {
  border-color: #86efac;
}

.is-ready.root .preview-image {
  opacity: 0.72;
}

这就是 @at-root .is-ready#{&} 的一个好处:不用关心当前写在根节点块里,还是写在子元素块里。&.root 时输出 .is-ready.root&.root .preview-image 时输出 .is-ready.root .preview-image

React

React 这边可以先按样式隔离方式分成两条路:用了 CSS Modules,就按模块局部 class 处理;不用 CSS Modules、继续写普通全局 CSS,就可以按 BEM 处理。

CSS Modules:不必套 BEM

在 React 场景里,如果仍然用样式文件管理组件样式,CSS Modules 是很常见的主流写法。换成 CSS Modules 后,class 名进入模块局部作用域,源码里可以回到小驼峰:

<section className={clsx(styles.root, fileIsReady && styles.isReady)}>
  <img className={styles.previewImage} src={previewUrl} alt="" />
  <button className={styles.copyButton}>复制</button>
</section>

styles.isReady 仍然挂在组件根节点。它只是模块内的局部状态 class,不需要写成 root--is-ready,也不需要硬套 BEM。CSS Modules 后续会把 .root.isReady.previewImage 映射成带 hash 的实际 class,唯一性由工具链兜住。

根状态影响根节点自己时,两种写法都可以:

.root {
  &.isReady {
    border-color: #86efac;
  }
}

也可以写成:

.root {
  @at-root .isReady#{&} {
    border-color: #86efac;
  }
}

两者都会命中根节点。为了和子元素就近写法保持一致,CSS Modules 场景里也可以统一用 @at-root .isReady#{&}。这样写的时候不用关心当前块是根节点还是子元素,& 会自己展开成当前选择器。

根状态影响子元素时:

.root {
  .previewImage {
    opacity: 1;

    @at-root .isReady#{&} {
      opacity: 0.72;
    }
  }
}

Sass 输出是:

.isReady.root .previewImage {
  opacity: 0.72;
}

.isReady.root.root.isReady 命中的是同一个根节点,顺序不影响表现。

再看几个组合:

.root {
  &.isUploading {
    cursor: progress;
  }

  &.isCompact {
    .copyButton {
      min-width: 96px;
    }
  }

  .copyButton {
    @at-root .isReady#{&} {
      color: #047857;
    }

    @at-root .isUploading#{&} {
      pointer-events: none;
    }
  }
}

这些写法都建立在一个前提上:样式文件从唯一根 class .root 开始。顶层不要再套外部容器、逗号选择器或根级 :global(...)。如果顶层先套一层 .card

.card {
  .root {
    .previewImage {
      @at-root .isReady#{&} {
        opacity: 0.72;
      }
    }
  }
}

输出会变成:

.isReady.card .root .previewImage {
  opacity: 0.72;
}

状态被拼到了 .card 上;但 React 代码通常把 styles.isReady 挂在 .root 所在的 DOM 上,这时选择器就匹配不上。

逗号选择器也容易让分支丢状态:

.root,
.panel {
  .previewImage {
    @at-root .isReady#{&} {
      opacity: 0.72;
    }
  }
}

Sass 输出是:

.isReady.root .previewImage,
.panel .previewImage {
  opacity: 0.72;
}

第二个分支没有 isReady 约束。

React CSS Modules 的结论到这里就够了:单一 .root 起步,状态 class 挂根节点,根节点和子元素都可以统一用 @at-root .isReady#{&}。这条路不需要 BEM,唯一性由 CSS Modules 处理。

BEM:能写,但不是主流选择

React 项目如果使用普通全局 CSS,仍然可以按 BEM 写:

<section
  className={clsx(
    'upload-card',
    fileIsReady && 'upload-card--is-ready'
  )}
>
  <img className="upload-card__preview" src={previewUrl} alt="" />
  <button className="upload-card__button">复制</button>
</section>

这时要像 Vue scoped + BEM 一样考虑状态归属、根状态和子元素的关系;额外还要保证 Block 在全局样式空间里足够唯一。React 普通 CSS 没有 Vue scoped 的 data-v-* 自动限制,也没有 CSS Modules 的 hash 映射,.upload-card 这类 Block 一旦命名太泛,就会和其他页面或组件互相影响。

也就是说,React 写 BEM 没问题,但它走的是“靠命名维护归属和唯一性”的路线。只要样式是全局 CSS,Block 就不能只是一个普通词,必须足够像组件自己的名字。这条路现在不算 React 的主流选择;如果项目一定要这么写,后面的 Vue scoped + BEM 机制仍然可以参考,只是 React 少了 scoped 的 data-v-* 边界兜底。

Vue

Vue 也可以先分成两条路:CSS Modules 能用,但日常并不常见;更常见的是 SFC scoped + BEM。

CSS Modules:能用,但不是日常主路

Vue 也能用 CSS Modules。写法大概是这样:

<template>
  <section :class="[$style.root, fileIsReady && $style.isReady]">
    <img :class="$style.previewImage" :src="previewUrl" alt="" />
    <button :class="$style.copyButton">复制</button>
  </section>
</template>

<style module lang="scss">
.root {
  border-color: #d8e0ea;
}

.previewImage {
  opacity: 1;
}

.copyButton {
  color: #2563eb;
}

.root.isReady {
  border-color: #86efac;
}

.root.isReady .previewImage {
  opacity: 0.72;
}
</style>

如果 Vue 项目真的使用 CSS Modules,它和 React CSS Modules 一样,不需要靠 BEM 解决 class 全局唯一性,也不需要纠结 upload-card--is-ready 这类 Block modifier。状态仍然挂在根节点,差异样式仍然描述根状态和子元素的关系,只是 class 的局部化交给 CSS Modules。

但在 Vue SFC 的日常开发里,CSS Modules 用得不多。更常见的写法是模板里直接写语义化 class,再用 <style scoped> 让 Vue 编译器加上 data-v-* 边界。这样模板不用写 $style.xxx,调试和搜索也更接近日常习惯。所以 Vue 这边更需要把 scoped + BEM 这条路讲清楚。

Scoped + BEM:状态要带上 Block

Vue SFC 的 <style scoped> 会把当前组件里的样式限制在当前组件 DOM 上。按 Vue SFC CSS Features 的说明,scoped 会通过类似 data-v-* 的属性改写选择器和组件渲染出来的元素;子组件根节点也会同时受到父组件 scoped 样式和子组件自身 scoped 样式影响。

这层 data-v-* 对 Vue 项目很重要。没有它,.title.button.is-ready 这类普通 class 在大项目里很容易互相撞。BEM 能提升命名可读性和搜索能力,但真正把当前 SFC 样式限制在当前组件里的,是 Vue scoped 的 data attribute 机制。

所以 Vue scoped + BEM 里,组件状态不要写成裸 is-ready。根节点应该这样:

<section class="upload-card upload-card--is-ready">
  <div class="upload-card__preview"></div>
  <button class="upload-card__button">继续上传</button>
</section>

upload-card--is-ready 属于整个 upload-card。预览区变淡、按钮文案变化、底部信息出现,都是同一个根状态派生出来的表现。

可以这样分:

  • upload-card--compact 这类稳定形态,用普通 BEM modifier。
  • upload-card--is-readyupload-card--is-uploadingupload-card--is-failed 这类影响整个组件的运行时状态,也用 BEM modifier,但 modifier 内容以 is- 开头。
  • upload-card__drop-zone--is-dragging 这类只属于某个元素的瞬时状态,跟着那个元素自己的 class 走。

这样做有两个收益:

  • upload-card--is-ready 就能知道状态属于哪个组件。
  • scoped 输出里,状态选择器也会继续带当前组件的 data-v-* 限制。

Scoped + BEM 的差异样式要靠近被影响元素

状态放在根节点以后,样式还有一个取舍:差异应该写在根节点状态块里,还是写在子元素块里?

可以写成这样:

.upload-card {
  &--is-ready {
    .upload-card__preview {
      opacity: 0.72;
    }

    .upload-card__button {
      border-color: #1976d2;
    }
  }
}

这能看出 upload-card--is-ready 会影响哪些子元素,但子元素自己的基础样式和状态差异被拆开了。组件复杂一点以后,读 upload-card__preview 时还要去根节点状态块里找它被谁改过。

更适合维护的写法是把差异放回子元素附近:

.upload-card {
  $root: &;

  &__preview {
    opacity: 1;

    #{$root}--is-ready & {
      opacity: 0.72;
    }
  }

  &__button {
    border-color: #b8c4d6;

    #{$root}--is-ready & {
      border-color: #1976d2;
    }
  }
}

Sass 输出的关键选择器是:

.upload-card--is-ready .upload-card__preview {
  opacity: 0.72;
}

经过 Vue scoped 后,它会继续被加上当前组件的作用域属性。可以把最终关系理解成:

.upload-card--is-ready[data-v-xxx] .upload-card__preview[data-v-xxx] {
  opacity: 0.72;
}

真正重要的是两层关系同时成立:

  • upload-card--is-readyupload-card__preview 都属于同一个 Block。
  • scoped 的 data-v-xxx 把这条规则限制在当前 SFC 里。

艰难探索之后剩下的答案

为了给出这个结论,我们进行了艰难的探索,一路否掉,最终剩下了正确答案。

1. 裸 is-* 加空格:会变成泛祖先状态

这里最关键的判断是:BEM 不使用后代选择器表达 Element。Element 自己就是完整 class,例如 .outer__inner,而不是 .outer .inner

这句话容易被误读。BEM 最终选择器里当然可以有空格,比如根状态影响子元素时会写成:

.outer--is-b .outer__inner {
  color: red;
}

但这里空格两边都是完整的 BEM class:.outer--is-b 是 Block 的状态,.outer__inner 是同一个 Block 的 Element。它不是 .outer .inner 这种“Block 后代里有一个普通 .inner”。

一开始容易想到一条更短的路:根节点挂裸 is-ready,子元素附近用 .is-ready &。这个写法在普通后代选择器里很自然:

.outer {
  .inner {
    .is-b & {
      color: red;
    }
  }
}

输出是:

.is-b .outer .inner {
  color: red;
}

这表达的是“外部某个祖先有 .is-b,里面有 .outer,再里面有 .inner”。普通后代选择器可以这么理解,但 BEM 场景里,Element 不是 .outer .inner,而是 .outer__inner。同样写法放进 BEM 后会变成:

.outer {
  &__inner {
    .is-b & {
      color: red;
    }
  }
}

输出是:

.is-b .outer__inner {
  color: red;
}

这虽然形成了祖先关系,但祖先是裸 .is-b,没有绑定到 .outer 这个 Block。外层父组件、页面容器或其他祖先只要也用了 .is-b,就可能影响当前 .outer__inner。这就是加空格的问题:它变成了泛祖先状态,不是当前组件根 Block 的状态。

2. 裸 is-* 不加空格:会拼到 Element 自己身上

如果不加空格,写成 Less / Stylus / postcss-nested 支持的拼接形式:

.outer {
  &__inner {
    .is-b& {
      color: red;
    }
  }
}

输出会变成:

.is-b.outer__inner {
  color: red;
}

这也不成立。它要求同一个 DOM 同时拥有 .is-b.outer__inner,连祖先关系都没有了。

前两条路被否掉的原因很明确:

  • .is-b & 加空格,会输出 .is-b .outer__inner,状态变成泛祖先状态,可能被父组件或外层容器的同名状态影响。
  • .is-b& 不加空格,会输出 .is-b.outer__inner,状态和 Element 在同一个 DOM 上,匹配不到根状态影响子元素的结构。
  • .is-b 最大的问题是没有 Block 归属;读到 .is-b .outer__inner 时,要额外判断 .is-b 是当前组件自己的状态,还是外部给它施加的状态。

BEM 真正需要的是:

.outer--is-b .outer__inner {
  color: red;
}

状态属于根 Block,Element 也属于同一个 Block。裸 is-* 能帮助理解预处理器里的空格和拼接,却不能作为 BEM 组件状态规范。

3. 给裸状态补 Block:能匹配但仍然别扭

发现裸 .is-b 会变成泛祖先状态后,还可以继续补一层限制:既然状态在根节点上,那就把裸状态和根 Block 同时写出来。

.outer {
  &__inner {
    .is-b.outer & {
      color: red;
    }
  }
}

输出是:

.is-b.outer .outer__inner {
  color: red;
}

这个选择器可以命中下面的 DOM:

<section class="outer is-b">
  <div class="outer__inner"></div>
</section>

它确实解决了一部分匹配问题:.is-b 不再是任意祖先,必须和 .outer 出现在同一个根 DOM 上。但这个方案仍然别扭。

第一,.is-b 自己还是裸状态。只看状态 class,仍然不知道它属于哪个 Block;归属信息靠旁边的 .outer 补回来,而不是写在状态 class 自己身上。

第二,子元素样式被迫关注根节点结构。读到 .outer__inner 时,本来只想就近描述“根状态出现时我怎么变”,现在每个子元素都要知道根节点上同时有哪些 class。这会把局部样式重新拉回整体结构里。

第三,如果没有 Vue scoped 或 CSS Modules,.outer 还必须在全局足够唯一。否则 .is-b.outer .outer__inner 仍然可能跨组件误伤。这个方案能救一部分选择器匹配问题,但它不是一个舒服的组件规范。

4. CSS 变量:会把状态关系藏进另一条通道

另一个方向是避开选择器组合,把根状态转成 CSS 变量。

.upload-card {
  --preview-opacity: 1;
  --button-border-color: #b8c4d6;
}

.upload-card.is-ready {
  --preview-opacity: 0.72;
  --button-border-color: #1976d2;
}

.upload-card__preview {
  opacity: var(--preview-opacity);
}

.upload-card__button {
  border-color: var(--button-border-color);
}

这能工作,也适合主题、尺寸、颜色这类天然适合变量化的东西。但组件运行时状态不一定适合这么做。变量会变成另一条“整体通道”:子元素看起来没有状态覆盖,真正的变化藏在变量赋值里。状态一多,变量名、默认值和覆盖关系会分散到多个地方,读代码时还是要来回找。

5. 每个子元素自己挂状态:会把同一个根状态拆散

还可以回到每个子元素自己挂状态:

<section class="upload-card">
  <div class="upload-card__preview upload-card__preview--is-ready"></div>
  <button class="upload-card__button upload-card__button--is-ready">继续上传</button>
</section>

这也能工作,但它把同一个根状态拆成多个子元素状态。模板要重复判断,脚本要把一个状态分发给多个节点,后续加新状态时还容易漏掉某个子元素。

CSS 变量和子元素状态都不是错,只是它们解决的是别的问题。这里要处理的是“组件自己的一个状态影响多个子元素”,状态入口最好仍然留在根元素上,子元素在自己的样式附近写差异。

6. CSS Modules:能避开 BEM,但不是 Vue scoped + BEM 这条路

Vue CSS Modules 或 React CSS Modules 可以绕开 BEM 命名归属问题。模块局部 class 会被工具链映射成带 hash 的名字,源码里可以写:

.root {
  .previewImage {
    @at-root .isReady#{&} {
      opacity: 0.72;
    }
  }
}

在“样式文件严格从唯一根 class .root 开始”的前提下,SCSS 会输出:

.isReady.root .previewImage {
  opacity: 0.72;
}

CSS Modules 会继续把这些 class 做模块化映射。这个方向在 React 里很自然,因为 React 项目常用 CSS Modules;在 Vue 里也能用,只是不算日常主路。Vue SFC 更常见的是模板直接写 class,再用 <style scoped> 让编译器加上 data-v-* 边界。

所以 CSS Modules 是另一条成立的路线,不是 Vue scoped + BEM 这条路线的答案。当前讨论既然落在 Vue scoped + BEM,就不能用 CSS Modules 的 hash 唯一性来替 BEM 命名归属兜底。

7. 完整 BEM 选择器:正确但需要减少重复

排除前面的方案后,剩下的正确基线其实很朴素:

.upload-card--is-ready .upload-card__preview {
  opacity: 0.72;
}

它同时满足两个条件:

  • upload-card--is-ready 把状态归属写在根 Block 上。
  • upload-card--is-ready .upload-card__preview 用空格表达根状态和子元素之间的祖先关系。

如果不用任何预处理器,直接把完整选择器写全就可以:

.upload-card__preview {
  opacity: 1;
}

.upload-card--is-ready .upload-card__preview {
  opacity: 0.72;
}

.upload-card__button {
  border-color: #b8c4d6;
}

.upload-card--is-ready .upload-card__button {
  border-color: #1976d2;
}

这个写法语义最清楚,只是重复比较多。$root 要解决的是“少写重复 Block 名”,不是用来证明结论。

这里用的是后代关系:#{$root}--is-ready & 里,& 仍然指向当前子元素选择器,左边是根状态选择器。它不是 .is-ready#{&} 那种拼接场景,所以 Vue scoped + BEM 这条路不需要把 @at-root 当成必要写法。

.upload-card {
  $root: &;

  &__preview {
    opacity: 1;

    #{$root}--is-ready & {
      opacity: 0.72;
    }
  }

  &__button {
    border-color: #b8c4d6;

    #{$root}--is-ready & {
      border-color: #1976d2;
    }
  }
}

输出仍然是完整 BEM 祖先关系:

.upload-card--is-ready .upload-card__preview {
  opacity: 0.72;
}

.upload-card--is-ready .upload-card__button {
  border-color: #1976d2;
}

所以最终选择不是“用了 $root 才正确”,而是“完整 BEM 选择器正确,$root 只是把它写得更靠近子元素,也更少重复”。

PostCSS 不能只看名字

PostCSS 本身只是处理器,嵌套语义取决于插件。postcss-nested 更接近 Sass-like 展开器,postcss-nesting 则跟随 CSS Nesting specification。

如果用 postcss-nested,下面这种写法会按 Sass-like 心智展开:

.upload-card {
  &__preview {
    .upload-card--is-ready & {
      opacity: 0.72;
    }
  }
}

输出可以服务 Vue BEM 结构:

.upload-card--is-ready .upload-card__preview {
  opacity: 0.72;
}

如果项目使用 postcss-nesting 或原生 CSS nesting,就不能把 .is-ready& 这类写法当成 Less / Stylus 规则套进去。它可能进入 :is(...) 相关语义,输出和预期的 BEM 祖先关系不等价。

所以“项目用了 PostCSS”这个信息不够。真正要看插件链;不确定时,写一个最小片段编译,看输出选择器再定规则。

最终判断口径

Vue scoped + BEM 里可以按这几步判断:

  1. 先看状态属于整个组件,还是只属于某个元素。
  2. 属于整个组件时,状态 class 放在组件根节点,写成 block--is-state
  3. 属于某个元素时,状态 class 放在元素自己身上,写成 block__element--is-state
  4. 子元素受根状态影响时,差异样式写在子元素块附近。
  5. SCSS 里优先在 Block 根部保存 $root: &,子元素里写 #{$root}--is-state &
  6. Less、Stylus、postcss-nested 可以写成 .block--is-state &,但不要退回裸 .is-state&
  7. 外部祖先状态才写 .page--is-state &,不要和组件根状态混用。
  8. 原生 CSS nesting 或 postcss-nesting 不套 Sass-like 规则,先看编译输出。

组件代码里只保留状态入口:

<section
  class="upload-card"
  :class="{
    'upload-card--is-ready': fileIsReady,
    'upload-card--is-uploading': fileIsUploading
  }"
>
  <div class="upload-card__preview"></div>
  <button class="upload-card__button">继续上传</button>
</section>

样式里就近描述差异:

.upload-card {
  $root: &;

  &__preview {
    opacity: 1;

    #{$root}--is-ready & {
      opacity: 0.72;
    }

    #{$root}--is-uploading & {
      filter: saturate(0.7);
    }
  }

  &__button {
    min-width: 120px;

    #{$root}--is-uploading & {
      pointer-events: none;
    }
  }
}

这套规则不解决所有样式组织问题,但能处理很常见的一类组件状态:状态集中在根节点,差异落在多个子元素上。Vue scoped 负责当前组件边界,BEM 负责结构归属,SCSS / Less 只负责把这条关系写得更靠近被影响的元素。

再补一个务实提醒:样式就近不等于无限嵌套。选择器链超过三层、状态组合开始互相覆盖时,就该重新看组件结构、状态拆分和样式职责;继续往里嵌套通常会让覆盖关系更难判断。