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 行“万能组件”。
推荐的日常骨架
<!-- 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 块内声明全局规则 | 全局影响需要显式、少量、可追踪 |
<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 是两种不同策略;一个组件通常选一种主策略即可,别让样式系统在同一文件里开辩论赛。
<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。
<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。
<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>排错路线:明明写了样式,为什么页面没变
- 确认选择器命中。 浏览器是否真的渲染了该元素、class 是否来自正确状态、
scoped生成的标记是否在预期节点上? - 检查层叠与继承。 规则被覆盖、CSS 变量未定义、颜色从父级继承,都比“scoped 失效”更常见。
- 确认边界所有权。 插槽内容主要属父级,第三方组件内部属对方;先找公开 API,再考虑
:slotted()或最小范围:deep()。 - 再看响应式值。
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 结界。