Vue 基础体系 · 第 42/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 样式工程:Scoped CSS、CSS Modules、变量、主题和覆盖边界

在 Vue 单文件组件(Single-File Component,SFC)中,样式通常写在:

<style>
/* 普通全局 CSS */
</style>

<style scoped>
/* 组件级 Scoped CSS */
</style>

<style module>
/* CSS Modules */
</style>

这三种写法都能出现在 Vue 组件中,但它们解决的问题不同:

  • 普通 CSS解决全局样式、重置样式和基础设计令牌。
  • Scoped CSS限制选择器的匹配范围,减少组件间的意外影响。
  • CSS Modules为类名建立局部映射,避免类名命名冲突。
  • CSS 变量在 DOM 树中传递可继承的样式数据,适合表达颜色、间距和尺寸等令牌。
  • 主题通常建立在 CSS 变量之上,通过改变变量值切换视觉方案。
  • 覆盖边界描述了一个组件能覆盖哪里、不能覆盖哪里,以及为什么某些样式“明明写了却不生效”。

这些机制并不是同一层次的能力。Scoped CSS 和 CSS Modules主要处理“选择器如何找到元素”;CSS 变量处理“值如何沿 DOM 传递”;主题处理“如何切换一组值”;最终是否生效,还要经过 CSS 的级联、继承、特异性、来源和顺序。


一、先区分四个容易混淆的概念

1. 作用域不是继承

假设有如下组件:

<template>
  <section class="card">
    <h2>标题</h2>
  </section>
</template>

<style scoped>
.card {
  color: red;
}
</style>

这里的 scoped 表示 .card 这个选择器不会随意匹配其他组件中的 .card。它不表示:

  • color 只影响 .card 本身;
  • 子元素不能继承 color
  • 子组件完全看不见这个样式;
  • 运行时创建的所有 DOM 都自动带上这个作用域。

CSS 的继承仍然存在。color 会从 .card 继承给 h2,即使 h2 没有写在选择器中。

因此需要区分:

概念 解决的问题
作用域 选择器能匹配哪些元素
继承 子元素是否从父元素获得某个属性值
级联 多条规则同时匹配时哪一条获胜
CSS 变量 样式值如何通过 DOM 继承并被读取

2. Vue 组件边界不是 DOM 边界

Vue 组件是逻辑和渲染组织单元,但 CSS 主要依据最终 DOM 树工作。

例如:

<!-- Parent.vue -->
<template>
  <ChildPanel />
</template>

如果 ChildPanel 最终渲染为:

<div class="panel">内容</div>

那么浏览器看到的是 DOM 元素,而不是一个“不可穿透的 Vue 组件对象”。Vue 可以通过编译转换给元素增加作用域标记,但 CSS 最终仍然依据元素、属性、祖先关系和级联规则匹配。

3. CSS Modules 不是运行时隔离

CSS Modules 会把局部类名编译为经过转换的类名,例如:

.button {
  color: white;
}

可能变成:

.button_abc123 {
  color: white;
}

模板中的 $style.button 会取得这个转换后的字符串。它主要防止类名冲突,并不会阻止:

  • 元素选择器匹配;
  • CSS 变量继承;
  • 全局选择器生效;
  • bodyhtml 等祖先样式影响组件;
  • 更高优先级的规则覆盖它。

4. 主题不是简单替换类名

主题通常是“在稳定的 DOM 结构上替换一组 CSS 变量”:

:root {
  --color-text: #1f2937;
  --color-surface: #ffffff;
}

[data-theme='dark'] {
  --color-text: #f3f4f6;
  --color-surface: #111827;
}

组件只消费变量:

.panel {
  color: var(--color-text);
  background: var(--color-surface);
}

这样主题状态和组件结构解耦。切换主题时不需要重新生成组件,也不需要给每个组件添加一组主题类名。


二、Scoped CSS 的真实工作机制

1. 编译前后的选择器

Vue 的 scoped 依赖 Vue SFC 编译器处理,而不是浏览器原生的 Shadow DOM。

组件:

<template>
  <button class="save">保存</button>
</template>

<style scoped>
.save {
  color: white;
  background: #2563eb;
}

.save:hover {
  background: #1d4ed8;
}
</style>

编译后的结果可以近似理解为:

<button class="save" data-v-7f3a1c2e>保存</button>
.save[data-v-7f3a1c2e] {
  color: white;
  background: #2563eb;
}

.save[data-v-7f3a1c2e]:hover {
  background: #1d4ed8;
}

这里的 data-v-7f3a1c2e 是编译器生成的作用域标记,具体值由构建结果决定,不应在业务代码中依赖它。

形式化地说,原始选择器为 SS,作用域标记为 aa,编译器会把选择器转换为近似:

T(S,a)=ST(S, a) = S'

其中 SS' 要求目标元素同时满足原始选择器条件和属性 [a] 条件。

例如:

.card .title {
  font-weight: 700;
}

会近似转换为:

.card[data-v-x] .title[data-v-x] {
  font-weight: 700;
}

