Skip to content

列表渲染

Vue 提供了 v-for 指令来渲染列表数据。本文将详细介绍如何使用 v-for 指令以及相关的最佳实践。

基本用法

渲染数组

使用 v-for 指令可以基于一个数组来渲染一个列表:

vue
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.message }}
    </li>
  </ul>
</template>

<script setup>
import { ref } from 'vue'

const items = ref([
  { id: 1, message: 'Foo' },
  { id: 2, message: 'Bar' },
  { id: 3, message: 'Baz' }
])
</script>

访问索引

v-for 也支持可选的第二个参数表示当前项的索引:

vue
<template>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      {{ index }} - {{ item.message }}
    </li>
  </ul>
</template>

渲染对象

你也可以用 v-for 来遍历一个对象的所有属性:

vue
<template>
  <ul>
    <li v-for="value in myObject" :key="value">
      {{ value }}
    </li>
  </ul>
</template>

<script setup>
import { reactive } from 'vue'

const myObject = reactive({
  title: 'How to do lists in Vue',
  author: 'Jane Doe',
  publishedAt: '2016-04-10'
})
</script>

对象的键名和索引

在遍历对象时,你也可以提供第二个参数为键名,第三个参数为索引:

vue
<template>
  <ul>
    <li v-for="(value, key, index) in myObject" :key="key">
      {{ index }}. {{ key }}: {{ value }}
    </li>
  </ul>
</template>

key 属性

为什么需要 key

当 Vue 正在更新使用 v-for 渲染的元素列表时,它默认使用"就地更新"的策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序,而是就地更新每个元素。

为了给 Vue 一个提示,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素,你需要为每项提供一个唯一的 key 属性:

vue
<template>
  <div v-for="item in items" :key="item.id">
    <!-- 内容 -->
  </div>
</template>

key 的选择

  • 推荐:使用唯一且稳定的值作为 key,如 item.id
  • 不推荐:使用索引作为 key(除非列表是静态的)
vue
<!-- 好的做法 -->
<li v-for="todo in todos" :key="todo.id">
  {{ todo.text }}
</li>

<!-- 不推荐的做法 -->
<li v-for="(todo, index) in todos" :key="index">
  {{ todo.text }}
</li>

数组变更检测

变更方法

Vue 能够侦听响应式数组的变更方法,并在它们被调用时触发相关的更新。这些变更方法包括:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()
vue
<template>
  <div>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.text }}
      </li>
    </ul>
    
    <button @click="addItem">添加项目</button>
    <button @click="removeItem">删除项目</button>
    <button @click="sortItems">排序</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref([
  { id: 1, text: '学习 Vue' },
  { id: 2, text: '整个牛项目' },
  { id: 3, text: '征服世界' }
])

const addItem = () => {
  const newId = items.value.length + 1
  items.value.push({
    id: newId,
    text: `新项目 ${newId}`
  })
}

const removeItem = () => {
  items.value.pop()
}

const sortItems = () => {
  items.value.sort((a, b) => a.text.localeCompare(b.text))
}
</script>

替换数组

变更方法,顾名思义,会变更调用了这些方法的原始数组。相比之下,也有非变更方法,例如 filter()concat()slice()。它们不会变更原始数组,而总是返回一个新数组。当使用非变更方法时,可以用新数组替换旧数组:

vue
<script setup>
import { ref } from 'vue'

const items = ref([1, 2, 3, 4, 5])

// 这会触发更新
items.value = items.value.filter(item => item % 2 === 0)
</script>

显示过滤/排序后的结果

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

vue
<template>
  <div>
    <input v-model="searchText" placeholder="搜索..." />
    
    <ul>
      <li v-for="item in filteredItems" :key="item.id">
        {{ item.name }} - {{ item.category }}
      </li>
    </ul>
    
    <button @click="sortBy = 'name'">按名称排序</button>
    <button @click="sortBy = 'category'">按分类排序</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const searchText = ref('')
const sortBy = ref('name')

const items = ref([
  { id: 1, name: 'Apple', category: 'Fruit' },
  { id: 2, name: 'Carrot', category: 'Vegetable' },
  { id: 3, name: 'Banana', category: 'Fruit' },
  { id: 4, name: 'Broccoli', category: 'Vegetable' }
])

