组件状态和就近差异样式
写组件样式时,经常会遇到一个很小但很烦人的问题:组件根节点有一个状态,真正变化的却是里面某个元素。
比如一个上传组件,根节点上有 is-ready、is-uploading、is-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-ready、is-uploading、is-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”而乱放。真正的目标是让状态有主人,让差异样式能就近维护,让读代码的人不用在模板、脚本和样式之间来回找同一个条件。
默认判断口径
写组件状态样式时,可以先按这几步判断:
- 这个状态属于整个组件,还是只属于某个元素。
- 如果属于整个组件,状态类放在组件根节点,用
is-*表达运行时状态。 - 子元素的差异样式优先写在子元素块附近。
- Less、Stylus、
postcss-nested可以用.is-state&表达根节点状态。 - SCSS 用
@at-root .is-state#{&}表达同样语义。 - 原生 CSS nesting 或
postcss-nesting不套这条 Sass-like 规则,先看编译输出。 - 外部祖先状态才用
.is-state &,不要把有空格和无空格混用。
这套规则不解决所有样式组织问题,但能处理很常见的一类组件状态:状态集中在根节点,差异落在多个子元素上。它比把所有状态样式堆到文件底部更好维护,也比把状态类散在模板每个元素上更清楚。
再补一个务实提醒:样式就近不等于无限嵌套。选择器链超过三层、状态组合开始互相覆盖时,就该重新看组件结构、状态拆分和样式职责;继续往里嵌套通常会让覆盖关系更难判断。