因此只有同时属于这个组件作用域的 .card.title 才会完整匹配。

2. Scoped CSS 不是安全边界

下面的代码并不能防止外部样式影响按钮:

<style scoped>
button {
  padding: 8px 12px;
}
</style>

它只会生成类似:

button[data-v-x] {
  padding: 8px 12px;
}

全局样式仍然可以匹配这个 button

button {
  padding: 0;
}

哪条规则生效,要继续比较:

  1. 样式来源和重要性;
  2. 级联层;
  3. 特异性;
  4. 源码出现顺序。

因此 scoped 降低了选择器误匹配的概率,但不是权限隔离、Shadow DOM 隔离,也不是“组件内样式绝对优先”。

3. 性能与选择器形状

Scoped CSS 会给选择器增加属性选择条件。以下写法:

.container p {
  line-height: 1.6;
}

通常会比下面这种类选择器更依赖较多 DOM 匹配工作:

.container .paragraph {
  line-height: 1.6;
}

更重要的是,Scoped CSS 的主要目标是边界清晰,而不是提供某个固定性能指标。选择器是否高效仍取决于结构、数量、浏览器实现和页面规模,不能简单声称某种写法必然有固定性能收益。

4. 根元素的特殊表现

当父组件使用 scoped 样式渲染子组件时,子组件的根元素可能同时拥有父组件和子组件的作用域标记。

例如:

<!-- Parent.vue -->
<template>
  <ChildPanel />
</template>

<style scoped>
.child-panel-root {
  margin-top: 16px;
}
</style>

如果 ChildPanel 的根元素是:

<div class="child-panel-root">...</div>

父组件的 Scoped CSS 可以匹配这个根元素。这样做是为了让父组件能够控制子组件在父布局中的位置,例如外边距、网格占位和对齐方式。

但这并不意味着父组件可以直接控制子组件内部任意元素:

/* Parent.vue */
.child-panel-root .internal-title {
  color: red;
}

如果 .internal-title 是子组件内部元素,父组件的普通 Scoped 选择器通常不会匹配它。


三、Scoped CSS 的穿透、插槽和全局出口

Vue SFC 提供了几种明确表达边界的语法。它们是 Vue SFC 编译器支持的能力,具体语法应以当前 Vue 工具链版本为准;下面的写法适用于现代 Vue 3 + Vite 配置。

1. :deep():有意访问子组件内部

组件:

<template>
  <ChildPanel />
</template>

<style scoped>
:deep(.panel-title) {
  color: #2563eb;
}
</style>

:deep(.panel-title) 表示“从当前作用域出发,允许匹配后代中的 .panel-title”。

可以近似理解为:

[data-v-parent] .panel-title {
  color: #2563eb;
}

它有一个重要前提:目标元素仍然必须位于当前组件作用域元素的后代关系中。:deep() 不是全局搜索,也不会自动穿过任意 DOM 位置。

更明确的写法是:

.wrapper :deep(.panel-title) {
  color: #2563eb;
}

这意味着:

  • .wrapper 必须是当前组件作用域中的元素;
  • .panel-title 可以是后代组件内部的元素;
  • 选择器仍受 CSS 级联和特异性影响。

失败示例

<template>
  <ChildPanel />
</template>

<style scoped>
:deep(.panel-title) {
  color: red;
}
</style>

如果 ChildPanel 内部通过 Teleport.panel-title 渲染到 body 下,那么它在最终 DOM 中不再是当前组件作用域根节点的后代,:deep() 不能靠 Vue 组件关系跨越这个 DOM 位置。

2. :slotted():明确设置插槽内容样式

父组件:

<template>
  <Card>
    <span class="external-label">外部传入的内容</span>
  </Card>
</template>

Card.vue

<template>
  <section class="card">
    <slot />
  </section>
</template>

<style scoped>
.card {
  padding: 16px;
}

:slotted(.external-label) {
  font-weight: 700;
}
</style>

插槽内容由父组件提供。它的样式归属和渲染位置容易让人误判,因此 Vue 提供 :slotted() 表达“针对插槽传入内容的样式”。

不能把普通 .external-label 选择器等价看成插槽专用规则。若组件需要控制插槽内容,应显式使用:

:slotted(.external-label) {
  font-weight: 700;
}

但插槽内容的最终样式仍可能被父组件自己的样式、全局样式或更高优先级规则覆盖。

3. :global():在 Scoped 文件中创建全局规则

<style scoped>
:global(.page-title) {
  margin: 0;
}
</style>

这条规则不会被加上当前组件的作用域限制,效果类似全局 .page-title

它适合少数明确的全局出口,例如第三方库要求的类名,或者必须与全局 DOM 约定对接的选择器。若大量使用 :global(),Scoped 文件就失去了边界表达能力,维护者也难以判断样式影响范围。

4. v-html 的边界

<template>
  <article class="article" v-html="html"></article>
</template>

<style scoped>
.article p {
  margin-block: 1em;
}
</style>

v-html 插入的元素不会经过当前 SFC 模板编译,因此通常不会自动带上当前组件的作用域属性。结果是:

