Vue 列表渲染

列表渲染(List Rendering)是用來顯示陣列或物件資料的常見需求。Vue 提供了 v-for 指令來實現列表渲染。

v-for 基本用法

遍歷陣列

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

const items = ref(['Apple', 'Banana', 'Orange'])
</script>

<template>
  <ul>
    <li v-for="item in items">{{ item }}</li>
  </ul>
</template>

取得索引

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

const items = ref(['Apple', 'Banana', 'Orange'])
</script>

<template>
  <ul>
    <li v-for="(item, index) in items">
      {{ index + 1 }}. {{ item }}
    </li>
  </ul>
</template>

遍歷物件

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

const user = reactive({
  name: 'John',
  age: 30,
  email: 'john@example.com'
})
</script>

<template>
  <ul>
    <!-- 只取值 -->
    <li v-for="value in user">{{ value }}</li>
  </ul>

  <ul>
    <!-- 取值和鍵 -->
    <li v-for="(value, key) in user">
      {{ key }}: {{ value }}
    </li>
  </ul>

  <ul>
    <!-- 取值、鍵和索引 -->
    <li v-for="(value, key, index) in user">
      {{ index }}. {{ key }}: {{ value }}
    </li>
  </ul>
</template>

遍歷數字範圍

<template>
  <!-- 從 1 到 10 -->
  <span v-for="n in 10">{{ n }} </span>
  <!-- 輸出:1 2 3 4 5 6 7 8 9 10 -->
</template>

key 屬性的重要性

使用 v-for 時,應該總是提供一個唯一的 key 屬性:

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

const items = ref([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Orange' }
])
</script>

<template>
  <ul>
    <!-- ✅ 提供唯一的 key -->
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

為什麼需要 key?

key 幫助 Vue 識別哪些元素改變了、新增了或被移除了。沒有 key 時,Vue 使用「就地更新」策略,可能會導致:

  1. 狀態錯亂(特別是有表單輸入或元件狀態時)
  2. 動畫效果不正確
  3. 效能問題
<script setup>
import { ref } from 'vue'

const items = ref([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Orange' }
])

function shuffle() {
  items.value = items.value.sort(() => Math.random() - 0.5)
}

function addItem() {
  items.value.unshift({
    id: Date.now(),
    name: 'New Item'
  })
}
</script>

<template>
  <button @click="shuffle">打亂順序</button>
  <button @click="addItem">新增到開頭</button>

  <ul>
    <!-- 每個 item 有輸入框,沒有 key 會導致輸入內容錯亂 -->
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
      <input type="text" placeholder="備註">
    </li>
  </ul>
</template>

key 的選擇

<!-- ✅ 好:使用唯一 ID -->
<li v-for="item in items" :key="item.id">

<!-- ❌ 不好:使用索引(可能導致問題) -->
<li v-for="(item, index) in items" :key="index">

<!-- ❌ 不好:使用可能重複的值 -->
<li v-for="item in items" :key="item.name">
不要使用索引作為 key,除非列表是靜態的、永遠不會重新排序或過濾。

在 template 上使用 v-for

如果需要渲染多個元素但不想加額外的包裝元素,可以在 <template> 上使用 v-for

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

const items = ref([
  { id: 1, title: 'Title 1', content: 'Content 1' },
  { id: 2, title: 'Title 2', content: 'Content 2' }
])
</script>

<template>
  <dl>
    <template v-for="item in items" :key="item.id">
      <dt>{{ item.title }}</dt>
      <dd>{{ item.content }}</dd>
    </template>
  </dl>
</template>

在元件上使用 v-for

可以在自訂元件上使用 v-for

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

const todos = ref([
  { id: 1, text: '學習 Vue', done: true },
  { id: 2, text: '寫專案', done: false }
])
</script>

<template>
  <TodoItem
    v-for="todo in todos"
    :key="todo.id"
    :todo="todo"
    @toggle="todo.done = !todo.done"
    @remove="todos = todos.filter(t => t.id !== todo.id)"
  />
</template>

陣列變更偵測

Vue 可以偵測陣列的變更方法(mutation methods):

會觸發更新的方法

const items = ref([])

// 這些方法會修改原陣列,Vue 會偵測到變化
items.value.push('a')      // 新增到末尾
items.value.pop()          // 移除末尾
items.value.shift()        // 移除開頭
items.value.unshift('b')   // 新增到開頭
items.value.splice(0, 1)   // 刪除/替換
items.value.sort()         // 排序
items.value.reverse()      // 反轉

