跳到正文

组件状态和就近差异样式

写组件样式时,经常需要处理一层联动:组件本身进入某个状态,里面一组子元素的表现随之变化。

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

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

根状态怎样匹配子元素,取决于选择器的写法。&、@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,表示上传组件进入已准备状态。

一个组件状态影响多个子元素时,状态 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 Modules 和 scoped 处理样式作用范围,BEM 标明结构和状态归属。

这段关系适合先用一个小演示试一遍。切换「根 BEM modifier」「子元素重复状态」「裸祖先状态」和「CSS 变量通道」时,注意统计里的状态 class 分布;再勾选「外层容器也带 is-ready」,裸祖先状态会更容易暴露误伤风险。

组件根状态和子元素差异演示正在加载代码工作区...

根元素既带有状态 class,也是子元素的祖先。选择器需要准确表达这层联动;&、@at-root、拼接和空格的用法,决定状态匹配的是当前元素还是祖先元素。

& 和 @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。

先展开 SCSS,再看选择器的真实含义

命名、class 映射与属性约束,分别解决什么

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 约束。

在本文的单一 .root 约定下,状态 class 挂根节点,根节点和子元素都可以用 @at-root .isReady#{&}。class 的局部命名由 CSS Modules 处理,不需要再靠 BEM 保证唯一性。

普通全局 CSS:用 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 一旦命名太泛,就会和其他页面或组件互相影响。

Vue

Vue 同样支持 CSS Modules。本文主要讨论我采用的 SFC scoped + BEM 写法,先说明它与 CSS Modules 的职责差异。

CSS Modules:局部 class 由工具链处理

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。

采用 scoped 时,模板可以直接写语义化 class,再由 Vue 编译器加上 data-v-* 限制,不需要 $style.xxx。这条路线里,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-ready、upload-card--is-uploading、upload-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 后,普通后代选择器会在目标元素一侧加上作用域属性。以 Vue 3.5.44 的 compileStyle 输出为例:

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

生成的选择器需要同时满足两层关系:

  • upload-card--is-ready 和 upload-card__preview 都属于同一个 Block。
  • data-v-xxx 限制被匹配的预览元素;编译器没有给这里的祖先状态段自动补上同样的属性。

因此 scoped 不能替裸祖先状态补出 Block 归属。.is-ready .upload-card__preview[data-v-xxx] 仍可能被外层同名状态触发,祖先关系要由 class 选择器自己表达。子组件根节点和递归组件的匹配范围也仍需按 Vue 的 scoped 规则检查。

几种简写的适用边界

下面比较几种能减少重复的写法。要验证的始终是同一件事:状态挂在组件根节点时,生成的选择器是否仍然命中这个根节点和它的子元素。

空格和拼接位置,决定状态匹配哪个节点

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,同样写法放进 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 自己身上。

第二,每个子元素的差异样式都要写出根节点上的状态 class 与 Block 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:由工具链处理局部命名

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 和 Vue 都可以采用这条路线。采用 Vue scoped + BEM 时,模板直接写 class,scoped 通过 data-v-* 限制选择器范围,BEM 则把组件和状态归属写进名字。

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

在当前 scoped + 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 是求值时的选择器值。Sass 的 & 表达式可以参与变量赋值和插值;即使后面进入 &__preview,$root 仍然保存 .upload-card。重命名组件时,改根选择器就能让这些状态关系一起更新。

如果项目使用 Less,不能把 SCSS 里的 $root: & 原样搬过去。Less 支持变量插值,也支持父选择器 &,这里可以把 Block 名保存成普通字符串:

.upload-card {
  @root: upload-card;

  &__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;
}

这两种方案的行数差不多,维护成本主要来自信息重复:Less 的 .upload-card 和 @root: upload-card 都包含 Block 名,重命名或复制组件时要同时检查两处。SCSS 的 $root: & 直接从当前位置取值,只维护根选择器即可。

这里的 @root 和 SCSS 的 $root 名字相似,但含义不同。Less 变量遵循延迟求值和作用域查找规则,写下一个 & 并不能得到 Sass 那样的根选择器快照。以 Less 4.9.1 为例:

.upload-card {
  @root: &;

  &__preview {
    @{root}--is-ready & {
      opacity: 0.72;
    }
  }
}

实际输出是:

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

--is-ready 被接到了 Element 上,而模板把状态放在 .upload-card 所在的根节点。代码能编译,但选择器没有表达需要的 DOM 关系。

如果某个组件里只出现一两处根状态影响子元素,直接写完整选择器也可以:

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

