组件状态和就近差异样式

写组件样式时,经常会遇到一个很小但很烦人的问题:组件根节点有一个状态,真正变化的却是里面某个元素。

比如一个上传组件,根节点上有 is-readyis-uploadingis-failed,预览图、按钮、提示文案都会跟着变。状态如果全散在子元素上,模板很快会变乱;样式如果全堆在文件底部,读代码时又要来回跳。

更稳的方式是先确定状态归属,再让差异样式靠近被影响的元素。这个思路不依赖 Vue 或 React。Vue 里可能写 :class="{ 'is-ready': isReady }",React 里可能拼 className={clsx('upload-card', isReady && 'is-ready')},但底层问题一样:状态类到底应该挂在哪里,以及样式应该怎么就近描述它。

先确定状态归属

组件状态最好有一个明确归属。能影响整个组件的状态,优先挂在组件根节点:

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

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

这个判断能减少两类问题:

  • 模板不会到处重复 is-ready,状态入口集中在根节点。
  • 样式不会把组件状态误写成子元素自己的 modifier。

可以这样分:

  • upload-card--compact 这类稳定形态,用 BEM modifier。
  • is-readyis-uploadingis-failed 这类运行时状态,用 is-*
  • is-dragging 这类只属于某个小元素的瞬时状态,可以挂在那个元素自己身上。

--modifier 更像组件规格,is-* 更像当前发生了什么。两者都能实现效果,但语义不同。状态一旦会同时影响多个子元素,就不要急着把它塞到每个子元素上。

差异样式靠近被影响的元素

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

可以写成这样:

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

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

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

另一种写法是把差异放回子元素附近:

.upload-card {
  &__preview {
    opacity: 1;

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

这段的重点是 .is-ready& 没有空格。它会把 .is-ready 拼到当前完整选择器最前面的同一个 compound selector 上。上面的 Less 会编译成:

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

这正好命中根节点同时拥有 upload-card is-ready 的结构。样式差异也留在 upload-card__preview 附近,读预览区样式时能顺手看到它在 is-ready 下会变成什么样。

空格决定状态在哪里

这个写法最容易踩的点就是空格。

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

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

两段只差一个空格,但语义完全不同:

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

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

第一段适合“状态在组件根节点上”。第二段适合“组件外层还有一个更大的状态容器”。比如页面根节点上有 .is-editing,里面所有组件都进入编辑态,那才应该用 .is-editing &

这个区分很实用:

  • 当前元素自己有状态:用 &.is-active
  • 当前组件根节点有状态,子元素就近写差异:用“状态类紧贴当前选择器”的写法。
  • 外部祖先有状态:用 .is-editing &

各工具的行为不完全一样

Less、Stylus、Sass 和 PostCSS 都能写嵌套,但嵌套语义由各自工具决定。尤其 PostCSS 要先问“用了哪个插件”。

先看这段输入:

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

在 Less、Stylus 和 postcss-nested 里,它会得到同一类结果:

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

这说明 .is-b& 可以表达“当前组件根节点同时有 .is-b”。Stylus 的官方选择器文档也把 & 解释为对父选择器的引用,并给了 html.ie8 & 这类“在上下文中匹配当前选择器”的例子。postcss-nested 的 README 则明确它是更接近 Sass 语法的 nested rules 展开插件。

SCSS 不接受 .is-b& 这种写法。Sass 的 parent selector 文档里说,& 只能出现在 compound selector 开头这类合法位置,像 span& 这样的形式不允许。SCSS 里需要借助插值和 @at-root

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

它会编译成:

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

如果写成有空格的版本:

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

输出就会变成:

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

这又回到了“外部祖先状态”的语义。

PostCSS 这里要特别小心。PostCSS 本体只是用插件转换 CSS 的处理器,嵌套行为取决于插件。postcss-nested 是 Sass-like 的展开器,上面的 .is-b& 会按 Sass / Less 心智输出。另一个常见插件 postcss-nesting 跟随 CSS Nesting specification,它和 postcss-nested 的定位不同。

postcss-nesting 处理同一段 .outer .inner 里的 .is-b&,会得到类似:

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

这个结果和 .is-b.outer .inner 不等价。它更接近 CSS Nesting 的 :is() 语义,不适合拿来表达“根节点状态影响子元素”。MDN 的 CSS nesting 文档也说明,原生 CSS nesting 和 Sass 这类预处理器不同,& 的选择器权重和 :is() 有关。

所以“项目用了 PostCSS”这个信息不够。真正要看的是 PostCSS 插件链:

  • postcss-nested,可以按 Sass-like 心智写 .is-b&
  • postcss-nesting 或原生 CSS nesting,不要把 .is-b& 当成 Less / Stylus 的根状态写法。
  • 如果项目不确定插件,先写一个最小片段编译,看输出选择器再定规则。

核心差异表

假设 DOM 是:

<div class="outer is-b">
  <div class="inner"></div>
</div>

希望在 .inner 附近写“根节点有 is-b 时”的差异样式,可以按这个表判断:

工具 写法 输出 是否命中这个 DOM
Less .is-b& .is-b.outer .inner
Stylus .is-b& .is-b.outer .inner
postcss-nested .is-b& .is-b.outer .inner
SCSS @at-root .is-b#{&} .is-b.outer .inner
SCSS 有空格 @at-root .is-b #{&} .is-b .outer .inner
Less / Stylus 有空格 .is-b & .is-b .outer .inner
postcss-nesting .is-b& .is-b:is(.outer .inner) 不等价

这个表只服务一个判断:状态到底在当前选择器链上的哪个节点。

如果状态就在当前元素上,&.is-b 最清楚:

.upload-card__button {
  &.is-loading {
    pointer-events: none;
  }
}

如果状态在组件根节点上,且差异影响的是子元素,就近写法才有价值:

.upload-card {
  &__button {
    border-color: #b8c4d6;

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

如果状态来自组件外部,就不要硬贴根节点:

.upload-card {
  &__button {
    .is-editing & {
      border-color: #1976d2;
    }
  }
}

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

样式规则理顺以后,组件代码应该尽量只表达状态,不表达样式细节。

Vue 里可以是:

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

React 里可以是:

<section className={clsx('upload-card', isReady && 'is-ready', isUploading && 'is-uploading')}>
  <div className="upload-card__preview" />
  <button className="upload-card__button">继续上传</button>
</section>

这两段的框架写法不同,但 CSS 入口一致:根节点负责公开组件状态,子元素只保留自己的结构类。这样换框架、换模板语法、甚至从 SFC 拆到 CSS Modules,样式心智都不会变。

状态不要只为了“少写 CSS”而乱放。真正的目标是让状态有主人,让差异样式能就近维护,让读代码的人不用在模板、脚本和样式之间来回找同一个条件。

默认判断口径

写组件状态样式时,可以先按这几步判断:

  1. 这个状态属于整个组件,还是只属于某个元素。
  2. 如果属于整个组件,状态类放在组件根节点,用 is-* 表达运行时状态。
  3. 子元素的差异样式优先写在子元素块附近。
  4. Less、Stylus、postcss-nested 可以用 .is-state& 表达根节点状态。
  5. SCSS 用 @at-root .is-state#{&} 表达同样语义。
  6. 原生 CSS nesting 或 postcss-nesting 不套这条 Sass-like 规则,先看编译输出。
  7. 外部祖先状态才用 .is-state &,不要把有空格和无空格混用。

这套规则不解决所有样式组织问题,但能处理很常见的一类组件状态:状态集中在根节点,差异落在多个子元素上。它比把所有状态样式堆到文件底部更好维护,也比把状态类散在模板每个元素上更清楚。

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