替換整個陣列

非變更方法(如 filtermapslice)會返回新陣列,需要替換原陣列:

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

// 過濾
items.value = items.value.filter(item => item > 2)

// 映射
items.value = items.value.map(item => item * 2)

// 合併
items.value = items.value.concat([6, 7, 8])

Vue 會智慧地重用 DOM 元素,所以替換陣列是高效的操作。

顯示過濾/排序後的結果

如果想顯示過濾或排序後的結果,但不想修改原始資料,使用 computed:

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

const numbers = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])

// 偶數
const evenNumbers = computed(() => {
  return numbers.value.filter(n => n % 2 === 0)
})

// 排序(降序)
const sortedNumbers = computed(() => {
  return [...numbers.value].sort((a, b) => b - a)
})
</script>

<template>
  <p>原始:{{ numbers }}</p>
  <p>偶數:{{ evenNumbers }}</p>
  <p>降序:{{ sortedNumbers }}</p>
</template>

巢狀 v-for 中使用 computed

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

const sets = ref([
  [1, 2, 3, 4, 5],
  [6, 7, 8, 9, 10]
])

// 在 v-for 中無法直接用 computed,可以用方法
function filterEven(numbers) {
  return numbers.filter(n => n % 2 === 0)
}
</script>

<template>
  <ul v-for="(numbers, index) in sets" :key="index">
    <li v-for="n in filterEven(numbers)" :key="n">
      {{ n }}
    </li>
  </ul>
</template>

實際範例

待辦事項列表

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

const newTodo = ref('')
const todos = ref([
  { id: 1, text: '學習 Vue', done: false },
  { id: 2, text: '寫專案', done: false },
  { id: 3, text: '部署上線', done: false }
])

const filter = ref('all')  // all, active, completed

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

const remaining = computed(() => {
  return todos.value.filter(t => !t.done).length
})

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

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}

function clearCompleted() {
  todos.value = todos.value.filter(t => !t.done)
}
</script>

<template>
  <div class="todo-app">
    <h1>待辦事項</h1>

    <!-- 新增 -->
    <form @submit.prevent="addTodo">
      <input v-model="newTodo" placeholder="輸入待辦事項">
      <button type="submit">新增</button>
    </form>

    <!-- 列表 -->
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id" :class="{ done: todo.done }">
        <input type="checkbox" v-model="todo.done">
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">刪除</button>
      </li>
    </ul>

    <!-- 篩選和統計 -->
    <div class="footer" v-show="todos.length">
      <span>{{ remaining }} 項未完成</span>
      <div class="filters">
        <button :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button>
        <button :class="{ active: filter === 'active' }" @click="filter = 'active'">未完成</button>
        <button :class="{ active: filter === 'completed' }" @click="filter = 'completed'">已完成</button>
      </div>
      <button @click="clearCompleted" v-show="todos.length > remaining">清除已完成</button>
    </div>
  </div>
</template>

<style scoped>
.done span {
  text-decoration: line-through;
  color: #999;
}

.filters button.active {
  font-weight: bold;
}
</style>

商品列表(含排序和篩選)

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

const products = ref([
  { id: 1, name: 'iPhone', price: 35900, category: 'electronics', inStock: true },
  { id: 2, name: 'MacBook', price: 52900, category: 'electronics', inStock: true },
  { id: 3, name: 'T-Shirt', price: 590, category: 'clothing', inStock: false },
  { id: 4, name: 'Jeans', price: 1290, category: 'clothing', inStock: true },
  { id: 5, name: 'Book', price: 350, category: 'books', inStock: true }
])

const searchQuery = ref('')
const selectedCategory = ref('')
const sortBy = ref('name')
const showInStockOnly = ref(false)

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

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

  // 搜尋
  if (searchQuery.value) {
    const query = searchQuery.value.toLowerCase()
    result = result.filter(p => p.name.toLowerCase().includes(query))
  }

  // 分類
  if (selectedCategory.value) {
    result = result.filter(p => p.category === selectedCategory.value)
  }

  // 只顯示有庫存
  if (showInStockOnly.value) {
    result = result.filter(p => p.inStock)
  }

  // 排序
  result = [...result].sort((a, b) => {
    if (sortBy.value === 'name') {
      return a.name.localeCompare(b.name)
    } else if (sortBy.value === 'price-asc') {
      return a.price - b.price
    } else if (sortBy.value === 'price-desc') {
      return b.price - a.price
    }
    return 0
  })

  return result
})
</script>