这比为了少写一次根 class 再包一层 mixin 或 detached ruleset 更值得保留。抽象本身也有理解成本;当重复只是一两个完整 Block 名时,直接写清楚通常更好。

因此,在这个场景里比较 SCSS 和 Less,重点是根选择器能否直接复用、重命名需要维护几处信息,以及是否值得为了减少重复再加封装。根状态和子元素联动这件事,二者都能做到。

Stylus 可以引用嵌套起点或指定层级,但不同写法仍要比较编译输出,不能把层级引用当成永远稳定的 Block 名称。完整内容见Stylus 的选择器引用弹窗。

PostCSS 的嵌套展开、祖先引用与标准 CSS nesting 是不同能力。选择插件前先确认实际输出,以及是否支持 BEM 名称拼接。完整内容见PostCSS 与标准嵌套的对照弹窗。

选择器能力之外,还要比较日常维护成本

只看这一种根状态关系,SCSS、Less、Stylus 和 PostCSS 插件都能实现。选择工具时,更有区别的是重复信息、嵌套调整和其他语法进入编译器后的行为。

方案 根选择器如何复用 日常需要维护的约定
SCSS $root: & 保存选择器值 在 Block 根部保存;选择器列表和复杂外部包装另行验证
Less 保存 Block 名字符串,或直接写完整根状态 重命名时同步字符串,避免把 @root: & 当成快照
Stylus ~/ 或 ^[0] 引用第一层;也可用 selector() 从组件 Block 开始;不要让外部包装改变第一层
postcss-nested 加祖先插件 ^&、^^& 按层级回退 插件组合、层级变化和不同深度占位符的限制
postcss-current-selector 加变量、嵌套插件 提取 %@ 后再做变量替换 三类插件的语法、执行时机和版本组合
标准 CSS nesting 写完整 BEM class,按祖先关系嵌套 不使用 &__element,检查特异性与目标浏览器

当项目还使用 UnoCSS @apply 时,会遇到另一层解析差异。例如 leading-[1.65] 是 UnoCSS 的任意值工具类,预处理器必须先保留它,UnoCSS 才有机会展开。

以下结果来自 Sass Embedded 1.105.1、Less 4.9.1 和 Stylus 0.64.0,只检查预处理器阶段是否完整保留 @apply 参数:

输入 SCSS Less Stylus
@apply leading-[1.65],按各自语法补分号 保留原参数 解析失败 表达式求值失败
@apply 'leading-[1.65]',按各自语法补分号 保留字符串参数 保留字符串参数 保留字符串参数

SCSS 能直接保留这类任意值。Less 和 Stylus 可以通过引号保护工具类字符串;这是 UnoCSS 支持的写法。最终仍要让 directives transformer 展开这些内容,不能把预处理成功当成样式已经生效。这里说的是 UnoCSS,不能直接推广为 Tailwind @apply 的通用语法。

Less 的额外成本因此可以落到两个具体地方:根 Block 名需要重复维护,部分任意值需要额外的引号约定。它不一定表现为更长的代码,而是开发、重命名和复制组件时需要记住更多条件。

Stylus 用初始引用解决了根名称重复的问题,同时保留缩进语法。如果项目已经完成 formatter、Lint、Vue scoped 和 UnoCSS 构建验证,引号写法也已统一,那么仅为了去掉这一对引号再迁移,收益通常有限。升级编译器后,用最终 CSS 检查这些约定是否仍然成立即可。

新项目没有既有语法偏好时,SCSS 可以作为这一组需求下的默认选择:根选择器可以直接保存,常见 UnoCSS 任意值也能直接通过。已有 Stylus 项目可以继续保留;已有 PostCSS 插件链则按实际需求选择祖先引用或完整 BEM 选择器,不必为了统一写法重新组合整条工具链。

用实际输出复核选择器关系

下面的工作区包含编译脚本、22 个输入及其预期结果,以及本次运行的完整输出。它检查根选择器复用、深层 Element、错误层级、变量捕获、标准 nesting 的拼接边界和任意值解析;正确的选择器还会经过 Vue 3.5.44 的 scoped 编译。标记为 boundary 的用例是反例,测试通过表示错误行为已被如实记录,不表示该写法可以采用。

把工作区的 copy/ 文件保存到本地后,在该目录执行:

pnpm install
pnpm test

脚本只在本地运行,输出 results.json。依赖版本固定在 package.json;快照来自 2026-10-23 的隔离编译,不包含生产构建、CSS Modules 映射或浏览器兼容性承诺。源码中已有的 results.json 可用于对照升级前后的差异。

