Class与Style绑定
数据不仅决定页面显示什么,也决定页面以什么状态呈现。用 Class 和 Style 绑定表达状态,才能让交互反馈清晰、稳定且易于维护。
在上一篇文章中,我们通过事件处理与 v-model 让表单具备了交互能力。本文继续完善状态反馈,学习 Vue 如何动态管理元素类名和内联样式,并将它们应用到表单校验、按钮状态和列表项中。
写作约定:本系列以 Vue 3
<script setup>为主要写法。示例默认使用 Vue 3.2+,重点理解“由状态计算样式”,而不是在事件处理器中直接修改 DOM。
一、为什么需要 Class 与 Style 绑定
原生 JavaScript 中,我们可以通过 classList 或 element.style 修改元素外观:
button.classList.toggle('disabled', !canSubmit)
message.style.color = isValid ? 'green' : 'red'在 Vue 中,更推荐把类名和样式声明为响应式状态的结果:
<template>
<button :class="{ disabled: !canSubmit }">提交</button>
<p :style="{ color: isValid ? 'green' : 'red' }">校验结果</p>
</template>: 是 v-bind: 的缩写。以下两种写法等价:
<button v-bind:class="{ active: isActive }">完整写法</button>
<button :class="{ active: isActive }">简写</button>这种方式让模板直接表达“什么状态对应什么外观”。状态变化后,Vue 负责更新 DOM。
二、Class 的对象语法
2.1 单个动态类名
对象语法中,键是类名,值是布尔值或可以转换为布尔值的表达式。值为真时添加类名,为假时移除:
<template>
<button :class="{ active: isActive }" @click="isActive = !isActive">
切换状态
</button>
</template>
<script setup>
import { ref } from 'vue'
const isActive = ref(false)
</script>
<style>
.active {
color: #42b883;
font-weight: 600;
}
</style>2.2 同时绑定多个类名
一个对象可以包含多个类名:
<template>
<div
:class="{
'status-card': true,
'is-success': submitState === 'success',
'is-error': submitState === 'error',
'is-loading': submitState === 'loading'
}"
>
提交状态
</div>
</template>
<script setup>
import { ref } from 'vue'
const submitState = ref('idle')
</script>包含连字符的类名必须加引号。始终存在的静态类名,通常直接写在 class 中,模板会更简洁:
<div
class="status-card"
:class="{
'is-success': submitState === 'success',
'is-error': submitState === 'error',
'is-loading': submitState === 'loading'
}"
>
提交状态
</div>Vue 会合并静态 class 与动态 :class,不会互相覆盖。
2.3 用计算属性整理复杂条件
当类名依赖多个字段时,不建议把完整判断堆进模板:
<template>
<input
:class="titleTouched && title.length === 0 ? 'invalid' : titleTouched ? 'valid' : ''"
placeholder="标题"
>
</template>可以改为计算属性:
<template>
<input v-model.trim="title" :class="titleClass" @blur="titleTouched = true">
</template>
<script setup>
import { computed, ref } from 'vue'
const title = ref('')
const titleTouched = ref(false)
const titleClass = computed(() => {
if (!titleTouched.value) return ''
return title.value.length >= 3 ? 'valid' : 'invalid'
})
</script>模板负责展示,计算属性负责解释状态,后续修改校验规则时也更容易定位。
三、Class 的数组语法
3.1 绑定类名列表
数组语法适合按顺序组合多个类名:
<template>
<button :class="[sizeClass, themeClass]">保存</button>
</template>
<script setup>
import { ref } from 'vue'
const sizeClass = ref('btn-large')
const themeClass = ref('btn-primary')
</script>数组元素可以是变量,也可以直接写字符串:
<div :class="['panel', sizeClass, themeClass]">内容</div>3.2 在数组中使用条件表达式
<template>
<button
class="btn"
:class="[
isPrimary ? 'btn-primary' : 'btn-secondary',
{ 'is-loading': submitting, 'is-disabled': submitting }
]"
>
{{ submitting ? '提交中' : '提交' }}
</button>
</template>
<script setup>
import { ref } from 'vue'
const isPrimary = ref(true)
const submitting = ref(false)
</script>数组中的对象适合处理条件类名。对象与数组可以嵌套组合,但嵌套层级不宜过深;如果表达式已经难以一眼读懂,就应改为计算属性。
3.3 计算属性返回数组
<template>
<button :class="submitButtonClasses">提交</button>
</template>
<script setup>
import { computed, ref } from 'vue'
const submitting = ref(false)
const canSubmit = ref(true)
const submitButtonClasses = computed(() => [
'btn',
canSubmit.value ? 'btn-primary' : 'btn-disabled',
{ 'is-loading': submitting.value }
])
</script>返回对象还是数组没有绝对优劣。关键是让类名来源清晰,避免在模板中重复同一组条件。
四、Style 的对象语法
4.1 绑定单个样式对象
:style 可以直接接收 JavaScript 对象:
<template>
<div :style="{ color: textColor, fontSize: '18px', fontWeight: 600 }">
动态文本
</div>
</template>
<script setup>
import { ref } from 'vue'
const textColor = ref('#42b883')
</script>CSS 属性可以使用小驼峰命名:
<div :style="{ backgroundColor: '#f3f4f6', borderRadius: '8px' }">卡片</div>也支持带引号的短横线命名:
<div :style="{ 'background-color': '#f3f4f6', 'border-radius': '8px' }">卡片</div>同一项目中建议统一一种写法。数值型 CSS 值建议显式携带单位,避免不同属性对单位的要求造成歧义。
4.2 使用样式对象变量
样式条件较多时,可以把对象放入响应式数据或计算属性:
<template>
<div :style="progressStyle">{{ percent }}%</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const percent = ref(60)
const progressStyle = computed(() => ({
width: `${percent.value}%`,
backgroundColor: percent.value === 100 ? '#16a34a' : '#42b883',
transition: 'width 0.2s ease'
}))
</script>不要把整个计算属性写成每次都拼接的大段 CSS 字符串。对象结构更容易检查,也能减少无效样式覆盖。
4.3 绑定 CSS 自定义属性
Vue 的 :style 同样支持 CSS 自定义属性:
<template>
<div class="progress" :style="{ '--progress': `${percent}%` }">
<div class="progress-bar"></div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const percent = ref(60)
</script>
<style>
.progress {
height: 8px;
background: #e5e7eb;
}
.progress-bar {
width: var(--progress);
height: 100%;
background: #42b883;
}
</style>自定义属性名必须以 -- 开头并加引号。它适合把少量动态数值传给 CSS,其余布局与颜色规则仍保留在样式表中。
五、Style 的数组语法与合并规则
数组语法可以合并多个样式对象,后面的对象会覆盖前面的同名属性:
<template>
<div :style="[baseStyle, statusStyle]">状态文本</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const isError = ref(true)
const baseStyle = {
padding: '12px',
borderRadius: '6px',
color: '#374151'
}
const statusStyle = computed(() => ({
color: isError.value ? '#dc2626' : '#059669',
backgroundColor: isError.value ? '#fef2f2' : '#ecfdf5'
}))
</script>对象展开也能表达覆盖关系:
<template>
<div :style="{ ...baseStyle, ...statusStyle }">状态文本</div>
</template>数组写法的合并顺序更直观,尤其适合“基础样式 + 状态样式 + 临时覆盖样式”的场景。
六、用状态映射替代散落的条件判断
当状态固定为几种枚举值时,可以使用映射表集中管理样式:
<template>
<span class="status-tag" :class="statusClass">{{ statusText }}</span>
</template>
<script setup>
import { computed, ref } from 'vue'
const status = ref('pending')
const statusMap = {
pending: { text: '待处理', className: 'tag-warning' },
success: { text: '已完成', className: 'tag-success' },
failed: { text: '失败', className: 'tag-error' }
}
const currentStatus = computed(() => statusMap[status.value] || statusMap.pending)
const statusText = computed(() => currentStatus.value.text)
const statusClass = computed(() => currentStatus.value.className)
</script>状态映射让文案与类名保持一致,新增状态时只需修改一处。不过,class 是 JavaScript 的保留字,因此示例使用 className 作为对象字段名。
七、综合实战:带状态反馈的任务表单
下面把 Class 对象、Class 数组、Style 对象、计算属性和表单状态组合起来。示例包含标题校验、优先级选择、完成进度、提交状态和重置功能。
<template>
<form class="task-form" @submit.prevent="handleSubmit">
<div class="form-header">
<h2>新建任务</h2>
<span :class="['status-tag', `tag-${submitState}`]">{{ stateText }}</span>
</div>
<label for="title">任务标题</label>
<input
id="title"
v-model.trim="form.title"
:class="titleClass"
placeholder="至少输入 3 个字符"
@blur="touched.title = true"
>
<p v-if="titleError" class="field-error">{{ titleError }}</p>
<label for="priority">优先级</label>
<select id="priority" v-model="form.priority">
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
<label class="checkbox-row">
<input v-model="form.completed" type="checkbox">
创建时标记为已完成
</label>
<div class="progress-block">
<span>完成进度</span>
<div class="progress" :style="{ '--progress': `${progress}%` }">
<div class="progress-bar"></div>
</div>
<strong>{{ progress }}%</strong>
</div>
<div
class="preview-card"
:class="{ 'is-completed': form.completed }"
:style="[priorityStyle, { opacity: submitting ? 0.65 : 1 }]"
>
<small>任务预览</small>
<h3>{{ form.title || '尚未填写标题' }}</h3>
<p>{{ form.completed ? '已完成' : '待处理' }}</p>
</div>
<p v-if="message" :class="messageClass">{{ message }}</p>
<div class="actions">
<button type="button" :disabled="submitting" @click="resetForm">重置</button>
<button
type="submit"
:class="['btn-primary', { 'is-loading': submitting }]"
:disabled="submitting"
>
{{ submitting ? '提交中...' : '创建任务' }}
</button>
</div>
</form>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
const initialForm = () => ({
title: '',
priority: 'medium',
completed: false
})
const form = reactive(initialForm())
const touched = reactive({ title: false })
const submitting = ref(false)
const submitState = ref('idle')
const message = ref('')
const titleError = computed(() => {
if (!touched.title) return ''
if (!form.title) return '请输入任务标题'
if (form.title.length < 3) return '标题至少包含 3 个字符'
return ''
})
const titleClass = computed(() => {
if (!touched.title) return ''
return titleError.value ? 'is-invalid' : 'is-valid'
})
const isValid = computed(() => form.title.length >= 3)
const progress = computed(() => form.completed ? 100 : form.title ? 50 : 0)
const stateText = computed(() => ({
idle: '编辑中',
success: '创建成功',
error: '创建失败'
}[submitState.value]))
const messageClass = computed(() => [
'form-message',
submitState.value === 'error' ? 'message-error' : 'message-success'
])
const priorityStyle = computed(() => ({
borderLeft: `4px solid ${{ high: '#dc2626', medium: '#d97706', low: '#059669' }[form.priority]}`
}))
const handleSubmit = async () => {
touched.title = true
message.value = ''
if (!isValid.value || submitting.value) return
submitting.value = true
submitState.value = 'idle'
try {
await new Promise(resolve => setTimeout(resolve, 400))
submitState.value = 'success'
message.value = `任务“${form.title}”已创建`
} catch (error) {
submitState.value = 'error'
message.value = '创建失败,请稍后重试'
} finally {
submitting.value = false
}
}
const resetForm = () => {
Object.assign(form, initialForm())
touched.title = false
submitting.value = false
submitState.value = 'idle'
message.value = ''
}
</script>
<style>
.task-form {
max-width: 560px;
display: grid;
gap: 12px;
}
.form-header,
.progress-block,
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.form-header {
justify-content: space-between;
}
.task-form input:not([type='checkbox']),
.task-form select {
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
}
.is-valid {
border-color: #059669 !important;
}
.is-invalid {
border-color: #dc2626 !important;
}
.field-error,
.message-error {
color: #dc2626;
}
.message-success {
color: #059669;
}
.status-tag {
padding: 2px 8px;
border-radius: 999px;
background: #f3f4f6;
}
.tag-success {
color: #059669;
background: #ecfdf5;
}
.tag-error {
color: #dc2626;
background: #fef2f2;
}
.progress {
flex: 1;
height: 8px;
background: #e5e7eb;
}
.progress-bar {
width: var(--progress);
height: 100%;
background: #42b883;
transition: width 0.2s ease;
}
.preview-card {
padding: 16px;
border-radius: 8px;
background: #f9fafb;
}
.preview-card.is-completed h3 {
color: #6b7280;
text-decoration: line-through;
}
.btn-primary {
color: white;
background: #42b883;
}
.is-loading {
cursor: wait;
}
</style>这个示例有几个关键点:
titleClass根据失焦状态和校验结果返回类名,避免页面刚打开就显示错误。priorityStyle只负责动态边框颜色,其余卡片样式仍由 CSS 类管理。progress是业务状态,CSS 自定义属性只负责把进度数值传给样式表。submitState同时驱动标签、提示文案和提示颜色,避免同一状态出现互相矛盾的外观。重置时同时恢复表单、失焦状态、提交状态和提示信息。
八、列表项与组件中的样式绑定
8.1 列表项状态
在列表中,应使用每项自身的数据计算样式,而不是使用一个全局布尔值控制所有行:
<template>
<ul>
<li
v-for="task in tasks"
:key="task.id"
:class="{ 'is-selected': task.id === selectedId, 'is-completed': task.completed }"
@click="selectedId = task.id"
>
{{ task.title }}
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const selectedId = ref(1)
const tasks = ref([
{ id: 1, title: '学习 Class 绑定', completed: true },
{ id: 2, title: '学习 Style 绑定', completed: false }
])
</script>task.id === selectedId 只影响当前遍历项;task.completed 则完全来自该项自身状态。
8.2 组件上的 Class 与 Style 透传
在单根元素组件上使用时,父组件传入的 class 和 style 默认会合并到组件根元素:
<!-- 父组件 -->
<StatusCard class="panel-highlight" :style="{ marginTop: '16px' }" /><!-- StatusCard.vue -->
<template>
<section class="status-card">
<slot></slot>
</section>
</template>最终根元素会同时具有 status-card 和 panel-highlight,并应用父组件传入的上边距。父组件不需要修改子组件内部实现。
多根节点组件没有唯一根元素,无法自动决定透传目标。此时应明确绑定 $attrs:
<template>
<header>标题</header>
<section v-bind="$attrs">正文</section>
</template>
<script setup>
defineOptions({ inheritAttrs: false })
</script>v-bind="$attrs" 会透传父组件传入的属性,包括 class 和 style。只在确实需要控制透传目标时关闭 inheritAttrs,不要把它当成所有组件的固定模板。
九、Class 与内联 Style 如何选择
内联样式不是“性能更好”的通用方案,Class 也不是越少越好。选择标准是样式是否固定、是否需要复用,以及状态含义是否清楚。
十、常见问题自查
十一、总结
Class 与 Style 绑定的核心是让外观跟随状态变化:
:class支持对象和数组语法,静态class会与动态类名合并。对象语法适合条件类名,数组语法适合组合多个类名,两者可以嵌套使用。
:style支持对象、数组和 CSS 自定义属性,数组合并时后面的对象优先。固定状态优先使用 Class,连续数值和少量动态参数再考虑内联 Style。
复杂条件应提取到计算属性或状态映射,避免模板承担过多业务逻辑。
单根组件默认透传
class和style,多根组件需要明确透传目标。
下一站预告
下一篇《组件基础与注册方式》将把页面拆成可复用组件,学习组件定义、注册、插值渲染和基本组织方式,为后续 Props 与组件通信打好基础。
作者:洋洋技术笔记 发布日期:2026-11-04 系列:Vue.js从入门到精通 - 第7篇
原文链接:洋洋的技术笔记
评论区