const filteredItems = computed(() => {
  let result = items.value

  // 过滤
  if (searchText.value) {
    result = result.filter(item =>
      item.name.toLowerCase().includes(searchText.value.toLowerCase())
    )
  }

  // 排序
  result = result.slice().sort((a, b) => {
    return a[sortBy.value].localeCompare(b[sortBy.value])
  })

  return result
})
</script>

<template> 上使用 v-for

类似于 v-if,你也可以利用 <template> 来循环渲染一段包含多个元素的内容:

vue
<template>
  <ul>
    <template v-for="item in items" :key="item.id">
      <li>{{ item.msg }}</li>
      <li class="divider" role="presentation"></li>
    </template>
  </ul>
</template>

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>

组件上使用 v-for

在自定义组件上,你可以像在任何普通元素上一样使用 v-for

vue
<template>
  <div>
    <MyComponent
      v-for="item in items"
      :item="item"
      :key="item.id"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'

const items = ref([
  { id: 1, title: 'Hello world' },
  { id: 2, title: 'Goodbye world' }
])
</script>

子组件示例

vue
<!-- MyComponent.vue -->
<template>
  <div class="item">
    <h3>{{ item.title }}</h3>
    <p>ID: {{ item.id }}</p>
  </div>
</template>

<script setup>
defineProps({
  item: {
    type: Object,
    required: true
  }
})
</script>

<style scoped>
.item {
  border: 1px solid #ccc;
  padding: 10px;
  margin: 5px 0;
  border-radius: 4px;
}
</style>

实际应用示例

待办事项列表

vue
<template>
  <div class="todo-app">
    <h1>待办事项</h1>
    
    <form @submit.prevent="addTodo">
      <input
        v-model="newTodo"
        placeholder="添加新的待办事项..."
        required
      />
      <button type="submit">添加</button>
    </form>
    
    <div class="filters">
      <button
        v-for="filter in filters"
        :key="filter.key"
        :class="{ active: currentFilter === filter.key }"
        @click="currentFilter = filter.key"
      >
        {{ filter.label }}
      </button>
    </div>
    
    <ul class="todo-list">
      <li
        v-for="todo in filteredTodos"
        :key="todo.id"
        :class="{ completed: todo.completed }"
      >
        <input
          type="checkbox"
          v-model="todo.completed"
        />
        <span class="todo-text">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)" class="delete-btn">
          删除
        </button>
      </li>
    </ul>
    
    <p class="stats">
      剩余 {{ remainingCount }} 项,共 {{ todos.length }} 项
    </p>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const newTodo = ref('')
const currentFilter = ref('all')

const todos = ref([
  { id: 1, text: '学习 Vue 3', completed: false },
  { id: 2, text: '构建待办应用', completed: true },
  { id: 3, text: '部署到生产环境', completed: false }
])

const filters = [
  { key: 'all', label: '全部' },
  { key: 'active', label: '未完成' },
  { key: 'completed', label: '已完成' }
]

const filteredTodos = computed(() => {
  switch (currentFilter.value) {
    case 'active':
      return todos.value.filter(todo => !todo.completed)
    case 'completed':
      return todos.value.filter(todo => todo.completed)
    default:
      return todos.value
  }
})

const remainingCount = computed(() => {
  return todos.value.filter(todo => !todo.completed).length
})

const addTodo = () => {
  if (newTodo.value.trim()) {
    todos.value.push({
      id: Date.now(),
      text: newTodo.value.trim(),
      completed: false
    })
    newTodo.value = ''
  }
}

const removeTodo = (id) => {
  const index = todos.value.findIndex(todo => todo.id === id)
  if (index > -1) {
    todos.value.splice(index, 1)
  }
}
</script>

<style scoped>
.todo-app {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.todo-app h1 {
  text-align: center;
  color: #333;
}

form {
  display: flex;
  margin-bottom: 20px;
}

form input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px 0 0 4px;
}

form button {
  padding: 10px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 0 4px 4px 0;
  cursor: pointer;
}