.article[data-v-x] p[data-v-x] {
  margin-block: 1em;
}

而动态插入的 p 可能只有:

<p>动态内容</p>

所以选择器匹配失败。

可以改为:

<style scoped>
.article :deep(p) {
  margin-block: 1em;
}
</style>

v-html 还涉及 HTML 内容安全问题:如果内容来源不可信,必须在服务端或可信的清洗流程中处理 XSS 风险。:deep() 只解决 CSS 匹配,不解决内容安全。


四、CSS Modules:从类名局部化到模板绑定

1. 基本用法

<script setup lang="ts">
import { ref } from 'vue'

const disabled = ref(false)
</script>

<template>
  <button
    :class="[$style.button, { [$style.disabled]: disabled }]"
    :disabled="disabled"
  >
    提交
  </button>
</template>

<style module>
.button {
  border: 0;
  border-radius: 6px;
  padding: 8px 14px;
  color: white;
  background: #2563eb;
}

.disabled {
  cursor: not-allowed;
  opacity: 0.5;
}
</style>

<style module> 中的类名会被编译为局部名称映射。模板中的:

$style.button

得到的是最终类名字符串,而不是字面量 "button"

可以把它理解成:

type StyleMap = {
  button: string
  disabled: string
}

实际字符串由构建工具生成,例如:

{
  button: '_button_abc123',
  disabled: '_disabled_def456'
}

因此组件之间都可以安全使用 .button,因为它们最终不一定拥有相同的 DOM 类名。

2. 在 <script setup> 中访问模块

如果需要在脚本中动态读取类名,可以使用 useCssModule

<script setup lang="ts">
import { computed } from 'vue'
import { useCssModule } from 'vue'

const classes = useCssModule()

const buttonClass = computed(() => {
  return [classes.button, classes.primary]
})
</script>

<template>
  <button :class="buttonClass">保存</button>
</template>

<style module>
.button {
  padding: 8px 12px;
}

.primary {
  color: white;
  background: #2563eb;
}
</style>

如果定义了命名模块:

<script setup lang="ts">
import { useCssModule } from 'vue'

const layout = useCssModule('layout')
</script>

<template>
  <main :class="layout.page">内容</main>
</template>

<style module="layout">
.page {
  min-height: 100vh;
}
</style>

这里的 useCssModule('layout') 必须与 module="layout" 名称一致。名称写错时,读取到的模块映射就不是预期对象,使用结果可能是 undefined 或访问不存在的属性。

3. CSS Modules 与 Scoped CSS 的差异

维度 Scoped CSS CSS Modules
主要手段 给元素和选择器增加作用域属性 将类名映射为局部生成名
模板写法 class="button" :class="$style.button"
是否适合元素选择器 可以 可以写,但局部化重点是类名
是否暴露映射对象
适合场景 组件内语义样式、快速局部化 严格类名隔离、脚本动态组合
是否阻止继承
是否阻止全局样式

Scoped CSS 是“选择器附加条件”;CSS Modules 是“类名建立映射”。二者不能互相替代。

4. CSS Modules 仍然受全局规则影响

<template>
  <button :class="$style.button">提交</button>
</template>

<style module>
.button {
  font-size: 14px;
}
</style>

即使最终类名已经被哈希化,下面的全局规则仍可能影响按钮:

button {
  font-size: 18px;
}

假设 CSS Modules 规则和全局规则特异性相同,后加载者可能获胜。若要判断结果,必须检查构建后 CSS 的顺序、选择器特异性以及是否存在 !important,不能仅凭“这是 CSS Modules”推断局部规则一定优先。

5. 全局类名与模块局部类名混用

CSS Modules 文件中可以声明全局类:

<style module>
:global(.third-party-tooltip) {
  z-index: 1000;
}

.button {
  padding: 8px 12px;
}
</style>

.button 会被局部化,.third-party-tooltip 保持全局名称。这样可以对接第三方库,但应明确记录这个出口,否则后续维护者容易误以为整个文件都是局部的。


五、CSS 变量:让样式值沿 DOM 传递

1. 自定义属性的定义和读取

CSS 自定义属性通常写成 --name

.panel {
  --panel-padding: 16px;
  padding: var(--panel-padding);
}

读取语法是:

color: var(--color-text);

如果变量不存在,可以提供回退值:

