extend +伪元素 绑定失败问题

本来为了编译后的css体积大小,我更喜欢写extend胜过mixin,但这次我发现要保证复杂嵌套时各种意义上的正确性,还得是mixin。并且官网更推荐语义相关的情况使用extend,显然我遇到问题的这种情况不属于推荐使用extend的情况(重点:官网提到“大多数web服务器算法擅长压缩CSS相同文本的重复块,尽管mixins可能比extends生成更多的CSS,但可能不会显著增加用户需要下载的内容。所以选择对你的用例最有意义的特性,而不是生成最少CSS的特性!”【详情见下“复习@extend用法“】)

问题概览

  • 背景: vue3项目中 <RewardItem>组件是封装好的,内部根元素有item类,父组件调用该组件时绑定一个动态样式isCrystal ,现在有多个位置都会需要在水晶类型的时候往 <RewardItem>里加一个标签,但不至于加到组件内(毕竟只是活动限定)
  • 尝试并发现问题: 此时使用 extend+伪元素做角标,尝试让各个组件继承伪元素时就会发现继承伪元素失败
  • 重点总结: 下面会提到官方建议的 extendmixin 使用场景,实在不必为了节省编译后的css体积而盲目偏爱使用 extend ,这里实际应该是用mixin 来处理角标复用,不仅可以完美避开伪元素继承失败的问题,还可以顺便通过传参处理阿语样式相反
  • 具体问题场景还原见下“伪元素问题”
    案例示意图

复习下 extend / mixin / 公共样式 的区别

回顾下sass@extend@mixin、公共样式(公共 class / 全局样式)这三种方式,都能「复用样式」,但它们的 编译机制、作用范围和使用场景完全不同

已知<RewardItem :class="{ isCrystal: type === 12 }" />,且RewardItem内部根元素有class="item",带着这个前提看看不同的用法

@extend(选择器继承)

官网教你不同场景使用extend还是mixin

  • 官网 对于选择extend还是mixin有很明确的建议:

    根据经验法则,当您表达语义类(或其他语义选择器)之间的关系时,扩展是最佳选择。因为具有类 .error--serious 的元素  一个错误,所以它扩展 .error 是有意义的。但是对于非语义的样式集合,编写 mixin 可以避免级联问题,并使其更容易在以后进行配置

  • 官方案例里用到extend的是.error.error--serious,这就非常明显了(这不摆明了说我这种bug的情况如果按照推荐来分流去用mixin写就不会遇到了吗w-w)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    .error {
    border: 1px #f00;
    background-color: #fdd;

    &--serious {
    @extend .error;
    border-width: 3px;
    }
    }

注意:不必为了css体积而偏向使用extend

  • 按照官方网站的说法,我们并不需要为了缩小css体积而使用extend

💡 有趣的事实:
大多数web服务器使用一种算法压缩它们所提供的CSS,这种算法非常擅长处理相同文本的重复块。这意味着,尽管mixins可能比extends生成更多的CSS,但它们可能不会显著增加用户需要下载的内容。所以选择对你的用例最有意义的特性,而不是生成最少CSS的特性!

语法

1
2
3
.isCrystal {
@extend .crystal-tag;
}

编译结果

Sass 会把 .isCrystal.crystal-tag 合并成一个选择器组:

1
2
3
4
.isCrystal, .crystal-tag {
position: relative;
...
}

特点

特性 说明
减少重复 样式只编译一次,多个类共享一份声明。
⚠️ 强耦合 .isCrystal 会和 .crystal-tag 永远绑定在一起,任何地方修改 .crystal-tag 都会影响它。
⚠️ scoped 限制 在 Vue <style scoped> 下可能导致伪元素或嵌套选择器不生效。
⚠️ 调试困难 编译后的选择器组很长,不容易看清来源。

适用场景

  • 语义化的使用
  • 小范围组件内部样式复用
  • 不涉及 伪类 / 伪元素 的地方

@mixin(样式片段复用)

语法

1
2
3
4
5
6
7
8
9
10
11
12
@mixin crystal-tag {
position: relative;

&::after {
content: attr(data-text);
color: #fff;
}
}

.isCrystal {
@include crystal-tag;
}

编译结果

编译时会直接复制内容到使用的地方:

1
2
3
4
5
6
7
.isCrystal {
position: relative;
}
.isCrystal::after {
content: attr(data-text);
color: #fff;
}

特点

特性 说明
灵活可参数化 可以接受参数:@include crystal-tag($color: red);
不受 scoped 限制 因为内容是直接展开的。
⚠️ 可能膨胀 CSS 体积 每次 include 都会生成一份完整样式。
最稳定、最常用 Vue 项目里推荐方式。

适用场景

  • 可复用的样式片段(按钮、标签、卡片边框)。
  • 需要带参数的样式逻辑。
  • 有伪元素 / 动画的场景(比 extend 稳定)。

公共样式(全局类 / 公共文件)

写法

1
2
3
4
5
6
7
8
.isCrystal {
position: relative;

&::after {
content: attr(data-text);
color: #fff;
}
}

在组件中直接使用:

1
<RewardItem class="isCrystal" />

特点

特性 说明
简单直观 无需 Sass 特性,直接靠 class 组合。
全局复用 所有组件都能使用。
与 scoped 兼容 不受限制。
⚠️ 命名需规范 容易类名冲突。
⚠️ 可读性依赖命名规范 样式与结构分离较远。

