Skip to content

10|单文件组件与样式:让剧本、演员和灯光同处一屋,但别互相抢台词 ​

第一章概览 / 10。目标:理解 .vue 单文件组件(SFC)如何被编译,并能有意识地选择局部样式、CSS Modules、深度选择器和 CSS 变量。

示例以 Vue 3.5.x + Vite + TypeScript 为环境,文件名均写在代码块内。先把 StatusBadge.vue 导入 src/App.vue 并改变 status,再检查构建后的作用域属性;浏览器不会直接执行 .vue 源文件。

单文件组件从模板、脚本和样式到浏览器的路径

一个 .vue 文件里住着什么 ​

区块职责编译后的大致去向
<template>描述结构、绑定和组件组合编译为渲染函数,生成 VNode
<script setup>声明状态、事件、组件契约与导入展开为组件 setup() 相关逻辑
普通 <script>模块级一次性逻辑、少数 options与 setup 脚本协作;不要重复放同一份组件逻辑
<style>布局、视觉、主题由构建工具处理并注入/抽取 CSS

SFC 不是浏览器原生格式;Vite + Vue 插件等构建工具会先把它拆解、编译和打包。它把同一组件的结构、行为、样式放近,目的是让边界清楚,不是鼓励你写出 1,800 行“万能组件”。

推荐的日常骨架 ​

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

type Status = 'idle' | 'running' | 'success' | 'error'

const props = withDefaults(defineProps<{ status: Status; label?: string }>(), {
  label: ''
})

const text = computed(() => props.label || ({
  idle: '待命', running: '处理中', success: '完成', error: '出错'
}[props.status]))
</script>

<template>
  <span class="status-badge" :class="`status-badge--${props.status}`" role="status">
    {{ text }}
  </span>
</template>