color: var(--color-text, #1f2937);

var() 的第一个参数是变量名,第二个参数是回退值。这里的回退值只有在变量未定义或计算结果无效时才会使用。

2. 变量的继承关系

自定义属性默认会继承:

<div class="theme">
  <button class="button">按钮</button>
</div>
.theme {
  --button-bg: #2563eb;
}

.button {
  background: var(--button-bg);
}

.button 没有定义 --button-bg,但它能读取祖先 .theme 定义的值。

可以用如下过程理解:

  1. 浏览器计算 .theme--button-bg
  2. 因为自定义属性默认继承,该值传递给 .button
  3. 浏览器计算 .buttonbackground
  4. var(--button-bg) 被替换为 #2563eb
  5. 浏览器最终绘制蓝色背景。

如果中间某个元素重新定义了变量,后代就会使用更近的定义:

.theme {
  --button-bg: #2563eb;
}

.danger-zone {
  --button-bg: #dc2626;
}
<div class="theme">
  <button class="button">普通按钮</button>

  <div class="danger-zone">
    <button class="button">危险按钮</button>
  </div>
</div>

两个按钮使用相同的 .button 规则,但第二个按钮读取到更近的 --button-bg,因此变为红色。

3. 自定义属性的无效值陷阱

CSS 变量替换发生在属性计算阶段。下面的代码可能造成属性失效:

.card {
  --gap: 10px;
  margin: var(--gap) var(--unknown);
}

因为 --unknown 没有定义,整个 margin 声明可能被视为无效,而不是只忽略缺失的那一部分。

应提供回退值:

.card {
  margin: var(--gap, 8px) var(--unknown, 8px);
}

变量本身也可能存在,但值不适合目标属性:

.card {
  --size: red;
  width: var(--size);
}

--size 对自定义属性来说仍是合法字符串,但替换到 width 后,red 不是合法宽度,width 会失效。

4. Vue 的 v-bind() CSS 变量桥接

Vue SFC 支持在样式中使用 v-bind() 读取组件状态:

<script setup lang="ts">
import { ref } from 'vue'

const accent = ref('#2563eb')
</script>

<template>
  <button class="button" @click="accent = '#dc2626'">
    切换颜色
  </button>
</template>

<style scoped>
.button {
  color: white;
  background: v-bind(accent);
}
</style>

Vue 会把这种写法编译为内部 CSS 自定义属性,再在组件根节点或相关元素上更新它。概念上接近:

.button {
  background: var(--某个由 Vue 生成的变量);
}

accent.value 改变时,Vue 更新变量值,而不是重新生成整张样式表。

复杂表达式应使用字符串形式:

<script setup lang="ts">
import { computed, ref } from 'vue'

const hue = ref(210)
const accent = computed(() => `hsl(${hue.value} 80% 50%)`)
</script>

<template>
  <div class="preview">预览</div>
</template>

<style scoped>
.preview {
  color: v-bind('accent');
}
</style>

这里:

  • hue 变化;
  • accent 重新计算;
  • Vue 更新对应的 CSS 变量;
  • .preview 重新计算 color

v-bind() 是 Vue SFC 编译能力,不是标准 CSS 语法。将这段 CSS 复制到普通 .css 文件中,浏览器不会理解 v-bind()

5. 变量和 Scoped CSS 的组合

推荐把“值”与“结构选择器”分开:

<script setup lang="ts">
import { computed, ref } from 'vue'

const dense = ref(false)

const padding = computed(() => dense.value ? '8px' : '16px')
</script>

<template>
  <section class="panel">
    <h2>设置</h2>
  </section>
</template>

<style scoped>
.panel {
  padding: v-bind('padding');
  color: var(--color-text, #1f2937);
  background: var(--color-surface, #ffffff);
}
</style>

Scoped CSS 负责限制 .panel 的选择器,CSS 变量负责消费主题值,v-bind() 负责把 Vue 响应式状态桥接到 CSS。


六、主题系统:变量覆盖、主题状态和 DOM 位置

1. 一个可运行的主题示例

下面的示例基于 Vue 3、<script setup lang="ts"> 和现代 Vite。

<!-- App.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'

type Theme = 'light' | 'dark'

const theme = ref<Theme>('light')

const nextTheme = computed<Theme>(() => {
  return theme.value === 'light' ? 'dark' : 'light'
})

function toggleTheme() {
  theme.value = nextTheme.value
}
</script>

<template>
  <main class="app" :data-theme="theme">
    <button class="theme-button" type="button" @click="toggleTheme">
      切换到 {{ nextTheme }} 主题
    </button>

    <section class="panel">
      <h1>主题示例</h1>
      <p>组件只使用语义变量,不直接判断主题名称。</p>
    </section>
  </main>
</template>

<style>
:root {
  --color-text: #1f2937;
  --color-muted: #6b7280;
  --color-surface: #ffffff;
  --color-surface-raised: #f3f4f6;
  --color-border: #d1d5db;
  --color-accent: #2563eb;
}

[data-theme='dark'] {
  --color-text: #f3f4f6;
  --color-muted: #9ca3af;
  --color-surface: #111827;
  --color-surface-raised: #1f2937;
  --color-border: #374151;
  --color-accent: #60a5fa;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  color: var(--color-text);
  background: var(--color-surface);
  font-family: system-ui, sans-serif;
}

.app {
  min-height: 100vh;
  padding: 32px;
  color: var(--color-text);
  background: var(--color-surface);
}

.theme-button {
  border: 1px solid var(--color-border);
  border-radius: 6px;
  padding: 8px 12px;
  color: var(--color-text);
  background: var(--color-surface-raised);
}

.panel {
  margin-top: 24px;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 24px;
  background: var(--color-surface-raised);
}

.panel p {
  color: var(--color-muted);
}

.panel h1 {
  color: var(--color-accent);
}
</style>

点击按钮时,变化过程是:

  1. theme.value'light' 变为 'dark'
  2. 模板上的 data-themelight 变为 dark
  3. [data-theme='dark'] 开始匹配;
  4. 主题变量在 .app 内被重新定义;
  5. .app 及其后代重新计算 colorbackgroundborder-color 等属性;
  6. 不需要逐个组件修改类名。

2. 主题作用域由 DOM 位置决定

下面两种主题容器的效果不同:

<div data-theme="dark">
  <Panel />
</div>

如果 Panel 的普通 DOM 在这个 div 内,它能继承暗色变量。

但如果 Panel 内部使用 Teleport

<Teleport to="body">
  <div class="dialog">对话框</div>
</Teleport>

那么 .dialog 的最终 DOM 位置是 body 下,而不是主题容器内部。它可能无法继承主题容器定义的变量。

这解释了一个常见现象:

  • 页面普通内容已经切换为暗色;
  • 弹窗、下拉菜单或浮层仍然是亮色。

解决方式取决于主题架构:

方案 A:把主题属性放在 htmlbody

document.documentElement.dataset.theme = 'dark'
:root[data-theme='dark'] {
  --color-surface: #111827;
}

这样 Teleport 到 body 的元素仍处于 html 的主题变量继承链中。

方案 B:给 Teleport 目标节点同步主题

<body>
  <div id="app"></div>
  <div id="overlay-root" data-theme="light"></div>
</body>

组件根据当前主题同步 #overlay-rootdata-theme。这种方案能让不同区域拥有不同主题,但同步逻辑更复杂。

方案 C:把变量写到浮层自身

.dialog {
  --color-surface: #111827;
  background: var(--color-surface);
}

这种方式边界清晰,但会降低主题的集中管理能力。

3. 系统主题偏好

CSS 可以根据操作系统偏好提供初始值:

:root {
  --color-surface: #ffffff;
  --color-text: #1f2937;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-surface: #111827;
    --color-text: #f3f4f6;
  }
}

如果应用还支持用户手动选择主题,通常需要明确优先级:

:root {
  --color-surface: #ffffff;
  --color-text: #1f2937;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --color-surface: #111827;
    --color-text: #f3f4f6;
  }
}

:root[data-theme='light'] {
  --color-surface: #ffffff;
  --color-text: #1f2937;
}

:root[data-theme='dark'] {
  --color-surface: #111827;
  --color-text: #f3f4f6;
}

这里用 :root:not([data-theme]) 表示:只有用户没有明确选择主题时,才采用系统偏好。


七、覆盖边界:样式为什么能覆盖或不能覆盖

样式覆盖不是 Vue 独有规则,而是 CSS 级联的结果。要判断一条声明是否获胜,可以按以下顺序排查。

1. 先确认选择器是否匹配

例如:

<!-- ChildPanel.vue -->
<template>
  <section class="panel">
    <h2 class="title">标题</h2>
  </section>
</template>

<style scoped>
.title {
  color: blue;
}
</style>

父组件:

<style scoped>
.panel .title {
  color: red;
}
</style>

父组件的 .panel .title 通常不能匹配子组件内部的 .title,因为子组件内部元素没有父组件的作用域标记。

如果确实需要覆盖:

<style scoped>
:deep(.panel .title) {
  color: red;
}
</style>

但这会建立父组件到子组件内部结构的依赖。子组件将 .title 改名为 .heading,父组件的覆盖就会失效。

2. 再比较 CSS 属性是否可继承

父组件写:

.panel {
  color: red;
}

子组件内部文字没有设置 color 时,可能继承红色。

但下面这些属性通常不会通过普通继承传递:

.panel {
  margin: 20px;
  border: 1px solid red;
  width: 300px;
}

子组件的根元素不会因为位于 .panel 内就自动继承 marginborderwidth。如果需要传递样式值,应使用可继承属性或 CSS 变量:

.panel {
  --panel-border-color: red;
}

子组件:

.root {
  border: 1px solid var(--panel-border-color, #d1d5db);
}

3. 比较特异性

下面两条规则都能匹配同一个元素:

.button {
  color: blue;
}

.panel .button {
  color: red;
}

.panel .button 包含两个类选择器,特异性高于只有一个类选择器的 .button,因此红色获胜,除非前者位于不同的级联层或使用了不同的重要性规则。

不要把 Scoped 生成的属性简单当作“必然压过全局样式”。属性选择器确实会增加选择器特异性,但最终还要结合完整选择器比较。例如:

.button[data-v-x] {
  color: blue;
}

.panel .button {
  color: red;
}

两者都包含两个简单选择器,属于同一数量级,源码顺序就可能决定结果。

4. 比较源码顺序

在相同来源、相同级联层、相同重要性和相同特异性下,后出现的规则获胜:

.button {
  color: blue;
}

.button {
  color: red;
}

最终是红色。

在 Vite 项目中,样式可能来自:

  • main.ts 导入的全局 CSS;
  • Vue SFC 的 <style>
  • 第三方组件库;
  • 动态插入的开发环境样式;
  • 构建后的合并 CSS。

因此不能只看某个 .vue 文件,还要在浏览器开发者工具中查看“Matched CSS Rules”和最终生成的样式顺序。

5. !important 会改变正常判断

.library-button {
  padding: 4px !important;
}

普通规则很难覆盖它:

.button {
  padding: 12px;
}

如果确实需要覆盖,通常要在同等重要性下重新定义:

.button {
  padding: 12px !important;
}

但这样会继续制造覆盖链。更可控的方案是使用 CSS Cascade Layers(级联层)管理第三方 CSS 与应用 CSS 的优先级。


八、用 Cascade Layers 管理全局覆盖关系

现代 CSS 支持 @layer

@layer reset, vendor, components, utilities;

@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }
}

