Skip to content

类与样式绑定

数据绑定的一个常见需求场景是操纵元素的 CSS class 列表和内联样式。因为 classstyle 都是 attribute,我们可以和其他 attribute 一样使用 v-bind 将它们和动态的字符串绑定。但是,在处理比较复杂的绑定时,通过拼接生成字符串是麻烦且易出错的。因此,Vue 专门为 classstylev-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>

下一步

vue study guide - 专业的 Vue.js 学习平台