.filters {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.filters button {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background-color: white;
  border-radius: 4px;
  cursor: pointer;
}

.filters button.active {
  background-color: #007bff;
  color: white;
}

.todo-list {
  list-style: none;
  padding: 0;
}

.todo-list li {
  display: flex;
  align-items: center;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.todo-list li.completed .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-text {
  flex: 1;
  margin-left: 10px;
}

.delete-btn {
  background-color: #dc3545;
  color: white;
  border: none;
  padding: 5px 10px;
  border-radius: 4px;
  cursor: pointer;
}

.stats {
  text-align: center;
  color: #666;
  margin-top: 20px;
}
</style>

商品列表

vue
<template>
  <div class="product-list">
    <h2>商品列表</h2>
    
    <div class="controls">
      <input
        v-model="searchQuery"
        placeholder="搜索商品..."
        class="search-input"
      />
      
      <select v-model="sortBy" class="sort-select">
        <option value="name">按名称排序</option>
        <option value="price">按价格排序</option>
        <option value="rating">按评分排序</option>
      </select>
      
      <select v-model="filterCategory" class="filter-select">
        <option value="">所有分类</option>
        <option
          v-for="category in categories"
          :key="category"
          :value="category"
        >
          {{ category }}
        </option>
      </select>
    </div>
    
    <div class="products-grid">
      <div
        v-for="product in filteredProducts"
        :key="product.id"
        class="product-card"
      >
        <img :src="product.image" :alt="product.name" />
        <h3>{{ product.name }}</h3>
        <p class="category">{{ product.category }}</p>
        <div class="rating">
          <span
            v-for="star in 5"
            :key="star"
            class="star"
            :class="{ filled: star <= product.rating }"
          >

          </span>
          <span class="rating-text">({{ product.rating }})</span>
        </div>
        <p class="price">¥{{ product.price }}</p>
        <button @click="addToCart(product)" class="add-to-cart">
          加入购物车
        </button>
      </div>
    </div>
    
    <div v-if="filteredProducts.length === 0" class="no-results">
      没有找到匹配的商品
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const searchQuery = ref('')
const sortBy = ref('name')
const filterCategory = ref('')

const products = ref([
  {
    id: 1,
    name: 'iPhone 14',
    category: '手机',
    price: 5999,
    rating: 4.5,
    image: 'https://via.placeholder.com/200x200'
  },
  {
    id: 2,
    name: 'MacBook Pro',
    category: '电脑',
    price: 12999,
    rating: 4.8,
    image: 'https://via.placeholder.com/200x200'
  },
  {
    id: 3,
    name: 'AirPods Pro',
    category: '耳机',
    price: 1999,
    rating: 4.3,
    image: 'https://via.placeholder.com/200x200'
  },
  {
    id: 4,
    name: 'iPad Air',
    category: '平板',
    price: 4399,
    rating: 4.6,
    image: 'https://via.placeholder.com/200x200'
  }
])

const categories = computed(() => {
  return [...new Set(products.value.map(p => p.category))]
})

const filteredProducts = computed(() => {
  let result = products.value

  // 搜索过滤
  if (searchQuery.value) {
    result = result.filter(product =>
      product.name.toLowerCase().includes(searchQuery.value.toLowerCase())
    )
  }

  // 分类过滤
  if (filterCategory.value) {
    result = result.filter(product => product.category === filterCategory.value)
  }

  // 排序
  result = result.slice().sort((a, b) => {
    switch (sortBy.value) {
      case 'price':
        return a.price - b.price
      case 'rating':
        return b.rating - a.rating
      default:
        return a.name.localeCompare(b.name)
    }
  })

  return result
})

const addToCart = (product) => {
  console.log('添加到购物车:', product.name)
  // 这里可以添加购物车逻辑
}
</script>

<style scoped>
.product-list {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.controls {
  display: flex;
  gap: 15px;
  margin-bottom: 30px;
  flex-wrap: wrap;
}

.search-input,
.sort-select,
.filter-select {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.search-input {
  flex: 1;
  min-width: 200px;
}

.products-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

.product-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 15px;
  text-align: center;
  transition: transform 0.2s, box-shadow 0.2s;
}

.product-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.product-card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 4px;
  margin-bottom: 10px;
}

.product-card h3 {
  margin: 10px 0;
  color: #333;
}

.category {
  color: #666;
  font-size: 14px;
  margin: 5px 0;
}

.rating {
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 10px 0;
}

.star {
  color: #ddd;
  font-size: 18px;
}

.star.filled {
  color: #ffd700;
}

.rating-text {
  margin-left: 5px;
  color: #666;
  font-size: 14px;
}

.price {
  font-size: 18px;
  font-weight: bold;
  color: #e74c3c;
  margin: 10px 0;
}

.add-to-cart {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.2s;
}

.add-to-cart:hover {
  background-color: #0056b3;
}

.no-results {
  text-align: center;
  color: #666;
  font-size: 18px;
  margin-top: 50px;
}
</style>

性能优化

1. 使用正确的 key

vue
<!-- 好的做法:使用唯一标识符 -->
<div v-for="user in users" :key="user.id">
  {{ user.name }}
</div>

<!-- 避免:使用索引作为 key(当列表会变化时) -->
<div v-for="(user, index) in users" :key="index">
  {{ user.name }}
</div>

2. 避免在模板中进行复杂计算

vue
<!-- 不推荐:在模板中进行复杂计算 -->
<li v-for="item in items.filter(i => i.active).sort((a, b) => a.name.localeCompare(b.name))" :key="item.id">
  {{ item.name }}
</li>

<!-- 推荐:使用计算属性 -->
<li v-for="item in sortedActiveItems" :key="item.id">
  {{ item.name }}
</li>

<script setup>
import { computed } from 'vue'

const sortedActiveItems = computed(() => {
  return items.value
    .filter(item => item.active)
    .sort((a, b) => a.name.localeCompare(b.name))
})
</script>

3. 虚拟滚动

对于大量数据的列表,考虑使用虚拟滚动:

vue
<template>
  <div class="virtual-list" @scroll="handleScroll" ref="container">
    <div :style="{ height: totalHeight + 'px' }" class="virtual-list-phantom"></div>
    <div class="virtual-list-content" :style="{ transform: `translateY(${startOffset}px)` }">
      <div
        v-for="item in visibleData"
        :key="item.id"
        class="virtual-list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.text }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'

const props = defineProps({
  items: Array,
  itemHeight: {
    type: Number,
    default: 50
  },
  containerHeight: {
    type: Number,
    default: 300
  }
})

const container = ref(null)
const scrollTop = ref(0)

const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight))
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))
const startOffset = computed(() => startIndex.value * props.itemHeight)