@layer vendor {
  /* 第三方库样式 */
}

@layer components {
  .button {
    border-radius: 6px;
  }
}

@layer utilities {
  .mt-4 {
    margin-top: 16px;
  }
}

对正常声明而言,后声明的层优先级更高,因此这里通常是:

utilities > components > vendor > reset

这样可以用层级表达“应用样式应该覆盖第三方样式”,而不必不断增加选择器特异性。

但要注意:

  • @layer 是 CSS 能力,不是 Vue 专属能力;
  • 需要目标浏览器支持现代 CSS;
  • !important 的层级规则与普通声明相反,重要声明的优先级顺序需要单独核对;
  • 级联层不能修复“选择器根本没有匹配”的问题。

九、组件公开样式边界:根元素、插槽与变体

1. 不要让父组件依赖子组件内部结构

脆弱的写法:

<!-- Parent.vue -->
<style scoped>
:deep(.dialog .title) {
  font-size: 20px;
}
</style>

这把父组件绑定到子组件内部的 .dialog .title 结构。更稳定的方式是让子组件通过属性或类表达变体:

<!-- Dialog.vue -->
<script setup lang="ts">
withDefaults(
  defineProps<{
    size?: 'small' | 'large'
  }>(),
  {
    size: 'small'
  }
)
</script>