完整实现与接入示例见选择器编译输入与结果弹窗。

上传组件仍采用根状态入口与就近差异样式;按钮禁用使用原生 disabled 或组件接口。样式状态不能代替键盘与业务行为约束。完整内容见上传组件的完整对照弹窗。

总结

同一个组件状态影响多个子元素时,状态入口留在根节点,差异样式靠近被影响的元素,可以同时减少模板里的重复判断和样式里的来回查找。选择器仍要准确表达根与子元素的关系:CSS Modules 负责局部 class 映射;Vue scoped 限制匹配范围,BEM 补足结构和状态归属,不能靠 scoped 把裸祖先状态变成当前 Block 的状态。

减少重复选择器的工具各有条件。SCSS 可以保存根选择器,Less 可以保存 Block 名字符串,Stylus 可以引用第一层,PostCSS 则取决于嵌套、祖先引用和变量插件的组合。它们的差别会在重命名、增加嵌套和解析 UnoCSS 任意值时显现,标准 CSS nesting 也不能沿用 &__element 的拼接习惯。

本文这组需求下,新项目可以默认考虑 SCSS;已有工具链经过验证时,保留它往往更合适。判断依据是最终 CSS 是否匹配预期 DOM,以及维护者需要记住多少额外约定。样式联动之外,按钮禁用等行为仍要由原生属性或组件接口负责。

Stylus 的选择器引用

Stylus 有直接引用初始选择器的语法

Stylus 可以用 ~/ 初始引用指向选择器嵌套的第一层。在单一组件 Block 开始的样式里,这一层正好就是要复用的组件根选择器:

.upload-card
  &__preview
    opacity 1

    ~/--is-ready &
      opacity .72

  &__button
    border-color #b8c4d6

    ~/--is-ready &
      border-color #1976d2

其中 ~/ 取 .upload-card,后面的 --is-ready 拼成根状态;空格后的 & 仍然取当前 Element。Stylus 0.64.0 编译得到的状态选择器如下,省略基础样式:

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

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

这种写法不用再保存一个变量,也不用重复 Block 名。子元素继续嵌套时,初始引用保持不变:

.upload-card
  &__actions
    &__button
      ~/--is-uploading &
        opacity .5

输出是 .upload-card--is-uploading .upload-card__actions__button。~/ 也可以写成 ^[0];数字 0 表示选择器链的第一层,和向上数一层的相对引用不同。

初始引用依赖的是源码嵌套层级。它不会识别哪个 class 在业务上代表组件。如果在最外面套一层页面选择器:

.page
  .upload-card
    &__preview
      ~/--is-ready &
        opacity .72

实际输出会变成:

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

这时引用的是 .page,已经偏离组件根状态。采用 ~/ 的组件样式要从 Block 开始;外部祖先约束放在需要它的分支里,不要把所有组件样式包进一个外部选择器。

如果更习惯显式命名,也可以用 selector()保存当前编译后的选择器,再用插值引用:

.upload-card
  root = selector()

  &__preview
    {root}--is-ready &
      opacity .72

在这个单一 Block 示例中,它与 ~/--is-ready & 的输出一致。对于多分支选择器、外部包装和 mixin,仍应分别检查输出;把选择器保存下来不等于已经解决了所有拼接语义。

SCSS 保存选择器、Less 重复 Block 名、Stylus 引用第一层的差异

这张图只比较同一个 Block 下的引用来源。三种方案都能生成正确的根状态关系,区别在于根名称从哪里来、改名需要同步几处信息,以及源码嵌套变化是否会影响引用。

PostCSS 与标准嵌套的对照

PostCSS 要分清嵌套展开与祖先引用

PostCSS 提供 CSS 语法树和插件执行机制,具体的嵌套、变量和祖先引用由插件实现。名字很相近的两个插件,采用的是不同语义:

  • postcss-nested展开 Sass-like 嵌套,支持 &__preview 这样的后缀拼接。
  • postcss-nesting实现标准 CSS nesting,& 表示选择器关系,不能当成 BEM 字符串前缀。

只用 postcss-nested:可以就近写,但要重复根 class

下面这段只需要 postcss-nested:

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

输出能表达正确的根状态关系:

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

它满足差异就近放置的要求,但 .upload-card--is-ready 仍然要手写。postcss-nested 本身不提供 Sass 的 $root: & 变量语义;要避免重复根 class,还需要其他能力。

postcss-nested-ancestors:按层级取得祖先选择器