适用场景

  • 样式完全一致的通用模块(按钮、标签、卡片)。
  • 有伪元素、动画或跨组件效果。

总结对比表

特性 @extend @mixin 公共样式(class)
原理 合并选择器 复制样式 通过类名复用
是否重复生成 CSS
可参数化
scoped 兼容性 ⚠️ 差 ✅ 好 ✅ 好
优先级控制 不可控 可控 可控
调试/维护 一般 简单 简单
适用场景 小范围复用 通用组件逻辑复用 全局样式统一

总结建议

目标 推荐方式
样式完全一致(无逻辑) 公共 class
样式逻辑复用、有参数 @mixin
小块内部共享(无伪元素) @extend(次选)

回头结合现在的使用场景(需要 ::after 做标签,多个组件复用),最佳实践是:mixin 或公共 class,不用 extend
但是我想回顾的是我失败的extend以及失败原因,所以继续/(ㄒoㄒ)/~~


强行 extend 带来的伪元素问题

  • 已知: 父组件中有普通的透传控制子组件内嵌套多层的样式,包括img的宽高1.36rem
  • 期望:特殊子组件(水晶) 加样式覆盖 普通透传样式,比如img改成宽高1.05rem,再加一个伪元素角标
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!--    父组件    -->
<div class="rewards">
<RewardItem
v-for="(prize, index) in prizes"
:key="index"
:row="prize"
:data-text="dic.upTo"
:class="{
isAr,
isCrystal: prize.prizeType === 12,
}"
/>
</div>

<style lang="scss" scoped>
@import '@/style/base';

.rewards {
:deep(.item) {
.content img {
width: 1.36rem;
height: 1.36rem;
}

p {
font-size: 0.29rem;
}

}
}
1
2
3
4
5
6
7
<!--    RewardItem    -->
<div class="item">
<div class="content">
<img>
</div>
</div>

extend 失败!

  • extendmixin里我潜意识就先选择了extend(习惯性觉得应该节省css体积,卒)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// style/base.scss
.crystal-reward {
.content img {
width: 1.05rem;
height: 1.05rem;
}

position: relative;

&::after {
position: absolute;
top: 0.05rem;
right: 0.05rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.1rem;
height: 0.36rem;
background: linear-gradient(180deg, #ff6fe7, #ec0f94);
border: 0.02rem solid #fefe2c;
border-radius: 0 0.1rem;
content: attr(data-text);
font-weight: 600;
font-size: 0.24rem;
line-height: 0.24rem;
color: #ffffff;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<div class="rewards">
<RewardItem
:class="{
isCrystal: prize.prizeType === 12,
}"
/>
</div>

<style lang="scss" scoped>
@import '@/style/base';

.rewards {
:deep(.item) {
.content img {
width: 1.36rem;
height: 1.36rem;
}

p {
font-size: 0.29rem;
}

&.isCrystal {
@extend .crystal-reward;
}
}
}
</style>

结果: 可以看到1.05rem宽高是生效的,但是伪元素并未成功绑定isCrystal

查!伪元素绑到哪儿了

  • 通过全局样式直接绑定准备继承的样式crystal-extend来“钓”出isCrystal上应该绑定的伪元素到底绑到哪儿了,很明显这样做伪元素能成功绑定(当然全局样式试图覆盖透传item的样式不生效,优先级的问题这里先不管)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<div class="rewards">
<RewardItem
:class="{
isCrystal: prize.prizeType === 12,
['crystal-extend']: prize.prizeType === 12,
}"
/>
</div>

<style lang="scss" scoped>
@import '@/style/base';

.rewards {
:deep(.item) {
.content img {
width: 1.36rem;
height: 1.36rem;
}

p {
font-size: 0.29rem;
}

&.isCrystal {
@extend .crystal-reward;
}
}
}
</style>
  • 能在f12选中绑定成功的伪元素tag以此看到其他被继承的位置,很明显第一行是生效的,第二行说明编译后的isCrystal继承有问题导致之前看到的伪元素绑定失败
    • itemisCrystal这俩 同元素 不同class被识别成 父子关系 了! 能绑定上才有鬼
1
2
3
.crystal-extend[data-v-af4d0009]::after, 
.rewardList .rewards .isCrystal[data-v-af4d0009] .item::after
{ 伪元素 }
  • 虽然依旧不知道为啥会这样,但是明显sass对于extend和伪元素的混合处理有点问题,官方也不建议这种非语义相关的情况使用extend,那么问题看到这我也不再纠结为什么会产生这样的错位问题了,老老实实用mixin

StackOverflow 提到

  • extend伪类/伪元素时,你extend的是基础选择器而不是单独的伪部分:即使你在定义里写了伪元素,extend时也不会自动在所有继承者上创建对应的伪元素

mixin 成功!

  • mixin完全没问题,甚至可以传参去控制下leftright解决下阿语样式角标放左上角的问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// base.scss
@mixin crystal-reward() {
.content img { width: 1.05rem; height: 1.05rem; }
position: relative;
&::after {
position: absolute;
top: 0.05rem;
right: 0.05rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.1rem;
height: 0.36rem;
background: linear-gradient(180deg, #ff6fe7, #ec0f94);
border: 0.02rem solid #fefe2c;
border-radius: 0 0.1rem;
content: attr(data-text);
font-weight: 600;
font-size: 0.24rem;
line-height: 0.24rem;
color: #fff;
}
}
1
2
3
&.isCrystal {
@include crystal-reward();
}