列表渲染
在 Vue 中,我们可以使用 v-for
指令来渲染列表。v-for
指令需要使用 item in items
形式的特殊语法,其中 items
是源数据数组,而 item
则是被迭代的数组元素的别名。
基本列表渲染
渲染数组
<template>
<div>
<h2>水果列表</h2>
<ul>
<li v-for="fruit in fruits" :key="fruit.id">
{{ fruit.name }} - ¥{{ fruit.price }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const fruits = ref([
{ id: 1, name: '苹果', price: 5.99 },
{ id: 2, name: '香蕉', price: 3.99 },
{ id: 3, name: '橙子', price: 4.99 },
{ id: 4, name: '葡萄', price: 8.99 }
])
</script>
访问索引
v-for
也支持一个可选的第二个参数,即当前项的索引:
<template>
<div>
<h2>带索引的列表</h2>
<ol>
<li v-for="(fruit, index) in fruits" :key="fruit.id">
{{ index + 1 }}. {{ fruit.name }} - ¥{{ fruit.price }}
</li>
</ol>
</div>
</template>
<script setup>
import { ref } from 'vue'
const fruits = ref([
{ id: 1, name: '苹果', price: 5.99 },
{ id: 2, name: '香蕉', price: 3.99 },
{ id: 3, name: '橙子', price: 4.99 }
])
</script>
渲染对象
你也可以使用 v-for
来遍历一个对象的所有属性:
<template>
<div>
<h2>用户信息</h2>
<ul>
<li v-for="(value, key) in user" :key="key">
{{ key }}: {{ value }}
</li>
</ul>
<h3>带索引的对象遍历</h3>
<ul>
<li v-for="(value, key, index) in user" :key="key">
{{ index }}. {{ key }}: {{ value }}
</li>
</ul>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const user = reactive({
name: '张三',
age: 25,
email: 'zhangsan@example.com',
city: '北京'
})
</script>
在 <template>
上使用 v-for
类似于 v-if
,你也可以利用 <template>
元素来循环渲染一段包含多个元素的内容:
<template>
<div>
<h2>商品列表</h2>
<template v-for="product in products" :key="product.id">
<h3>{{ product.name }}</h3>
<p>价格: ¥{{ product.price }}</p>
<p>描述: {{ product.description }}</p>
<hr>
</template>
</div>
</template>
<script setup>
import { ref } from 'vue'
const products = ref([
{
id: 1,
name: 'iPhone 15',
price: 5999,
description: '最新款苹果手机'
},
{
id: 2,
name: 'MacBook Pro',
price: 12999,
description: '专业级笔记本电脑'
}
])
</script>
v-for 与 v-if
当它们处于同一节点,v-if
的优先级比 v-for
更高,这意味着 v-if
将没有权限访问 v-for
里的变量:
<!-- 这会抛出一个错误,因为属性 todo 此时没有在该实例上定义 -->
<li v-for="todo in todos" v-if="!todo.isComplete" :key="todo.id">
{{ todo.name }}
</li>
可以把 v-for
移动到 <template>
标签中来修正:
<template v-for="todo in todos" :key="todo.id">
<li v-if="!todo.isComplete">
{{ todo.name }}
</li>
</template>
通过 key 管理状态
当 Vue 正在更新使用 v-for
渲染的元素列表时,它默认使用"就地更新"的策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序,而是就地更新每个元素,并且确保它们在每个索引位置正确渲染。
为了给 Vue 一个提示,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素,你需要为每项提供一个唯一的 key
attribute:
<template>
<div>
<h2>待办事项</h2>
<div class="todo-controls">
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="添加新任务">
<button @click="addTodo">添加</button>
<button @click="shuffleTodos">随机排序</button>
</div>
<ul class="todo-list">
<li v-for="todo in todos" :key="todo.id" class="todo-item">
<input
v-model="todo.completed"
type="checkbox"
class="todo-checkbox"
>
<span
:class="{ completed: todo.completed }"
class="todo-text"
>
{{ todo.text }}
</span>
<button @click="removeTodo(todo.id)" class="remove-btn">删除</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const newTodo = ref('')
const todos = ref([
{ id: 1, text: '学习 Vue 3', completed: false },
{ id: 2, text: '完成项目', completed: true },
{ id: 3, text: '写文档', completed: false }
])
let nextId = 4
function addTodo() {
if (newTodo.value.trim()) {
todos.value.push({
id: nextId++,
text: newTodo.value,
completed: false
})
newTodo.value = ''
}
}
function removeTodo(id) {
const index = todos.value.findIndex(todo => todo.id === id)
if (index > -1) {
todos.value.splice(index, 1)
}
}
function shuffleTodos() {
for (let i = todos.value.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[todos.value[i], todos.value[j]] = [todos.value[j], todos.value[i]]
}
}
</script>
<style scoped>
.todo-controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.todo-controls input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.todo-controls button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
padding: 10px;
border: 1px solid #eee;
margin-bottom: 5px;
border-radius: 4px;
background-color: #f9f9f9;
}
.todo-checkbox {
margin-right: 10px;
}
.todo-text {
flex: 1;
transition: all 0.3s;
}
.todo-text.completed {
text-decoration: line-through;
color: #888;
}
.remove-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.remove-btn:hover {
background-color: #c82333;
}
</style>
数组变更检测
Vue 能够侦听响应式数组的变更方法,并在它们被调用时触发相关的更新。这些变更方法包括:
push()
pop()
shift()
unshift()
splice()
sort()
reverse()
<template>
<div class="array-methods-demo">
<h2>数组方法演示</h2>
<div class="controls">
<div class="control-group">
<h3>添加元素</h3>
<input v-model="newItem" placeholder="输入新项目" @keyup.enter="pushItem">
<button @click="pushItem">Push (末尾添加)</button>
<button @click="unshiftItem">Unshift (开头添加)</button>
</div>
<div class="control-group">
<h3>删除元素</h3>
<button @click="popItem">Pop (删除末尾)</button>
<button @click="shiftItem">Shift (删除开头)</button>
<button @click="spliceItem">Splice (删除中间)</button>
</div>
<div class="control-group">
<h3>排序和反转</h3>
<button @click="sortItems">Sort (排序)</button>
<button @click="reverseItems">Reverse (反转)</button>
<button @click="shuffleItems">Shuffle (随机)</button>
</div>
<div class="control-group">
<h3>重置</h3>
<button @click="resetItems" class="reset-btn">重置列表</button>
</div>
</div>
<div class="list-display">
<h3>当前列表 ({{ items.length }} 项)</h3>
<div class="items-grid">
<div
v-for="(item, index) in items"
:key="item.id"
class="item-card"
:style="{ animationDelay: index * 0.1 + 's' }"
>
<div class="item-index">{{ index }}</div>
<div class="item-content">
<div class="item-id">ID: {{ item.id }}</div>
<div class="item-name">{{ item.name }}</div>
<div class="item-timestamp">{{ item.timestamp }}</div>
</div>
<button @click="removeItem(index)" class="item-remove">×</button>
</div>
</div>
<div v-if="items.length === 0" class="empty-state">
<p>列表为空,添加一些项目吧!</p>
</div>
</div>
<div class="operation-log">
<h3>操作日志</h3>
<div class="log-container">
<div
v-for="(log, index) in operationLogs"
:key="index"
class="log-entry"
:class="log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-operation">{{ log.operation }}</span>
<span class="log-details">{{ log.details }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const newItem = ref('')
const items = ref([
{ id: 1, name: '项目 A', timestamp: new Date().toLocaleTimeString() },
{ id: 2, name: '项目 B', timestamp: new Date().toLocaleTimeString() },
{ id: 3, name: '项目 C', timestamp: new Date().toLocaleTimeString() }
])
const operationLogs = ref([])
let nextId = 4
function addLog(operation, details, type = 'info') {
operationLogs.value.unshift({
time: new Date().toLocaleTimeString(),
operation,
details,
type
})
// 保持日志数量在合理范围内
if (operationLogs.value.length > 20) {
operationLogs.value.pop()
}
}
function pushItem() {
if (newItem.value.trim()) {
const item = {
id: nextId++,
name: newItem.value,
timestamp: new Date().toLocaleTimeString()
}
items.value.push(item)
addLog('Push', `添加 "${item.name}" 到末尾`, 'success')
newItem.value = ''
}
}
function unshiftItem() {
if (newItem.value.trim()) {
const item = {
id: nextId++,
name: newItem.value,
timestamp: new Date().toLocaleTimeString()
}
items.value.unshift(item)
addLog('Unshift', `添加 "${item.name}" 到开头`, 'success')
newItem.value = ''
}
}
function popItem() {
if (items.value.length > 0) {
const removed = items.value.pop()
addLog('Pop', `删除末尾项目 "${removed.name}"`, 'warning')
} else {
addLog('Pop', '列表为空,无法删除', 'error')
}
}
function shiftItem() {
if (items.value.length > 0) {
const removed = items.value.shift()
addLog('Shift', `删除开头项目 "${removed.name}"`, 'warning')
} else {
addLog('Shift', '列表为空,无法删除', 'error')
}
}
function spliceItem() {
if (items.value.length > 0) {
const middleIndex = Math.floor(items.value.length / 2)
const removed = items.value.splice(middleIndex, 1)
addLog('Splice', `删除中间项目 "${removed[0].name}" (索引 ${middleIndex})`, 'warning')
} else {
addLog('Splice', '列表为空,无法删除', 'error')
}
}
function removeItem(index) {
const removed = items.value.splice(index, 1)
addLog('Remove', `删除项目 "${removed[0].name}" (索引 ${index})`, 'warning')
}
function sortItems() {
items.value.sort((a, b) => a.name.localeCompare(b.name))
addLog('Sort', '按名称排序列表', 'info')
}
function reverseItems() {
items.value.reverse()
addLog('Reverse', '反转列表顺序', 'info')
}
function shuffleItems() {
for (let i = items.value.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[items.value[i], items.value[j]] = [items.value[j], items.value[i]]
}
addLog('Shuffle', '随机打乱列表', 'info')
}
function resetItems() {
items.value = [
{ id: nextId++, name: '项目 A', timestamp: new Date().toLocaleTimeString() },
{ id: nextId++, name: '项目 B', timestamp: new Date().toLocaleTimeString() },
{ id: nextId++, name: '项目 C', timestamp: new Date().toLocaleTimeString() }
]
addLog('Reset', '重置列表到初始状态', 'info')
}
</script>
<style scoped>
.array-methods-demo {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.control-group {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.control-group h3 {
margin: 0 0 15px 0;
color: #495057;
font-size: 16px;
}
.control-group input {
width: 100%;
padding: 8px;
margin-bottom: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
box-sizing: border-box;
}
.control-group button {
width: 100%;
padding: 8px 12px;
margin-bottom: 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
background-color: #007bff;
color: white;
}
.control-group button:hover {
background-color: #0056b3;
transform: translateY(-1px);
}
.control-group button:last-child {
margin-bottom: 0;
}
.reset-btn {
background-color: #dc3545 !important;
}
.reset-btn:hover {
background-color: #c82333 !important;
}
.list-display {
margin-bottom: 30px;
}
.list-display h3 {
margin-bottom: 15px;
color: #333;
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.item-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
position: relative;
transition: all 0.3s;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.item-card:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.item-index {
position: absolute;
top: 5px;
left: 5px;
background-color: #007bff;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.item-content {
margin-left: 10px;
}
.item-id {
font-size: 12px;
color: #6c757d;
margin-bottom: 5px;
}
.item-name {
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.item-timestamp {
font-size: 12px;
color: #6c757d;
}
.item-remove {
position: absolute;
top: 5px;
right: 5px;
background-color: #dc3545;
color: white;
border: none;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.item-remove:hover {
background-color: #c82333;
transform: scale(1.1);
}
.empty-state {
text-align: center;
padding: 40px;
color: #6c757d;
background-color: #f8f9fa;
border-radius: 8px;
border: 2px dashed #dee2e6;
}
.operation-log {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.operation-log h3 {
margin: 0 0 15px 0;
color: #333;
}
.log-container {
max-height: 300px;
overflow-y: auto;
background-color: white;
border-radius: 4px;
padding: 10px;
}
.log-entry {
display: flex;
padding: 8px;
margin-bottom: 5px;
border-radius: 4px;
font-size: 14px;
border-left: 4px solid;
}
.log-entry.info {
background-color: #d1ecf1;
border-left-color: #17a2b8;
}
.log-entry.success {
background-color: #d4edda;
border-left-color: #28a745;
}
.log-entry.warning {
background-color: #fff3cd;
border-left-color: #ffc107;
}
.log-entry.error {
background-color: #f8d7da;
border-left-color: #dc3545;
}
.log-time {
font-weight: bold;
margin-right: 10px;
min-width: 80px;
color: #495057;
}
.log-operation {
font-weight: bold;
margin-right: 10px;
min-width: 80px;
}
.log-details {
flex: 1;
color: #6c757d;
}
@media (max-width: 768px) {
.controls {
grid-template-columns: 1fr;
}
.items-grid {
grid-template-columns: 1fr;
}
.log-entry {
flex-direction: column;
gap: 5px;
}
.log-time,
.log-operation {
min-width: auto;
}
}
</style>
替换数组
变更方法,顾名思义,会变更调用了这些方法的原始数组。相比之下,也有非变更方法,例如 filter()
、concat()
和 slice()
。它们不会变更原始数组,而总是返回一个新数组。当使用非变更方法时,可以用新数组替换旧数组:
<template>
<div class="filter-demo">
<h2>数组过滤演示</h2>
<div class="filters">
<div class="filter-group">
<label>按价格过滤:</label>
<select v-model="priceFilter">
<option value="all">全部</option>
<option value="low">低价 (< ¥50)</option>
<option value="medium">中价 (¥50-¥100)</option>
<option value="high">高价 (> ¥100)</option>
</select>
</div>
<div class="filter-group">
<label>按类别过滤:</label>
<select v-model="categoryFilter">
<option value="all">全部类别</option>
<option value="electronics">电子产品</option>
<option value="clothing">服装</option>
<option value="books">图书</option>
<option value="food">食品</option>
</select>
</div>
<div class="filter-group">
<label>搜索:</label>
<input
v-model="searchQuery"
type="text"
placeholder="搜索商品名称..."
>
</div>
<div class="filter-group">
<label>排序:</label>
<select v-model="sortBy">
<option value="name">按名称</option>
<option value="price-asc">价格升序</option>
<option value="price-desc">价格降序</option>
<option value="rating">按评分</option>
</select>
</div>
</div>
<div class="results-info">
<p>显示 {{ filteredProducts.length }} / {{ allProducts.length }} 个商品</p>
<button @click="resetFilters" class="reset-btn">重置筛选</button>
</div>
<div class="products-grid">
<div
v-for="product in filteredProducts"
:key="product.id"
class="product-card"
>
<div class="product-image">
<div class="product-category">{{ getCategoryLabel(product.category) }}</div>
</div>
<div class="product-info">
<h3 class="product-name">{{ product.name }}</h3>
<div class="product-price">¥{{ product.price.toFixed(2) }}</div>
<div class="product-rating">
<span class="stars">{{ '★'.repeat(Math.floor(product.rating)) }}{{ '☆'.repeat(5 - Math.floor(product.rating)) }}</span>
<span class="rating-text">({{ product.rating }})</span>
</div>
<p class="product-description">{{ product.description }}</p>
</div>
</div>
</div>
<div v-if="filteredProducts.length === 0" class="no-results">
<h3>没有找到匹配的商品</h3>
<p>尝试调整筛选条件或搜索关键词</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const searchQuery = ref('')
const priceFilter = ref('all')
const categoryFilter = ref('all')
const sortBy = ref('name')
const allProducts = ref([
{
id: 1,
name: 'iPhone 15 Pro',
price: 999,
category: 'electronics',
rating: 4.8,
description: '最新款苹果手机,配备A17 Pro芯片'
},
{
id: 2,
name: '连帽卫衣',
price: 89,
category: 'clothing',
rating: 4.2,
description: '舒适保暖的连帽卫衣,多色可选'
},
{
id: 3,
name: 'JavaScript高级程序设计',
price: 79,
category: 'books',
rating: 4.9,
description: '前端开发必读经典书籍'
},
{
id: 4,
name: '有机苹果',
price: 25,
category: 'food',
rating: 4.5,
description: '新鲜有机苹果,甜脆可口'
},
{
id: 5,
name: 'MacBook Air',
price: 1299,
category: 'electronics',
rating: 4.7,
description: '轻薄便携的笔记本电脑'
},
{
id: 6,
name: '牛仔裤',
price: 129,
category: 'clothing',
rating: 4.1,
description: '经典款牛仔裤,百搭时尚'
},
{
id: 7,
name: 'Vue.js实战',
price: 69,
category: 'books',
rating: 4.6,
description: 'Vue.js框架实战指南'
},
{
id: 8,
name: '精品咖啡豆',
price: 45,
category: 'food',
rating: 4.4,
description: '来自哥伦比亚的精品咖啡豆'
},
{
id: 9,
name: '无线耳机',
price: 199,
category: 'electronics',
rating: 4.3,
description: '高品质无线蓝牙耳机'
},
{
id: 10,
name: '运动鞋',
price: 299,
category: 'clothing',
rating: 4.5,
description: '舒适透气的运动鞋'
}
])
const filteredProducts = computed(() => {
let result = allProducts.value
// 按搜索查询过滤
if (searchQuery.value) {
result = result.filter(product =>
product.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
product.description.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
// 按价格过滤
if (priceFilter.value !== 'all') {
result = result.filter(product => {
switch (priceFilter.value) {
case 'low':
return product.price < 50
case 'medium':
return product.price >= 50 && product.price <= 100
case 'high':
return product.price > 100
default:
return true
}
})
}
// 按类别过滤
if (categoryFilter.value !== 'all') {
result = result.filter(product => product.category === categoryFilter.value)
}
// 排序
result = result.slice().sort((a, b) => {
switch (sortBy.value) {
case 'name':
return a.name.localeCompare(b.name)
case 'price-asc':
return a.price - b.price
case 'price-desc':
return b.price - a.price
case 'rating':
return b.rating - a.rating
default:
return 0
}
})
return result
})
function getCategoryLabel(category) {
const labels = {
electronics: '电子产品',
clothing: '服装',
books: '图书',
food: '食品'
}
return labels[category] || category
}
function resetFilters() {
searchQuery.value = ''
priceFilter.value = 'all'
categoryFilter.value = 'all'
sortBy.value = 'name'
}
</script>
<style scoped>
.filter-demo {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
font-weight: bold;
margin-bottom: 5px;
color: #333;
}
.filter-group select,
.filter-group input {
padding: 8px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
.results-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background-color: #e9ecef;
border-radius: 6px;
}
.results-info p {
margin: 0;
font-weight: bold;
color: #495057;
}
.reset-btn {
padding: 8px 16px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.reset-btn:hover {
background-color: #5a6268;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.product-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.product-image {
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.product-category {
background-color: rgba(255,255,255,0.9);
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
color: #333;
}
.product-info {
padding: 15px;
}
.product-name {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: bold;
color: #333;
}
.product-price {
font-size: 18px;
font-weight: bold;
color: #007bff;
margin-bottom: 8px;
}
.product-rating {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.stars {
color: #ffc107;
margin-right: 5px;
}
.rating-text {
font-size: 12px;
color: #6c757d;
}
.product-description {
margin: 0;
font-size: 14px;
color: #6c757d;
line-height: 1.4;
}
.no-results {
text-align: center;
padding: 60px 20px;
background-color: #f8f9fa;
border-radius: 8px;
border: 2px dashed #dee2e6;
}
.no-results h3 {
margin: 0 0 10px 0;
color: #6c757d;
}
.no-results p {
margin: 0;
color: #6c757d;
}
@media (max-width: 768px) {
.filters {
grid-template-columns: 1fr;
}
.results-info {
flex-direction: column;
gap: 10px;
text-align: center;
}
.products-grid {
grid-template-columns: 1fr;
}
}
</style>
显示过滤或排序后的结果
有时,我们希望显示数组经过过滤或排序后的版本,而不实际变更或重置原始数据。在这种情况下,可以创建一个计算属性,来返回过滤或排序后的数组。
<template>
<div>
<h2>计算属性过滤示例</h2>
<input v-model="searchText" placeholder="搜索用户...">
<ul>
<li v-for="user in filteredUsers" :key="user.id">
{{ user.name }} - {{ user.email }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const searchText = ref('')
const users = ref([
{ id: 1, name: '张三', email: 'zhangsan@example.com' },
{ id: 2, name: '李四', email: 'lisi@example.com' },
{ id: 3, name: '王五', email: 'wangwu@example.com' }
])
const filteredUsers = computed(() => {
if (!searchText.value) {
return users.value
}
return users.value.filter(user =>
user.name.includes(searchText.value) ||
user.email.includes(searchText.value)
)
})
</script>
在 v-for 里使用范围值
v-for
可以直接接受一个整数值。在这种用例中,会把该模板基于 1...n
的取值范围重复多次。
<template>
<div>
<h2>数字范围渲染</h2>
<span v-for="n in 10" :key="n">{{ n }} </span>
<h3>九九乘法表</h3>
<div class="multiplication-table">
<div v-for="i in 9" :key="i" class="row">
<span v-for="j in i" :key="j" class="cell">
{{ j }} × {{ i }} = {{ i * j }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.multiplication-table {
margin-top: 20px;
}
.row {
margin-bottom: 10px;
}
.cell {
display: inline-block;
width: 120px;
padding: 5px;
margin-right: 10px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
text-align: center;
font-family: monospace;
}
</style>
最佳实践
始终使用 key:
- 为每个
v-for
项提供唯一的key
- 避免使用索引作为
key
,除非列表是静态的
- 为每个
避免 v-if 和 v-for 同时使用:
- 当需要条件渲染时,使用
<template>
包装 - 或者使用计算属性预先过滤数据
- 当需要条件渲染时,使用
性能优化:
- 对于大型列表,考虑使用虚拟滚动
- 使用
Object.freeze()
冻结不会改变的数据 - 合理使用计算属性缓存过滤和排序结果
数据结构设计:
- 确保每个列表项都有唯一标识符
- 避免深层嵌套的数据结构
用户体验:
- 为列表变化添加适当的过渡动画
- 在加载大量数据时显示加载状态
- 提供分页或无限滚动功能
下一步
现在你已经掌握了 Vue 的列表渲染,让我们继续学习:
列表渲染是构建动态界面的核心技能,结合条件渲染,你可以创建出功能丰富的用户界面!