const visibleData = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value)
})

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}

onMounted(() => {
  container.value.style.height = props.containerHeight + 'px'
})
</script>

<style scoped>
.virtual-list {
  overflow-y: auto;
  position: relative;
}

.virtual-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.virtual-list-content {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
}

.virtual-list-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
  display: flex;
  align-items: center;
}
</style>

选项式 API

在选项式 API 中,列表渲染的用法基本相同:

vue
<template>
  <ul>
    <li v-for="item in filteredItems" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, name: 'Apple', category: 'fruit' },
        { id: 2, name: 'Carrot', category: 'vegetable' }
      ],
      filter: 'all'
    }
  },
  computed: {
    filteredItems() {
      if (this.filter === 'all') {
        return this.items
      }
      return this.items.filter(item => item.category === this.filter)
    }
  },
  methods: {
    addItem(item) {
      this.items.push(item)
    },
    removeItem(id) {
      const index = this.items.findIndex(item => item.id === id)
      if (index > -1) {
        this.items.splice(index, 1)
      }
    }
  }
}
</script>

最佳实践

1. 总是使用 key

vue
<!-- 好 -->
<div v-for="item in items" :key="item.id">
  {{ item.name }}
</div>

<!-- 不好 -->
<div v-for="item in items">
  {{ item.name }}
</div>

2. 避免 v-if 和 v-for 在同一元素上

vue
<!-- 不推荐 -->
<li v-for="user in users" v-if="user.isActive" :key="user.id">
  {{ user.name }}
</li>

<!-- 推荐 -->
<template v-for="user in users" :key="user.id">
  <li v-if="user.isActive">
    {{ user.name }}
  </li>
</template>

<!-- 或者使用计算属性 -->
<li v-for="user in activeUsers" :key="user.id">
  {{ user.name }}
</li>

3. 使用计算属性进行复杂的列表操作

vue
<script setup>
import { computed } from 'vue'

const processedItems = computed(() => {
  return items.value
    .filter(item => item.visible)
    .sort((a, b) => a.order - b.order)
    .map(item => ({
      ...item,
      displayName: `${item.name} (${item.category})`
    }))
})
</script>

4. 合理使用 v-memo

对于大列表中的复杂项目,可以使用 v-memo 来优化性能:

vue
<template>
  <div
    v-for="item in list"
    :key="item.id"
    v-memo="[item.id, item.selected]"
  >
    <p>ID: {{ item.id }}</p>
    <p>Selected: {{ item.selected }}</p>
    <p>...more info</p>
  </div>
</template>

下一步

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