postcss-nested-ancestors提供 ^&,表示从当前选择器上下文回退一级。它先处理祖先引用,再由 postcss-nested 展开嵌套:

import ancestors from 'postcss-nested-ancestors';
import nested from 'postcss-nested';

export default {
  plugins: [ancestors(), nested()],
};

在 Element 里回退一级,正好取得 Block:

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

PostCSS 8.5.30、postcss-nested-ancestors 3.0.0 与 postcss-nested 9.0.1 的实际输出是:

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

它可以省掉重复的根 class,但 ^& 是相对层级引用。中间新增一层 Element 后,要相应改成 ^^&:

.upload-card {
  &__actions {
    &__button {
      ^^&--is-ready & {
        opacity: 0.72;
      }
    }
  }
}

这会得到 .upload-card--is-ready .upload-card__actions__button。如果仍然写一个 ^,状态就会落到 .upload-card__actions 上。相比 Stylus 的 ~/,这种写法更依赖使用点距离根节点有几层。

插件需要在嵌套树仍然存在时取得祖先。把输入先用 postcss-nested 处理成扁平 CSS,再交给祖先插件,本次验证会残留错误的 ^ 选择器。配置采用官方建议的顺序,并检查真实产物;PostCSS 插件还有不同执行 hook,不能只交换数组中两个名字,就认定已经模拟了两次独立处理的时序。

该插件的 Known issues还说明,同一条选择器里混用不同深度的祖先占位符有已知限制。本次输入 ^&--is-ready ^^&--is-active & 会收到 warning,并把两个状态都拼到同一层祖先上;不要把这类输出当作可靠的状态组合方案。

同样是去掉重复根 class,初始引用和相对祖先引用在增加层级后的区别

current-selector 加变量插件:也能保存根,但组合更多

postcss-current-selector可以把声明值中的 %@ 替换成当前选择器。再配合 postcss-simple-vars 和 postcss-nested,可以写成:

.upload-card {
  $root: %@;

  &__preview {
    $(root)--is-ready & {
      opacity: 0.72;
    }
  }
}

这里 $(root) 是变量插件的插值语法,不能和 Sass 的 #{$root} 混用。本次按 currentSelector()、simpleVars()、nested() 组合,能够生成 .upload-card--is-ready .upload-card__preview;多个独立 Block 的输入也检查过了。

这条路线具备保存根选择器的能力,但同时引入选择器提取、变量替换和嵌套展开三项插件约定。复用已有插件链时可以评估它;只为了这一种状态写法从成熟预处理器迁过去,新增的维护项往往比少写的几个字符更多。

标准 nesting:支持状态联动,不提供 BEM 拼接

标准 CSS nesting 同样可以把差异写在目标元素附近,只要完整写出 Element class:

.upload-card__preview {
  opacity: 1;

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

postcss-nesting 14.0.3 会输出正确的 .upload-card--is-ready .upload-card__preview。这种方案的成本是 Element 名要写完整,不能继续套用 &__preview 的 Sass-like 拼接习惯。

尤其要注意,转换器能解析某段输入,不代表生成了想要的 class。把下面的代码交给同一版本:

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

本次实际输出为 __preview.upload-card,其中 __preview 是类型选择器,完全不同于 .upload-card__preview。原生 CSS nesting 的相应写法也不能用于 BEM 后缀拼接,具体语义见 MDN 的说明。

采用标准 nesting 时,还要检查选择器列表与 :is(...) 的特异性。Sass-like 展开器、标准 nesting 插件和浏览器原生 nesting,应分别看待,不能只凭名字里都有 nested 或 nesting 就互换。

上传组件的完整对照

上传组件里的状态与行为

回到上传组件,我更愿意让模板保留一个上传状态入口,让预览图和按钮在各自的样式块里描述变化。前面几种工具都能表达这层关系,差别在于根选择器从哪里取得,以及重命名、增加嵌套时要维护多少约定。

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

<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" :disabled="fileIsUploading">继续上传</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 负责结构归属,预处理器或 PostCSS 插件让这条关系更靠近被影响的元素。

上传中的按钮禁用要通过原生 disabled 或组件的禁用接口实现。pointer-events: none 只影响指针命中,不能阻止键盘激活,样式状态不能代替行为约束。

选择器链超过三层、状态组合开始互相覆盖时,就该重新看组件结构、状态拆分和样式职责;继续往里嵌套,会让覆盖关系更难判断。

SCSS、Less、Stylus 与 PostCSS 的编译输入和结果

打开原文件 FILES.json

SCSS、Less、Stylus 与 PostCSS 的编译输入和结果正在加载代码工作区...