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 使用「就地更新」策略,可能會導致:
- 狀態錯亂(特別是有表單輸入或元件狀態時)
- 動畫效果不正確
- 效能問題
<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() // 反轉
替換整個陣列
非變更方法(如 filter、map、slice)會返回新陣列,需要替換原陣列:
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>