Skip to content

列表渲染

在 Vue 中,我们可以使用 v-for 指令来渲染列表。v-for 指令需要使用 item in items 形式的特殊语法,其中 items 是源数据数组,而 item 则是被迭代的数组元素的别名。

基本列表渲染

渲染数组

vue
<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 也支持一个可选的第二个参数,即当前项的索引:

vue
<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 来遍历一个对象的所有属性:

vue
<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> 元素来循环渲染一段包含多个元素的内容:

vue
<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 里的变量:

vue
<!-- 这会抛出一个错误,因为属性 todo 此时没有在该实例上定义 -->
<li v-for="todo in todos" v-if="!todo.isComplete" :key="todo.id">
  {{ todo.name }}
</li>

可以把 v-for 移动到 <template> 标签中来修正:

vue
<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:

vue
<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()
vue
<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()。它们不会变更原始数组,而总是返回一个新数组。当使用非变更方法时,可以用新数组替换旧数组:

vue
<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>

显示过滤或排序后的结果

有时,我们希望显示数组经过过滤或排序后的版本,而不实际变更或重置原始数据。在这种情况下,可以创建一个计算属性,来返回过滤或排序后的数组。

vue
<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 的取值范围重复多次。

vue
<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>

最佳实践

  1. 始终使用 key

    • 为每个 v-for 项提供唯一的 key
    • 避免使用索引作为 key,除非列表是静态的
  2. 避免 v-if 和 v-for 同时使用

    • 当需要条件渲染时,使用 <template> 包装
    • 或者使用计算属性预先过滤数据
  3. 性能优化

    • 对于大型列表,考虑使用虚拟滚动
    • 使用 Object.freeze() 冻结不会改变的数据
    • 合理使用计算属性缓存过滤和排序结果
  4. 数据结构设计

    • 确保每个列表项都有唯一标识符
    • 避免深层嵌套的数据结构
  5. 用户体验

    • 为列表变化添加适当的过渡动画
    • 在加载大量数据时显示加载状态
    • 提供分页或无限滚动功能

下一步

现在你已经掌握了 Vue 的列表渲染,让我们继续学习:

列表渲染是构建动态界面的核心技能,结合条件渲染,你可以创建出功能丰富的用户界面!

基于 Vue.js 官方文档构建的学习宝典