<template>
  <section class="dialog" :class="`dialog--${size}`">
    <slot />
  </section>
</template>

<style scoped>
.dialog {
  border-radius: 8px;
}

.dialog--small {
  width: 320px;
}

.dialog--large {
  width: 640px;
}
</style>

父组件使用:

<Dialog size="large" />

这里的组件 API 表达了可支持的变化,而不是让父组件猜测内部类名。

2. 使用 CSS 变量作为低耦合样式 API

子组件:

<template>
  <section class="dialog">
    <slot />
  </section>
</template>

<style scoped>
.dialog {
  border: 1px solid var(--dialog-border, #d1d5db);
  border-radius: var(--dialog-radius, 8px);
  background: var(--dialog-surface, #ffffff);
}
</style>

父组件:

<template>
  <Dialog class="danger-dialog" />
</template>

<style scoped>
.danger-dialog {
  --dialog-border: #fca5a5;
  --dialog-surface: #fef2f2;
}
</style>

这里父组件没有穿透子组件内部,也没有依赖 .dialog 的内部结构,而是通过子组件事先约定的变量名修改视觉参数。

这要求子组件明确哪些变量属于公开样式 API,例如:

--dialog-border
--dialog-radius
--dialog-surface

变量名不是自动类型安全的接口;改名、删除或改变语义仍可能破坏使用方。因此公开变量也需要像组件 props 一样被文档化和测试。

3. class 属性与组件根节点

父组件:

<Dialog class="danger-dialog" />

Vue 会把未声明为 props 的 $attrs,包括 classstyle,默认继承到子组件根元素,前提是组件不是多根节点并且没有关闭属性继承。

多根组件需要显式处理:

<script setup lang="ts">
defineOptions({
  inheritAttrs: false
})
</script>

<template>
  <header v-bind="$attrs">标题</header>
  <main>内容</main>
</template>

此时父组件传入的 class 不会自动决定应该落在哪个根节点上,组件作者必须手动使用 $attrs。如果样式覆盖依赖父组件传入的类名,应先确认 $attrs 的实际落点。


十、响应式状态与 CSS 状态的边界

1. 用类名表示离散状态

<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
  <button
    class="menu-button"
    :class="{ 'menu-button--open': open }"
    type="button"
    @click="open = !open"
  >
    菜单
  </button>
</template>

<style scoped>
.menu-button {
  color: #374151;
}

.menu-button--open {
  color: #2563eb;
}
</style>

这里的状态流是:

用户点击
  -> open.value 改变
  -> 模板重新计算 class
  -> .menu-button--open 开始或停止匹配
  -> CSS 重新计算

类名适合表示有限的离散状态,例如:

  • open / closed
  • active / inactive
  • loading
  • error

2. 用 CSS 变量表示连续值或主题值

<script setup lang="ts">
import { computed, ref } from 'vue'

const progress = ref(40)
const width = computed(() => `${progress.value}%`)
</script>

<template>
  <div class="progress">
    <div class="progress__bar" :style="{ width }"></div>
  </div>
</template>

<style scoped>
.progress {
  height: 8px;
  border-radius: 999px;
  background: #e5e7eb;
}

.progress__bar {
  height: 100%;
  border-radius: inherit;
  background: #2563eb;
  transition: width 160ms ease;
}
</style>

也可以使用 CSS 变量:

<template>
  <div class="progress" :style="{ '--progress': `${progress}%` }">
    <div class="progress__bar"></div>
  </div>
</template>

<style scoped>
.progress__bar {
  width: var(--progress, 0%);
}
</style>

如果样式值来自用户输入或外部数据,应先做类型和范围校验。比如进度值应限制在 0100,避免将未经处理的字符串拼入样式属性。

3. 布尔状态不要只依赖颜色

.is-error {
  color: #dc2626;
}

颜色可以表达视觉状态,但不能替代语义和可访问性。模板还应提供:

<p
  class="message"
  :class="{ 'message--error': hasError }"
  role="alert"
>
  {{ message }}
</p>

CSS 负责表现,Vue 模板负责状态语义,二者不要混为一谈。


十一、完整示例:CSS Modules、主题变量和 Scoped 边界并存

下面把三种机制组合起来。

<!-- UserCard.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useCssModule } from 'vue'

const props = withDefaults(
  defineProps<{
    name: string
    online?: boolean
    compact?: boolean
  }>(),
  {
    online: false,
    compact: false
  }
)

const classes = useCssModule()

const cardClass = computed(() => [
  classes.card,
  {
    [classes.compact]: props.compact,
    [classes.offline]: !props.online
  }
])
</script>

<template>
  <article :class="cardClass">
    <div :class="$style.avatar" aria-hidden="true">
      {{ name.slice(0, 1).toUpperCase() }}
    </div>

    <div :class="$style.content">
      <h2 :class="$style.name">{{ name }}</h2>
      <p :class="$style.status">
        {{ online ? '在线' : '离线' }}
      </p>
    </div>

    <slot name="action" />
  </article>
</template>

<style module>
.card {
  display: flex;
  align-items: center;
  gap: 12px;
  border: 1px solid var(--card-border, #d1d5db);
  border-radius: var(--card-radius, 8px);
  padding: var(--card-padding, 16px);
  color: var(--color-text, #1f2937);
  background: var(--card-surface, #ffffff);
}

.compact {
  --card-padding: 8px;
  --card-radius: 4px;
}

.avatar {
  display: grid;
  width: 40px;
  height: 40px;
  place-items: center;
  border-radius: 50%;
  color: white;
  background: var(--color-accent, #2563eb);
}

.content {
  min-width: 0;
  flex: 1;
}

.name {
  margin: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 16px;
}

.status {
  margin: 4px 0 0;
  color: var(--color-muted, #6b7280);
  font-size: 13px;
}

.offline .avatar {
  filter: grayscale(1);
  opacity: 0.65;
}
</style>

使用方:

<script setup lang="ts">
import UserCard from './UserCard.vue'
</script>

<template>
  <UserCard
    name="Ada Lovelace"
    :online="true"
    class="featured-card"
  >
    <template #action>
      <button class="profile-action">查看</button>
    </template>
  </UserCard>
</template>

<style scoped>
.featured-card {
  --card-border: #93c5fd;
  --card-surface: #eff6ff;
}

:global(.profile-action) {
  border: 0;
  color: var(--color-accent, #2563eb);
  background: transparent;
}
</style>

这个例子中的边界如下:

  1. UserCard.vue 使用 CSS Modules,组件内部类名不会与其他组件的 .card.name 等类名冲突。
  2. --card-border--card-surface 通过根元素继承到 CSS Modules 生成的内部元素。
  3. 父组件通过根元素上的 class="featured-card" 注入变量,没有穿透子组件内部。
  4. 插槽中的 profile-action 是父组件提供的内容,它不会自动变成 UserCard 的 CSS Module 类。
  5. :global(.profile-action) 明确创建了一个全局规则,因此需要控制名称唯一性。
  6. 如果 UserCard 改成多根节点,featured-card 是否落到预期节点就必须重新检查。

十二、常见失败表现与诊断路径

1. “Scoped 样式没有生效”

按顺序检查:

第一步:元素是否真的被当前组件渲染

如果元素来自:

  • v-html
  • 第三方组件;
  • Teleport 的目标节点;
  • 浏览器扩展或外部脚本;

它可能没有当前 SFC 的作用域标记。

第二步:选择器是否越过了组件内部边界

父组件:

.title {
  color: red;
}

目标却是子组件内部的 .title。此时需要判断是否应该:

  • 修改子组件;
  • 通过 props 暴露变体;
  • 通过 CSS 变量暴露样式 API;
  • 临时使用 :deep()

第三步:规则是否被级联覆盖

在开发者工具中查看:

  • 规则是否出现在 Matched Rules 中;
  • 是否被划线;
  • 哪一条规则最终设置了该属性;
  • 是否有 !important
  • 是否处于不同 @layer
  • 是否有内联样式。

2. “CSS Modules 类名是 undefined”

检查:

<style module="theme">
.button {
  color: red;
}
</style>

如果脚本写成:

const classes = useCssModule()

名称不一致,读取的不是 theme 模块。应改为:

const classes = useCssModule('theme')

如果只在模板中使用默认模块,则使用:

:class="$style.button"

不要把 $style.button 当作普通字符串传给另一个运行时不认识 CSS Modules 的系统,除非你明确传递的是最终生成的类名。

3. “主题切换了,但弹窗没切换”

最常见原因是变量定义在局部容器:

<div id="app" data-theme="dark">
  <!-- 页面内容 -->
</div>

而弹窗被 Teleport 到:

<body>
  <div id="app"></div>
  <div class="dialog"></div>
</body>

.dialog 不在 #app 的后代树中,因此不能继承 #app 上的变量。诊断时不要看 Vue 组件嵌套关系,而要在 Elements 面板中确认最终 DOM 位置和祖先变量。

4. “组件里写了变量,但子组件读不到”

变量必须位于子组件最终 DOM 元素的祖先链上:

<ChildPanel />

如果变量写在父组件的某个兄弟元素上:

<div class="theme-source"></div>
<ChildPanel />

ChildPanel 不是 .theme-source 的后代,就读不到变量。

正确方式是放在共同祖先:

<div class="theme-root">
  <ChildPanel />
</div>
.theme-root {
  --color-accent: #2563eb;
}

5. “子组件的根元素能改,内部元素不能改”

这是 Vue Scoped CSS 的典型边界:

/* Parent.vue */
.child-root {
  margin-top: 16px;
}

父组件通常可以控制子组件根元素的布局位置;但要改内部标题:

.child-root .title {
  color: red;
}

通常需要 :deep(),或者更推荐由子组件提供 props、变体类或 CSS 变量接口。


十三、生产取舍:如何选择机制

1. 使用普通全局 CSS 的场景

适合:

html,
body {
  margin: 0;
}

:root {
  --color-text: #1f2937;
}

@font-face {
  /* 全局字体资源 */
}

这些内容本来就应该影响应用范围,不需要伪装成组件局部样式。

2. 使用 Scoped CSS 的场景

适合:

  • 页面组件的结构样式;
  • 单个组件中以语义类名组织的样式;
  • 需要少量 :deep():slotted() 边界表达的 SFC;
  • 不需要从脚本读取最终类名的场景。

Scoped CSS 的优势是模板可读性较高:

<div class="panel">

它的代价是仍然依赖编译器生成作用域属性,且复杂组件之间的穿透关系需要谨慎管理。

3. 使用 CSS Modules 的场景

适合:

  • 类名冲突风险较高的中大型组件;
  • 需要在 TypeScript 逻辑中组合类名;
  • 希望类名映射成为组件实现的一部分;
  • 组件样式主要由类选择器构成。

它的代价是模板中出现 $style 或模块映射,调试时需要查看最终生成的类名。

4. 使用 CSS 变量的场景

适合:

  • 主题;
  • 组件可配置颜色、间距、圆角;
  • 祖先上下文影响后代视觉;
  • 运行时动态更新的连续样式值。

不适合把所有样式都塞进变量。变量应表达稳定的设计语义,例如:

--button-primary-background
--dialog-radius
--color-muted

而不是大量暴露内部实现细节:

--internal-wrapper-child-margin-top

后者会把组件内部结构变成难以维护的外部接口。


十四、建立一条清晰的样式责任链

一个可维护的 Vue 样式系统,通常可以按以下责任分层:

全局层
  -> 重置、字体、设计令牌、主题变量、级联层

组件层
  -> Scoped CSS 或 CSS Modules 表达结构和局部状态

组件接口层
  -> props 表达行为变体
  -> CSS 变量表达可配置视觉值
  -> slot 表达内容扩展点

覆盖层
  -> :deep() 处理确需穿透的第三方或遗留结构
  -> :global() 对接明确的全局类名
  -> @layer 管理不同来源的级联顺序

DOM 位置层
  -> Teleport、Portal、浮层容器决定变量继承和选择器后代关系

当一个样式问题出现时,可以先回答四个问题:

  1. 这条规则的选择器实际匹配了哪个 DOM 元素?
  2. 目标值来自局部规则、继承,还是 CSS 变量?
  3. 元素最终位于哪个 DOM 祖先链中?
  4. 如果多条规则匹配,级联来源、层级、特异性和顺序如何比较?

Scoped CSS 负责缩小匹配范围,CSS Modules 负责局部化类名,CSS 变量负责传递值,主题负责组织变量切换,而覆盖边界则由最终 DOM 结构和 CSS 级联共同决定。只有把这几层分开,才能解释“为什么这个组件能改子组件根节点,却改不了内部标题”“为什么主题能改页面,却改不了 Teleport 弹窗”“为什么哈希类名仍然会被全局 button 规则影响”等实际问题。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。