Appearance
类与样式绑定
数据绑定的一个常见需求场景是操纵元素的 CSS class 列表和内联样式。因为 class 和 style 都是 attribute,我们可以和其他 attribute 一样使用 v-bind 将它们和动态的字符串绑定。但是,在处理比较复杂的绑定时,通过拼接生成字符串是麻烦且易出错的。因此,Vue 专门为 class 和 style 的 v-bind 用法提供了特殊的功能增强。除了字符串外,表达式的值也可以是对象或数组。
绑定 HTML class
绑定对象
我们可以给 :class (v-bind:class 的缩写) 传递一个对象来动态切换 class:
vue
<template>
<div :class="{ active: isActive }"></div>
</template>
<script setup>
import { ref } from 'vue'
const isActive = ref(true)
</script>上面的语法表示 active 这个 class 存在与否将取决于数据属性 isActive 的真假值。
你可以在对象中传入更多字段来动态切换多个 class。此外,:class 指令也可以与一般的 class attribute 共存:
vue
<template>
<div
class="static"
:class="{ active: isActive, 'text-danger': hasError }"
></div>
</template>
<script setup>
import { ref } from 'vue'
const isActive = ref(true)
const hasError = ref(false)
</script>渲染的结果会是:
html
<div class="static active"></div>当 isActive 或者 hasError 变化时,class 列表将相应地更新。例如,如果 hasError 的值为 true,class 列表将变为 "static active text-danger"。
绑定的对象并不一定需要写成内联字面量的形式:
vue
<template>
<div :class="classObject"></div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const classObject = reactive({
active: true,
'text-danger': false
})
</script>这也会渲染出相同的结果。我们也可以绑定一个返回对象的计算属性:
vue
<template>
<div :class="classObject"></div>
</template>
<script setup>
import { ref, computed } from 'vue'
const isActive = ref(true)
const error = ref(null)
const classObject = computed(() => ({
active: isActive.value && !error.value,
'text-danger': error.value && error.value.type === 'fatal'
}))
</script>绑定数组
我们可以给 :class 绑定一个数组来渲染多个 CSS class:
vue
<template>
<div :class="[activeClass, errorClass]"></div>
</template>
<script setup>
import { ref } from 'vue'
const activeClass = ref('active')
const errorClass = ref('text-danger')
</script>渲染的结果是:
html
<div class="active text-danger"></div>如果你想根据条件切换列表中的 class,可以使用三元表达式:
vue
<template>
<div :class="[isActive ? activeClass : '', errorClass]"></div>
</template>这样写将始终添加 errorClass,但是只有在 isActive 为真时才添加 activeClass。
不过,当有多个条件 class 时这样写有些冗长。所以在数组语法中也可以使用对象语法:
vue
<template>
<div :class="[{ active: isActive }, errorClass]"></div>
</template>在组件上使用
对于只有一个根元素的组件,当你使用了 class attribute 时,这些 class 会被添加到根元素上并与该元素上已有的 class 合并。
举例来说,如果你声明了一个组件名叫 MyComponent,模板如下:
vue
<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>在使用时添加一些 class:
vue
<!-- 在使用组件时 -->
<MyComponent class="baz boo" />渲染出的 HTML 为:
html
<p class="foo bar baz boo">Hi!</p>Class 的绑定也是同样的:
vue
<MyComponent :class="{ active: isActive }" />当 isActive 为真时,渲染出的 HTML 会是:
html
<p class="foo bar active">Hi!</p>如果你的组件有多个根元素,你需要指定哪个根元素来接收这个 class。你可以通过组件的 $attrs 属性来实现指定:
vue
<!-- MyComponent 模板使用 $attrs 时 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>vue
<MyComponent class="baz" />这将渲染出:
html
<p class="baz">Hi!</p>
<span>This is a child component</span>绑定内联样式
绑定对象
:style 支持绑定 JavaScript 对象值,对应的是 HTML 元素的 style 属性:
vue
<template>
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
</template>
<script setup>
import { ref } from 'vue'
const activeColor = ref('red')
const fontSize = ref(30)
</script>尽管推荐使用 camelCase,但 :style 也支持 kebab-cased 形式的 CSS 属性 key (对应其 CSS 中的实际名称):
vue
<template>
<div :style="{ 'font-size': fontSize + 'px' }"></div>
</template>直接绑定到一个样式对象通常更好,这会让模板更清洁:
vue
<template>
<div :style="styleObject"></div>
</template>
<script setup>
import { reactive } from 'vue'
const styleObject = reactive({
color: 'red',
fontSize: '13px'
})
</script>同样的,如果样式对象需要更复杂的逻辑,也可以使用返回样式对象的计算属性:
vue
<template>
<div :style="styleObject"></div>
</template>
<script setup>
import { ref, computed } from 'vue'
const baseStyles = {
fontSize: '14px',
color: 'black'
}
const isSpecial = ref(false)
const styleObject = computed(() => {
return {
...baseStyles,
color: isSpecial.value ? 'red' : baseStyles.color,
fontWeight: isSpecial.value ? 'bold' : 'normal'
}
})
</script>绑定数组
我们还可以给 :style 绑定一个包含多个样式对象的数组。这些对象会被合并后应用到同一元素上:
vue
<template>
<div :style="[baseStyles, overridingStyles]"></div>
</template>
<script setup>
import { reactive } from 'vue'
const baseStyles = reactive({
fontSize: '14px',
color: 'black'
})
const overridingStyles = reactive({
color: 'red'
})
</script>自动前缀
当你在 :style 中使用了需要浏览器特殊前缀的 CSS 属性时,Vue 会自动为他们加上相应的前缀。Vue 是在运行时检查该属性是否支持在当前浏览器中使用。如果浏览器不支持某个属性,那么将尝试加上各个浏览器特殊前缀,以找到哪一个是被支持的。
样式多值
你可以对一个样式属性提供多个 (不同前缀的) 值,举例来说:
vue
<template>
<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>
</template>数组仅会渲染浏览器支持的最后一个值。在这个示例中,在支持不需要特别前缀的浏览器中都会渲染为 display: flex。
实际应用示例
主题切换
vue
<template>
<div :class="themeClass">
<header :class="headerClass">
<h1>我的应用</h1>
<button @click="toggleTheme" :class="buttonClass">
切换到{{ isDark ? '浅色' : '深色' }}主题
</button>
</header>
<main :class="mainClass">
<p>这是主要内容区域</p>
</main>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const isDark = ref(false)
const themeClass = computed(() => ({
'theme-dark': isDark.value,
'theme-light': !isDark.value
}))
const headerClass = computed(() => [
'header',
{
'header-dark': isDark.value,
'header-light': !isDark.value
}
])
const buttonClass = computed(() => ({
'btn': true,
'btn-primary': !isDark.value,
'btn-secondary': isDark.value
}))
const mainClass = computed(() => ({
'main': true,
'main-dark': isDark.value,
'main-light': !isDark.value
}))
const toggleTheme = () => {
isDark.value = !isDark.value
}
</script>
<style scoped>
.theme-light {
background-color: #ffffff;
color: #333333;
}
.theme-dark {
background-color: #1a1a1a;
color: #ffffff;
}
.header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-light {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.header-dark {
background-color: #343a40;
border-bottom: 1px solid #495057;
}
.main {
padding: 2rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
</style>动态表单验证样式
vue
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="email">邮箱</label>
<input
id="email"
v-model="email"
type="email"
:class="emailInputClass"
@blur="validateEmail"
placeholder="请输入邮箱"
/>
<div v-if="emailError" class="error-message">
{{ emailError }}
</div>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="password"
type="password"
:class="passwordInputClass"
@blur="validatePassword"
placeholder="请输入密码"
/>
<div v-if="passwordError" class="error-message">
{{ passwordError }}
</div>
</div>
<button
type="submit"
:class="submitButtonClass"
:disabled="!isFormValid"
>
提交
</button>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
const email = ref('')
const password = ref('')
const emailError = ref('')
const passwordError = ref('')
const emailTouched = ref(false)
const passwordTouched = ref(false)
const validateEmail = () => {
emailTouched.value = true
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!email.value) {
emailError.value = '邮箱不能为空'
} else if (!emailRegex.test(email.value)) {
emailError.value = '请输入有效的邮箱地址'
} else {
emailError.value = ''
}
}
const validatePassword = () => {
passwordTouched.value = true
if (!password.value) {
passwordError.value = '密码不能为空'
} else if (password.value.length < 6) {
passwordError.value = '密码长度至少为6位'
} else {
passwordError.value = ''
}
}
const emailInputClass = computed(() => ({
'form-input': true,
'input-error': emailTouched.value && emailError.value,
'input-success': emailTouched.value && !emailError.value && email.value,
'input-default': !emailTouched.value
}))
const passwordInputClass = computed(() => ({
'form-input': true,
'input-error': passwordTouched.value && passwordError.value,
'input-success': passwordTouched.value && !passwordError.value && password.value,
'input-default': !passwordTouched.value
}))
const isFormValid = computed(() => {
return email.value &&
password.value &&
!emailError.value &&
!passwordError.value
})
const submitButtonClass = computed(() => ({
'btn': true,
'btn-primary': isFormValid.value,
'btn-disabled': !isFormValid.value
}))
const handleSubmit = () => {
validateEmail()
validatePassword()
if (isFormValid.value) {
console.log('表单提交:', { email: email.value, password: password.value })
}
}
</script>
<style scoped>
.form-group {
margin-bottom: 1rem;
}
.form-input {
width: 100%;
padding: 0.5rem;
border: 2px solid #ddd;
border-radius: 4px;
transition: border-color 0.3s ease;
}
.input-default {
border-color: #ddd;
}
.input-success {
border-color: #28a745;
}
.input-error {
border-color: #dc3545;
}
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-disabled {
background-color: #6c757d;
color: white;
cursor: not-allowed;
}
</style>动画和过渡效果
vue
<template>
<div class="animation-demo">
<button @click="toggleAnimation" class="control-btn">
{{ isAnimating ? '停止动画' : '开始动画' }}
</button>
<div
:class="boxClass"
:style="boxStyle"
@click="handleBoxClick"
>
点击我
</div>
<div class="controls">
<label>
大小:
<input
type="range"
min="50"
max="200"
v-model="size"
/>
</label>
<label>
颜色:
<input
type="color"
v-model="color"
/>
</label>
<label>
旋转角度:
<input
type="range"
min="0"
max="360"
v-model="rotation"
/>
</label>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const isAnimating = ref(false)
const isClicked = ref(false)
const size = ref(100)
const color = ref('#007bff')
const rotation = ref(0)
const boxClass = computed(() => ({
'animated-box': true,
'box-animating': isAnimating.value,
'box-clicked': isClicked.value
}))
const boxStyle = computed(() => ({
width: size.value + 'px',
height: size.value + 'px',
backgroundColor: color.value,
transform: `rotate(${rotation.value}deg)`
}))
const toggleAnimation = () => {
isAnimating.value = !isAnimating.value
}
const handleBoxClick = () => {
isClicked.value = true
setTimeout(() => {
isClicked.value = false
}, 300)
}
</script>
<style scoped>
.animation-demo {
padding: 2rem;
text-align: center;
}
.control-btn {
margin-bottom: 2rem;
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.animated-box {
margin: 2rem auto;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
user-select: none;
}
.box-animating {
animation: pulse 2s infinite;
}
.box-clicked {
transform: scale(1.1) !important;
}
.controls {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 300px;
margin: 0 auto;
}
.controls label {
display: flex;
justify-content: space-between;
align-items: center;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(0, 123, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0);
}
}
</style>响应式布局
vue
<template>
<div :class="containerClass">
<aside :class="sidebarClass">
<h3>侧边栏</h3>
<nav>
<ul>
<li><a href="#" :class="linkClass">首页</a></li>
<li><a href="#" :class="linkClass">关于</a></li>
<li><a href="#" :class="linkClass">联系</a></li>
</ul>
</nav>
</aside>
<main :class="mainClass">
<h1>主要内容</h1>
<p>这是主要内容区域,会根据屏幕大小自适应布局。</p>
<div :class="cardGridClass">
<div
v-for="card in cards"
:key="card.id"
:class="cardClass"
>
<h3>{{ card.title }}</h3>
<p>{{ card.content }}</p>
</div>
</div>
</main>
<button
@click="toggleSidebar"
:class="toggleButtonClass"
>
{{ sidebarVisible ? '隐藏' : '显示' }}侧边栏
</button>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const sidebarVisible = ref(true)
const screenWidth = ref(window.innerWidth)
const cards = ref([
{ id: 1, title: '卡片 1', content: '这是第一张卡片的内容' },
{ id: 2, title: '卡片 2', content: '这是第二张卡片的内容' },
{ id: 3, title: '卡片 3', content: '这是第三张卡片的内容' },
{ id: 4, title: '卡片 4', content: '这是第四张卡片的内容' }
])
const isMobile = computed(() => screenWidth.value < 768)
const isTablet = computed(() => screenWidth.value >= 768 && screenWidth.value < 1024)
const isDesktop = computed(() => screenWidth.value >= 1024)
const containerClass = computed(() => ({
'layout-container': true,
'mobile': isMobile.value,
'tablet': isTablet.value,
'desktop': isDesktop.value,
'sidebar-hidden': !sidebarVisible.value
}))
const sidebarClass = computed(() => ({
'sidebar': true,
'sidebar-visible': sidebarVisible.value,
'sidebar-mobile': isMobile.value
}))
const mainClass = computed(() => ({
'main-content': true,
'main-full': !sidebarVisible.value || isMobile.value
}))
const cardGridClass = computed(() => ({
'card-grid': true,
'grid-mobile': isMobile.value,
'grid-tablet': isTablet.value,
'grid-desktop': isDesktop.value
}))
const cardClass = computed(() => ({
'card': true,
'card-mobile': isMobile.value
}))
const linkClass = computed(() => ({
'nav-link': true,
'link-mobile': isMobile.value
}))
const toggleButtonClass = computed(() => ({
'toggle-btn': true,
'btn-mobile': isMobile.value,
'btn-fixed': isMobile.value
}))
const toggleSidebar = () => {
sidebarVisible.value = !sidebarVisible.value
}
const handleResize = () => {
screenWidth.value = window.innerWidth
// 在移动设备上默认隐藏侧边栏
if (isMobile.value) {
sidebarVisible.value = false
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
handleResize() // 初始化
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped>
.layout-container {
display: flex;
min-height: 100vh;
position: relative;
}
.sidebar {
width: 250px;
background-color: #f8f9fa;
padding: 1rem;
transition: transform 0.3s ease;
}
.sidebar-mobile {
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 1000;
transform: translateX(-100%);
}
.sidebar-visible.sidebar-mobile {
transform: translateX(0);
}
.main-content {
flex: 1;
padding: 1rem;
transition: margin-left 0.3s ease;
}
.main-full {
margin-left: 0;
}
.card-grid {
display: grid;
gap: 1rem;
margin-top: 2rem;
}
.grid-mobile {
grid-template-columns: 1fr;
}
.grid-tablet {
grid-template-columns: repeat(2, 1fr);
}
.grid-desktop {
grid-template-columns: repeat(3, 1fr);
}
.card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-mobile {
padding: 0.75rem;
}
.nav-link {
color: #007bff;
text-decoration: none;
padding: 0.5rem;
display: block;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.nav-link:hover {
background-color: #e9ecef;
}
.link-mobile {
padding: 0.75rem;
font-size: 1.1rem;
}
.toggle-btn {
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-fixed {
position: fixed;
z-index: 1001;
}
.btn-mobile {
padding: 0.75rem 1rem;
font-size: 1rem;
}
/* 移动设备上的覆盖层 */
.mobile .sidebar-visible::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
</style>选项式 API
在选项式 API 中,class 和 style 绑定的用法基本相同:
javascript
export default {
data() {
return {
isActive: true,
hasError: false,
activeColor: 'red',
fontSize: 30
}
},
computed: {
classObject() {
return {
active: this.isActive && !this.hasError,
'text-danger': this.hasError && this.hasError.type === 'fatal'
}
},
styleObject() {
return {
color: this.activeColor,
fontSize: this.fontSize + 'px'
}
}
}
}最佳实践
1. 使用计算属性处理复杂逻辑
javascript
// 好
const buttonClass = computed(() => ({
'btn': true,
'btn-primary': !disabled.value && !loading.value,
'btn-disabled': disabled.value,
'btn-loading': loading.value
}))
// 不好 - 模板中的复杂逻辑
// :class="{ 'btn': true, 'btn-primary': !disabled && !loading, 'btn-disabled': disabled, 'btn-loading': loading }"2. 保持 CSS 类名的语义化
css
/* 好 - 语义化的类名 */
.user-card { }
.user-card--active { }
.user-card__avatar { }
/* 不好 - 无意义的类名 */
.box1 { }
.red-text { }
.big { }3. 使用 CSS 变量配合动态样式
vue
<template>
<div
class="themed-component"
:style="cssVars"
>
内容
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
primaryColor: { type: String, default: '#007bff' },
secondaryColor: { type: String, default: '#6c757d' },
borderRadius: { type: Number, default: 4 }
})
const cssVars = computed(() => ({
'--primary-color': props.primaryColor,
'--secondary-color': props.secondaryColor,
'--border-radius': props.borderRadius + 'px'
}))
</script>
<style scoped>
.themed-component {
background-color: var(--primary-color);
border: 1px solid var(--secondary-color);
border-radius: var(--border-radius);
padding: 1rem;
}
</style>4. 避免内联样式的过度使用
vue
<!-- 好 - 使用 CSS 类 -->
<template>
<div :class="alertClass">
{{ message }}
</div>
</template>
<script setup>
const alertClass = computed(() => ({
'alert': true,
'alert-success': type.value === 'success',
'alert-error': type.value === 'error'
}))
</script>
<!-- 不好 - 过多的内联样式 -->
<template>
<div :style="{
padding: '1rem',
margin: '1rem 0',
borderRadius: '4px',
backgroundColor: type === 'success' ? '#d4edda' : '#f8d7da',
color: type === 'success' ? '#155724' : '#721c24'
}">
{{ message }}
</div>
</template>