Appearance
条件渲染
在 Vue 中,我们可以使用条件渲染指令来根据条件显示或隐藏元素。Vue 提供了几种不同的条件渲染方式,每种都有其特定的使用场景。
v-if
v-if 指令用于条件性地渲染一块内容。这块内容只会在指令的表达式返回真值时才被渲染。
vue
<template>
<div>
<h1 v-if="awesome">Vue is awesome!</h1>
</div>
</template>
<script setup>
import { ref } from 'vue'
const awesome = ref(true)
</script>v-else
你也可以使用 v-else 为 v-if 添加一个"else 块":
vue
<template>
<div>
<button @click="awesome = !awesome">Toggle</button>
<h1 v-if="awesome">Vue is awesome!</h1>
<h1 v-else>Oh no 😢</h1>
</div>
</template>
<script setup>
import { ref } from 'vue'
const awesome = ref(true)
</script>一个 v-else 元素必须跟在一个 v-if 或者 v-else-if 元素后面,否则它将不会被识别。
v-else-if
v-else-if 提供的是与 v-if 相应的"else if 块"。它可以连续多次重复使用:
vue
<template>
<div>
<div v-if="type === 'A'">
A
</div>
<div v-else-if="type === 'B'">
B
</div>
<div v-else-if="type === 'C'">
C
</div>
<div v-else>
Not A/B/C
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const type = ref('A')
</script>和 v-else 类似,一个使用 v-else-if 的元素必须紧跟在一个 v-if 或一个 v-else-if 元素后面。
在 <template> 上使用 v-if
因为 v-if 是一个指令,它必须依附于某个元素。但如果我们想要切换不止一个元素呢?在这种情况下我们可以在一个 <template> 元素上使用 v-if,这只是一个不可见的包装器元素,最终渲染的结果并不会包含这个 <template> 元素。
vue
<template>
<div>
<template v-if="ok">
<h1>Title</h1>
<p>Paragraph 1</p>
<p>Paragraph 2</p>
</template>
</div>
</template>
<script setup>
import { ref } from 'vue'
const ok = ref(true)
</script>v-else 和 v-else-if 也可以在 <template> 上使用。
v-show
另一个可以用来按条件显示一个元素的指令是 v-show。其用法基本一样:
vue
<template>
<div>
<h1 v-show="ok">Hello!</h1>
</div>
</template>
<script setup>
import { ref } from 'vue'
const ok = ref(true)
</script>不同之处在于 v-show 会在 DOM 渲染中保留该元素;v-show 仅切换了该元素上名为 display 的 CSS 属性。
v-show 不支持在 <template> 元素上使用,也不能和 v-else 搭配使用。
v-if vs v-show
v-if 是"真实的"按条件渲染,因为它确保了在切换时,条件区块内的事件监听器和子组件都会被销毁与重建。
v-if 也是惰性的:如果在初次渲染时条件值为 false,则不会做任何事。条件区块只有当条件首次变为 true 时才被渲染。
相比之下,v-show 简单许多,元素无论初始条件如何,始终会被渲染,只有 CSS display 属性会被切换。
总的来说,v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要频繁切换,则使用 v-show 较好;如果在运行时绑定条件很少改变,则 v-if 会更合适。
实际应用示例
用户权限控制
vue
<template>
<div class="dashboard">
<nav>
<ul>
<li><a href="#" @click="currentView = 'home'">首页</a></li>
<li v-if="user.role === 'admin'">
<a href="#" @click="currentView = 'admin'">管理面板</a>
</li>
<li v-if="user.role === 'admin' || user.role === 'moderator'">
<a href="#" @click="currentView = 'moderation'">内容审核</a>
</li>
<li><a href="#" @click="currentView = 'profile'">个人资料</a></li>
</ul>
</nav>
<main>
<div v-if="currentView === 'home'">
<h1>欢迎回来,{{ user.name }}!</h1>
<p>这是您的个人仪表板。</p>
</div>
<div v-else-if="currentView === 'admin' && user.role === 'admin'">
<h1>管理面板</h1>
<div class="admin-controls">
<button @click="showUserManagement = !showUserManagement">
用户管理
</button>
<button @click="showSystemSettings = !showSystemSettings">
系统设置
</button>
</div>
<div v-show="showUserManagement" class="panel">
<h2>用户管理</h2>
<p>管理系统用户...</p>
</div>
<div v-show="showSystemSettings" class="panel">
<h2>系统设置</h2>
<p>配置系统参数...</p>
</div>
</div>
<div v-else-if="currentView === 'moderation'">
<h1>内容审核</h1>
<p v-if="pendingReviews.length === 0">
暂无待审核内容
</p>
<div v-else>
<p>待审核内容:{{ pendingReviews.length }} 项</p>
<!-- 审核内容列表 -->
</div>
</div>
<div v-else-if="currentView === 'profile'">
<h1>个人资料</h1>
<p>编辑您的个人信息...</p>
</div>
<div v-else>
<h1>页面未找到</h1>
<p>您访问的页面不存在或您没有权限访问。</p>
</div>
</main>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const user = reactive({
name: '张三',
role: 'admin' // 'admin', 'moderator', 'user'
})
const currentView = ref('home')
const showUserManagement = ref(false)
const showSystemSettings = ref(false)
const pendingReviews = ref([])
</script>
<style scoped>
.dashboard {
display: flex;
min-height: 100vh;
}
nav {
width: 200px;
background-color: #f8f9fa;
padding: 1rem;
}
nav ul {
list-style: none;
padding: 0;
}
nav li {
margin-bottom: 0.5rem;
}
nav a {
color: #007bff;
text-decoration: none;
padding: 0.5rem;
display: block;
border-radius: 4px;
}
nav a:hover {
background-color: #e9ecef;
}
main {
flex: 1;
padding: 2rem;
}
.admin-controls {
margin: 1rem 0;
}
.admin-controls button {
margin-right: 1rem;
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.panel {
background-color: #f8f9fa;
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
}
</style>表单状态管理
vue
<template>
<div class="form-container">
<form @submit.prevent="handleSubmit">
<h2>用户注册</h2>
<!-- 步骤指示器 -->
<div class="step-indicator">
<div
v-for="(step, index) in steps"
:key="index"
:class="stepClass(index)"
>
{{ step }}
</div>
</div>
<!-- 第一步:基本信息 -->
<div v-if="currentStep === 1" class="step">
<h3>基本信息</h3>
<div class="form-group">
<label for="username">用户名</label>
<input
id="username"
v-model="form.username"
type="text"
required
/>
<div v-if="errors.username" class="error">
{{ errors.username }}
</div>
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input
id="email"
v-model="form.email"
type="email"
required
/>
<div v-if="errors.email" class="error">
{{ errors.email }}
</div>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="form.password"
type="password"
required
/>
<div v-if="errors.password" class="error">
{{ errors.password }}
</div>
</div>
</div>
<!-- 第二步:个人信息 -->
<div v-else-if="currentStep === 2" class="step">
<h3>个人信息</h3>
<div class="form-group">
<label for="firstName">姓名</label>
<input
id="firstName"
v-model="form.firstName"
type="text"
required
/>
</div>
<div class="form-group">
<label for="lastName">姓氏</label>
<input
id="lastName"
v-model="form.lastName"
type="text"
required
/>
</div>
<div class="form-group">
<label for="birthDate">出生日期</label>
<input
id="birthDate"
v-model="form.birthDate"
type="date"
/>
</div>
<div class="form-group">
<label for="phone">电话号码</label>
<input
id="phone"
v-model="form.phone"
type="tel"
/>
</div>
</div>
<!-- 第三步:确认信息 -->
<div v-else-if="currentStep === 3" class="step">
<h3>确认信息</h3>
<div class="confirmation">
<h4>基本信息</h4>
<p><strong>用户名:</strong>{{ form.username }}</p>
<p><strong>邮箱:</strong>{{ form.email }}</p>
<h4>个人信息</h4>
<p><strong>姓名:</strong>{{ form.firstName }} {{ form.lastName }}</p>
<p v-if="form.birthDate">
<strong>出生日期:</strong>{{ form.birthDate }}
</p>
<p v-if="form.phone">
<strong>电话:</strong>{{ form.phone }}
</p>
</div>
<div class="form-group">
<label>
<input
v-model="form.agreeToTerms"
type="checkbox"
required
/>
我同意<a href="#" @click.prevent="showTerms = true">服务条款</a>
</label>
</div>
<div class="form-group">
<label>
<input
v-model="form.subscribeNewsletter"
type="checkbox"
/>
订阅我们的新闻通讯
</label>
</div>
</div>
<!-- 提交成功 -->
<div v-else-if="currentStep === 4" class="step success">
<h3>注册成功!</h3>
<p>感谢您的注册,我们已向您的邮箱发送了确认邮件。</p>
<button type="button" @click="resetForm">注册新用户</button>
</div>
<!-- 按钮组 -->
<div v-if="currentStep < 4" class="button-group">
<button
v-if="currentStep > 1"
type="button"
@click="previousStep"
class="btn-secondary"
>
上一步
</button>
<button
v-if="currentStep < 3"
type="button"
@click="nextStep"
:disabled="!canProceed"
class="btn-primary"
>
下一步
</button>
<button
v-if="currentStep === 3"
type="submit"
:disabled="!form.agreeToTerms || isSubmitting"
class="btn-primary"
>
<span v-if="isSubmitting">提交中...</span>
<span v-else>完成注册</span>
</button>
</div>
</form>
<!-- 服务条款模态框 -->
<div v-if="showTerms" class="modal-overlay" @click="showTerms = false">
<div class="modal" @click.stop>
<h3>服务条款</h3>
<p>这里是服务条款的内容...</p>
<button @click="showTerms = false">关闭</button>
</div>
</div>
<!-- 加载指示器 -->
<div v-if="isSubmitting" class="loading-overlay">
<div class="spinner"></div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
const currentStep = ref(1)
const isSubmitting = ref(false)
const showTerms = ref(false)
const steps = ['基本信息', '个人信息', '确认信息']
const form = reactive({
username: '',
email: '',
password: '',
firstName: '',
lastName: '',
birthDate: '',
phone: '',
agreeToTerms: false,
subscribeNewsletter: false
})
const errors = reactive({
username: '',
email: '',
password: ''
})
const stepClass = (index) => ({
'step-item': true,
'active': index + 1 === currentStep.value,
'completed': index + 1 < currentStep.value
})
const canProceed = computed(() => {
if (currentStep.value === 1) {
return form.username && form.email && form.password &&
!errors.username && !errors.email && !errors.password
} else if (currentStep.value === 2) {
return form.firstName && form.lastName
}
return true
})
const validateStep1 = () => {
errors.username = form.username.length < 3 ? '用户名至少3个字符' : ''
errors.email = !/\S+@\S+\.\S+/.test(form.email) ? '请输入有效邮箱' : ''
errors.password = form.password.length < 6 ? '密码至少6个字符' : ''
return !errors.username && !errors.email && !errors.password
}
const nextStep = () => {
if (currentStep.value === 1 && !validateStep1()) {
return
}
if (currentStep.value < 3) {
currentStep.value++
}
}
const previousStep = () => {
if (currentStep.value > 1) {
currentStep.value--
}
}
const handleSubmit = async () => {
isSubmitting.value = true
try {
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 2000))
currentStep.value = 4
} catch (error) {
console.error('注册失败:', error)
} finally {
isSubmitting.value = false
}
}
const resetForm = () => {
currentStep.value = 1
Object.keys(form).forEach(key => {
if (typeof form[key] === 'boolean') {
form[key] = false
} else {
form[key] = ''
}
})
Object.keys(errors).forEach(key => {
errors[key] = ''
})
}
</script>
<style scoped>
.form-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
position: relative;
}
.step-indicator {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
}
.step-item {
flex: 1;
text-align: center;
padding: 0.5rem;
background-color: #e9ecef;
margin: 0 0.25rem;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.step-item.active {
background-color: #007bff;
color: white;
}
.step-item.completed {
background-color: #28a745;
color: white;
}
.step {
min-height: 400px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.error {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.confirmation {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.confirmation h4 {
margin-top: 1rem;
margin-bottom: 0.5rem;
color: #495057;
}
.confirmation h4:first-child {
margin-top: 0;
}
.success {
text-align: center;
padding: 2rem;
}
.success h3 {
color: #28a745;
margin-bottom: 1rem;
}
.button-group {
display: flex;
justify-content: space-between;
margin-top: 2rem;
}
.btn-primary, .btn-secondary {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>动态内容展示
vue
<template>
<div class="content-display">
<div class="filters">
<button
v-for="category in categories"
:key="category"
@click="selectedCategory = category"
:class="{ active: selectedCategory === category }"
>
{{ category }}
</button>
</div>
<div class="search-bar">
<input
v-model="searchQuery"
placeholder="搜索内容..."
@input="handleSearch"
/>
</div>
<div class="content-area">
<!-- 加载状态 -->
<div v-if="isLoading" class="loading">
<p>加载中...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<h3>出错了</h3>
<p>{{ error }}</p>
<button @click="retry">重试</button>
</div>
<!-- 空状态 -->
<div v-else-if="filteredItems.length === 0" class="empty-state">
<h3>没有找到内容</h3>
<p v-if="searchQuery">
没有找到包含 "{{ searchQuery }}" 的内容
</p>
<p v-else>
当前分类下暂无内容
</p>
</div>
<!-- 内容列表 -->
<div v-else class="content-list">
<div
v-for="item in filteredItems"
:key="item.id"
class="content-item"
@click="selectItem(item)"
>
<img
v-if="item.image"
:src="item.image"
:alt="item.title"
class="item-image"
/>
<div class="item-content">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<div class="item-meta">
<span class="category">{{ item.category }}</span>
<span class="date">{{ formatDate(item.date) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 详情模态框 -->
<div v-if="selectedItem" class="modal-overlay" @click="closeModal">
<div class="modal" @click.stop>
<button class="close-btn" @click="closeModal">×</button>
<img
v-if="selectedItem.image"
:src="selectedItem.image"
:alt="selectedItem.title"
class="modal-image"
/>
<h2>{{ selectedItem.title }}</h2>
<p class="modal-description">{{ selectedItem.description }}</p>
<div v-if="selectedItem.content" class="modal-content">
<h3>详细内容</h3>
<p>{{ selectedItem.content }}</p>
</div>
<div class="modal-actions">
<button @click="likeItem(selectedItem)" class="btn-like">
{{ selectedItem.liked ? '取消点赞' : '点赞' }}
({{ selectedItem.likes }})
</button>
<button @click="shareItem(selectedItem)" class="btn-share">
分享
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const categories = ['全部', '技术', '设计', '产品', '营销']
const selectedCategory = ref('全部')
const searchQuery = ref('')
const isLoading = ref(false)
const error = ref('')
const items = ref([])
const selectedItem = ref(null)
const filteredItems = computed(() => {
let filtered = items.value
// 按分类过滤
if (selectedCategory.value !== '全部') {
filtered = filtered.filter(item => item.category === selectedCategory.value)
}
// 按搜索关键词过滤
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(item =>
item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query)
)
}
return filtered
})
const loadItems = async () => {
isLoading.value = true
error.value = ''
try {
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 1000))
items.value = [
{
id: 1,
title: 'Vue.js 3.0 新特性',
description: '了解 Vue.js 3.0 带来的激动人心的新功能',
category: '技术',
date: new Date('2023-01-15'),
image: '/api/placeholder/300/200',
content: 'Vue.js 3.0 引入了许多新特性,包括组合式 API、更好的 TypeScript 支持等...',
likes: 42,
liked: false
},
{
id: 2,
title: '现代 UI 设计趋势',
description: '探索 2023 年最新的用户界面设计趋势',
category: '设计',
date: new Date('2023-02-20'),
image: '/api/placeholder/300/200',
content: '现代 UI 设计注重简洁性、可访问性和用户体验...',
likes: 38,
liked: true
},
{
id: 3,
title: '产品管理最佳实践',
description: '成功产品管理的关键策略和方法',
category: '产品',
date: new Date('2023-03-10'),
image: '/api/placeholder/300/200',
content: '有效的产品管理需要平衡用户需求、技术可行性和商业目标...',
likes: 29,
liked: false
}
]
} catch (err) {
error.value = '加载内容失败,请稍后重试'
} finally {
isLoading.value = false
}
}
const handleSearch = () => {
// 搜索防抖可以在这里实现
}
const selectItem = (item) => {
selectedItem.value = item
}
const closeModal = () => {
selectedItem.value = null
}
const likeItem = (item) => {
item.liked = !item.liked
item.likes += item.liked ? 1 : -1
}
const shareItem = (item) => {
if (navigator.share) {
navigator.share({
title: item.title,
text: item.description,
url: window.location.href
})
} else {
// 降级处理
navigator.clipboard.writeText(window.location.href)
alert('链接已复制到剪贴板')
}
}
const formatDate = (date) => {
return date.toLocaleDateString('zh-CN')
}
const retry = () => {
loadItems()
}
onMounted(() => {
loadItems()
})
</script>
<style scoped>
.content-display {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.filters button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.filters button.active,
.filters button:hover {
background-color: #007bff;
color: white;
border-color: #007bff;
}
.search-bar {
margin-bottom: 2rem;
}
.search-bar input {
width: 100%;
max-width: 400px;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.loading, .error-state, .empty-state {
text-align: center;
padding: 3rem;
color: #6c757d;
}
.error-state button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.content-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.content-item {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.content-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.item-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.item-content {
padding: 1rem;
}
.item-content h3 {
margin: 0 0 0.5rem 0;
color: #333;
}
.item-content p {
color: #666;
margin: 0 0 1rem 0;
}
.item-meta {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #999;
}
.category {
background-color: #e9ecef;
padding: 0.25rem 0.5rem;
border-radius: 12px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background-color: white;
border-radius: 8px;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
}
.modal-image {
width: 100%;
height: 300px;
object-fit: cover;
}
.modal h2 {
padding: 1rem;
margin: 0;
}
.modal-description {
padding: 0 1rem;
color: #666;
}
.modal-content {
padding: 1rem;
border-top: 1px solid #eee;
}
.modal-actions {
padding: 1rem;
display: flex;
gap: 1rem;
border-top: 1px solid #eee;
}
.btn-like, .btn-share {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-like:hover, .btn-share:hover {
background-color: #f8f9fa;
}
</style>性能优化建议
1. 避免不必要的重新渲染
vue
<!-- 不好 - 每次都会重新计算 -->
<div v-if="items.filter(item => item.active).length > 0">
有活跃项目
</div>
<!-- 好 - 使用计算属性 -->
<div v-if="hasActiveItems">
有活跃项目
</div>
<script setup>
const hasActiveItems = computed(() =>
items.value.some(item => item.active)
)
</script>2. 合理使用 v-show 和 v-if
vue
<!-- 频繁切换使用 v-show -->
<div v-show="isVisible" class="frequently-toggled">
频繁切换的内容
</div>
<!-- 条件很少改变使用 v-if -->
<div v-if="user.isAdmin" class="admin-panel">
管理员面板
</div>3. 使用 key 优化列表渲染
vue
<template v-for="item in items" :key="item.id">
<div v-if="item.visible">
{{ item.name }}
</div>
</template>