<template>
  <div class="product-list">
    <!-- 篩選控制 -->
    <div class="controls">
      <input v-model="searchQuery" placeholder="搜尋商品...">

      <select v-model="selectedCategory">
        <option value="">所有分類</option>
        <option v-for="cat in categories" :key="cat" :value="cat">
          {{ cat }}
        </option>
      </select>

      <select v-model="sortBy">
        <option value="name">依名稱排序</option>
        <option value="price-asc">價格:低到高</option>
        <option value="price-desc">價格:高到低</option>
      </select>

      <label>
        <input type="checkbox" v-model="showInStockOnly">
        只顯示有庫存
      </label>
    </div>

    <!-- 商品列表 -->
    <div class="products">
      <div
        v-for="product in filteredProducts"
        :key="product.id"
        class="product-card"
        :class="{ 'out-of-stock': !product.inStock }"
      >
        <h3>{{ product.name }}</h3>
        <p class="price">${{ product.price.toLocaleString() }}</p>
        <span class="category">{{ product.category }}</span>
        <span class="stock" :class="product.inStock ? 'in' : 'out'">
          {{ product.inStock ? '有庫存' : '無庫存' }}
        </span>
      </div>
    </div>

    <p v-if="filteredProducts.length === 0">
      沒有符合條件的商品
    </p>
  </div>
</template>

表格(可排序)

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

const users = ref([
  { id: 1, name: 'Alice', email: 'alice@example.com', age: 25 },
  { id: 2, name: 'Bob', email: 'bob@example.com', age: 30 },
  { id: 3, name: 'Charlie', email: 'charlie@example.com', age: 28 }
])

const sortKey = ref('name')
const sortOrder = ref('asc')

const sortedUsers = computed(() => {
  return [...users.value].sort((a, b) => {
    let result = 0
    if (sortKey.value === 'name') {
      result = a.name.localeCompare(b.name)
    } else if (sortKey.value === 'age') {
      result = a.age - b.age
    } else if (sortKey.value === 'email') {
      result = a.email.localeCompare(b.email)
    }
    return sortOrder.value === 'asc' ? result : -result
  })
})

function sort(key) {
  if (sortKey.value === key) {
    sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
  } else {
    sortKey.value = key
    sortOrder.value = 'asc'
  }
}

function getSortIcon(key) {
  if (sortKey.value !== key) return '↕️'
  return sortOrder.value === 'asc' ? '↑' : '↓'
}
</script>

<template>
  <table>
    <thead>
      <tr>
        <th @click="sort('name')" class="sortable">
          姓名 {{ getSortIcon('name') }}
        </th>
        <th @click="sort('email')" class="sortable">
          Email {{ getSortIcon('email') }}
        </th>
        <th @click="sort('age')" class="sortable">
          年齡 {{ getSortIcon('age') }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="user in sortedUsers" :key="user.id">
        <td>{{ user.name }}</td>
        <td>{{ user.email }}</td>
        <td>{{ user.age }}</td>
      </tr>
    </tbody>
  </table>
</template>

<style scoped>
table {
  width: 100%;
  border-collapse: collapse;
}

th, td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #ddd;
}

th.sortable {
  cursor: pointer;
  user-select: none;
}

th.sortable:hover {
  background-color: #f5f5f5;
}
</style>

巢狀列表(樹狀結構)

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

const tree = ref([
  {
    id: 1,
    name: '資料夾 1',
    children: [
      { id: 2, name: '檔案 1-1' },
      {
        id: 3,
        name: '資料夾 1-2',
        children: [
          { id: 4, name: '檔案 1-2-1' },
          { id: 5, name: '檔案 1-2-2' }
        ]
      }
    ]
  },
  {
    id: 6,
    name: '資料夾 2',
    children: [
      { id: 7, name: '檔案 2-1' }
    ]
  }
])
</script>

<!-- TreeItem.vue 元件(遞迴) -->
<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      <span>{{ item.name }}</span>
      <!-- 遞迴渲染子項目 -->
      <TreeItem v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

<script>
// 遞迴元件需要有名稱
export default {
  name: 'TreeItem'
}
</script>

使用 <script setup> 的寫法:

<!-- TreeItem.vue -->
<script setup>
defineProps({
  items: Array
})
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <span>{{ item.name }}</span>
      <TreeItem v-if="item.children" :items="item.children" />
    </li>
  </ul>
</template>

<script>
export default {
  name: 'TreeItem'
}
</script>