<style scoped>
.status-badge { border-radius: 999px; padding: .25rem .625rem; font-weight: 600; }
.status-badge--idle { background: #e2e8f0; color: #334155; }
.status-badge--running { background: #dbeafe; color: #1d4ed8; }
.status-badge--success { background: #dcfce7; color: #166534; }
.status-badge--error { background: #fee2e2; color: #b91c1c; }
</style>

scoped:局部,不是绝对隔离 ​

<style scoped> 会让编译器给当前组件的选择器和相关元素加作用域标记(常见形态类似 data-v-xxxx),以减少类名撞车。它是选择器转换,不是 Shadow DOM:全局 CSS、继承、CSS 自定义属性仍会影响它;父组件也能用其作用域选择器影响子组件的根元素。

语法用途该谨慎的原因
<style scoped>组件默认局部样式多层后代选择器可能有性能和维护成本
:deep(.child)必须穿透到子组件/第三方内部说明封装边界正在被跨越;把范围压到最小
:slotted(.token)样式化传进来的插槽内容插槽内容由父级提供,别越界规定太多
:global(.utility)在 scoped 块内声明全局规则全局影响需要显式、少量、可追踪
vue
<template>
  <article class="editor-shell"><slot /></article>
</template>

<style scoped>
.editor-shell { --accent: #0f766e; }

/* 仅当第三方编辑器没有公开主题 API 时才使用。 */
.editor-shell :deep(.third-party-editor__toolbar) { border-color: var(--accent); }

/* 只给插入的标签做有限修饰,不接管父组件整段内容。 */
:slotted(.tag) { color: var(--accent); }

/* 有意为之的全局工具类,写在这里也要承担全局责任。 */
:global(.sr-only) {
  position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0);
}
</style>

CSS Modules:当“类名就是模块 API” ​

CSS Modules 把类名映射成局部标识,适合希望从脚本直接引用样式、或项目已经以模块化 CSS 为主的场景。它与 scoped 是两种不同策略;一个组件通常选一种主策略即可,别让样式系统在同一文件里开辩论赛。

vue
<script setup lang="ts">
import { useCssModule } from 'vue'
const classes = useCssModule()
</script>

<template><p :class="classes.highlight">我来自 CSS Module。</p></template>

<style module>
.highlight { background: #fef3c7; color: #92400e; padding: .25rem; }
</style>

深水区:样式方案是在声明边界,不是在比赛谁更“隔离” ​

选择样式方案时,先问这条规则应该由谁拥有、谁可以覆写、有没有跨组件的公共语义。通常,一个项目会同时拥有三层,而不是只押一种技术:

样式层放什么推荐手段
全局基础reset、字体、设计令牌、全站无障碍工具类全局样式或主题入口
组件内部卡片布局、局部状态、私有结构scoped 或 CSS Modules
组件公开主题调用方可以合法调整的颜色、密度、尺寸props + CSS 变量 / v-bind()

scoped 适合“这条选择器只服务这个组件”;CSS Modules 适合“这个类名要作为脚本可引用的模块 API”。它们都不会自动解决设计令牌、第三方主题或 CSS 继承。若一个组件同时大规模使用全局类、scoped、Modules、!important 与深度选择器,问题多半不是缺少第五种方案,而是样式所有权没有画清。

CSS v-bind():把响应式值送到样式层 ​

在 SFC <style> 中,v-bind() 会被编译成 CSS 自定义属性;适合颜色、尺寸等展示值。业务布局分支仍应通过 class/结构表达,别把半个状态机浓缩进一串内联 style。

vue
<script setup lang="ts">
import { ref } from 'vue'
const accent = ref('#7c3aed')
const radius = ref('0.75rem')
</script>

<template><section class="panel">响应式主题面板</section></template>

<style scoped>
.panel {
  border: 2px solid v-bind(accent);
  border-radius: v-bind(radius);
  padding: 1rem;
}
</style>

让主题值可变,让结构保持可读 ​

CSS v-bind() 很适合把少量响应式展示值送进样式层;它不是把全部条件逻辑搬进 CSS 的邀请函。比如“紧凑模式是否显示次要说明”是结构与内容决策,应由模板和 class 表达;“紧凑模式的间距是多少”才是 CSS 变量擅长的范围。这样既能让主题可配置,也不会让开发者面对一串无法搜索的内联 style。

vue
<script setup lang="ts">
const props = withDefaults(defineProps<{ title: string; compact?: boolean }>(), {
  compact: false
})
</script>

<template>
  <article class="resource-card" :class="{ 'resource-card--compact': props.compact }">
    <h2>{{ props.title }}</h2>
    <p v-if="!props.compact">完整说明只在非紧凑模式显示。</p>
  </article>
</template>

<style scoped>
.resource-card { padding: var(--card-padding, 1rem); }
.resource-card--compact { --card-padding: .5rem; }
</style>

排错路线:明明写了样式,为什么页面没变 ​

  1. 确认选择器命中。 浏览器是否真的渲染了该元素、class 是否来自正确状态、scoped 生成的标记是否在预期节点上?
  2. 检查层叠与继承。 规则被覆盖、CSS 变量未定义、颜色从父级继承,都比“scoped 失效”更常见。
  3. 确认边界所有权。 插槽内容主要属父级,第三方组件内部属对方;先找公开 API,再考虑 :slotted() 或最小范围 :deep()。
  4. 再看响应式值。 v-bind() 依赖的 ref / props 是否真的变化?结构性变化应由 class 或模板承担,不要期待 CSS 自己创造 DOM。

工程组织:共处,不等于全塞 ​

  • 一个 SFC 管一个可解释的 UI 单元;跨页面逻辑抽为 composable,纯计算抽为普通 TypeScript 模块。
  • 全局 reset、设计令牌、字体与主题入口放在全局样式;组件私有布局放在 SFC。
  • scoped 下避免 .a .b .c 这类深层后代选择器,尤其大量列表中;给目标元素明确类名通常更快也更稳。
  • 样式优先服务语义、可访问性和状态;颜色不是错误状态的唯一提示,动效也应尊重 prefers-reduced-motion。

翻车博物馆 ​

现象根因修复
scoped 样式没进第三方组件内部它不是 Shadow DOM 穿透器优先使用对方的主题 API;必要时最小范围 :deep()
插槽内容样式不如预期内容属父组件作用域使用 :slotted() 或在父级控制其样式
样式隔离后仍被全局规则影响CSS 继承/全局选择器仍存在检查层叠、变量与全局 CSS,不要误怪 scoped
一个文件几千行“同文件”被误解为“同职责”按组件边界拆分,复用逻辑抽 composable

练习:做一张可主题化的资料卡 ​

创建 src/components/ResourceCard.vue 与 src/App.vue。输入合同为 title: string、url: string、featured?: boolean,以及来自可信主题配置的 theme?: { accent: string; surface: string };默认主题必须由工厂函数产生。

通过 CSS v-bind() 传递颜色,通过 class 表达 featured 结构状态。默认插槽没有内容时显示“暂未提供说明”;传入根节点带 resource-link 类的内容后,只用 :slotted(.resource-link) 调整这一个公开插槽样式。再写明:不可信 CSS 值应在进入组件前验证,第三方组件优先走其主题 API。

参考答案:查看第一章练习答案中的第 10 题。

验收清单:如何判断练习完成 ​

  • 把 accent 从 #0f766e 改为 #7c3aed 后边框与插槽链接一起变化;切换 featured 只改变 class 与对应视觉,不创建全局规则。
  • 不传默认槽时后备内容可见;传入 .resource-link 时只有该插槽根元素命中 :slotted()。
  • 传入非法或不可信 CSS 值时,调用方验证层必须拒绝或回退;组件没有用宽泛 :deep(*) 越过第三方边界。

通关自检:你能解释 .vue 文件三块分别何时被处理,并知道 scoped 是降低冲突的工具而不是 CSS 结界。

用心记录每一步成长