:::info
本文件連結:https://hackmd.io/1EDcX6WsQc2VIzs9zJ5Dtg
此為 2025 Vue3 前端新手營課程資料,相關資源僅授權給該課程學員。
:::
# 第三週:Vue 與元件概念,Props 與 Emits
## 課前說明
- 每日任務:Discord 上發布
- 每週挑戰:
- 一~三週:HackMD 上繳交
- RPG 程式勇者:
- 課程捷徑
- 課程講義
- 最後一週作業繳交
- 提醒老師錄影
### 額外課程介紹:
- JS 直播班:https://www.hexschool.com/courses/js-training.html
- JS&React 培訓班:https://www.hexschool.com/courses/js-react-training-2025.html
## 本課程架構

## **建立元件**
### 什麼是元件?

故事:漂亮阿姨電腦壞了,想請小明幫忙組一台新的
原價屋:https://www.coolpc.com.tw/evaluate.php
**對應 Vue 元件特性**
| 電腦硬體 | Vue 元件 |
|---------|---------|
| CPU、主機板、記憶體 | Button、Card、Modal 等元件 |
| 每個硬體有獨立功能 | 每個元件有獨立功能 |
| 硬體可以重複使用 | 元件可以重複使用 |
| 組裝成完整電腦 | 組合成完整網頁 |
**資料傳輸的概念相似**
```
電腦硬體傳輸:
鍵盤 → USB → 主機板 → CPU
螢幕 ← HDMI ← 顯卡 ← 主機板
Vue 元件傳輸:
子元件 → Props → 父元件
父元件 ← Emit ← 子元件
```
為什麼要使用元件?常見拆分原因:
- 程式碼太長
- 部分元件需要多次運用
- 邏輯拆分,不同的功能使用獨立的元件進行運作
- Vue Router 時,每個元件都是獨立頁面
### 元件建構起手式:
1. 建立新檔案
2. 加入 template,及基本 HTML 結構
3. import
4. 將它呈現在畫面上
**元件特性:**
1. 狀態獨立
2. 封裝
3. 可重複使用
4. Props
## 元件資料傳遞
- 元件向內傳遞
- 外到內:props
- 使用方法:defineProps
- 口訣:前內後外 - 前面是名稱,後面是值
注意:
- 單向數據流,內層不改外層資料
- HTML 屬性一律是小寫
- 該資料如果不存在,則可能會導致內層出錯

## 使用範例
## 卡片元件設計一:defineProps()
::: spoiler
### HomeView
```
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Card from '../components/Card.vue'
const pageTitle = '卡片標題'
</script>
<template>
<Card :title="pageTitle" />
</template>
```
### Card
```
<!-- Card.vue -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
})
</script>
<template>
<div style="border: 1px solid #ddd; padding: 8px; border-radius: 6px">
<h3>{{ props.title }}</h3>
</div>
</template>
```
:::

## 兩張卡片元件:用 compact 進行區隔
::: spoiler
### HomeView
```
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Card from '../components/Card.vue'
const title = '卡片 A'
const content = '這是內容 A'
</script>
<template>
<Card :title="title" :content="content" />
<Card :title="title" :content="content" compact />
<!-- 注意:presence 寫法 <Card compact /> 代表 true -->
</template>
```
### Card
```
<!-- Card.vue -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
content: { type: String, default: '' },
compact: { type: Boolean, default: false }, // 新增
})
</script>
<template>
<div class="card" :class="{ 'card--compact': props.compact }">
<h3 class="card__title">{{ props.title }}</h3>
<p v-if="props.content" class="card__content">{{ props.content }}</p>
</div>
</template>
<style scoped>
.card {
border: 1px solid #e5e7eb;
padding: 16px;
border-radius: 8px;
margin-bottom: 8px;
}
.card--compact {
padding: 6px;
font-size: 12px;
background-color: #f9fafb;
}
.card__title {
margin: 0 0 8px;
font-weight: 600;
font-size: 16px;
}
.card__content {
margin: 0;
color: #374151;
font-size: 14px;
}
.card--compact .card__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 500;
}
.card--compact .card__content {
font-size: 12px;
color: #6b7280;
}
</style>
```
:::

## emit
::: spoiler
### HomeView
```
<script setup>
import { ref } from 'vue'
import Card from '../components/Card.vue'
const title = ref('原始標題')
const applyText = (newText) => {
if (!newText) return
title.value = newText
}
</script>
<template>
<p>父層標題:{{ title }}</p>
<!-- 口訣:前內後外(前=emit-text,後=applyText) -->
<Card @emit-text="applyText" />
</template>
```
### Card
```
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['emit-text'])
const text = ref('')
const send = () => {
emit('emit-text', text.value) // 傳出字串參數
}
</script>
<template>
<div style="border: 1px solid #ddd; padding: 8px; border-radius: 6px">
<input v-model="text" placeholder="輸入新標題..." />
<button @click="send">套用到父層</button>
</div>
</template>
```
:::

## emit 事件從觸發到更新的 8 個步驟
1. **渲染階段**
父層渲染 `<Card @emit-text="applyText" />`,Vue 登記一個名為 `emit-text` 的監聽器,對應到父層方法 `applyText`。
2. **子層初始化**
子層建立 `emit = defineEmits(['emit-text'])`、`text`(`ref`),並定義 `send()`。
3. **使用者輸入**
使用者在子層 `<input v-model="text">` 打字,`text.value` 即時更新(反應式)。
4. **點擊觸發**
使用者點擊子層按鈕,觸發 `@click="send"`,進入 `send()`。
5. **子層發送事件(同步)**
`send()` 呼叫 `emit('emit-text', text.value)`:
- 事件名:`emit-text`
- 這是**同步**呼叫,立即把`text.value` 交給父層對應監聽器。
6. **父層接收與處理**
Vue 以 `newText = text.value` 呼叫 `applyText(newText)`;`applyText` 內更新 `title.value`。
7. **標記更新、批次渲染**
父層狀態變更後,Vue 標記要更新;**DOM 更新會在下一個 tick 批次套用**。
8. **畫面更新完成**
父層 `<p>{{ title }}</p>` 顯示新文字;若父層把 `title` 再傳回子層作為 prop,子層也會收到新值並重渲染。
## 購物車、推薦卡片整合
::: spoiler
### HomeView
```
<script setup>
import { ref, computed, onMounted } from 'vue'
import ProductCard from '../components/ProductCard.vue'
// 📦 響應式資料 - 一開始是空的
const products = ref([])
const recommendations = ref([])
// 🛒 購物車和收藏狀態
const cart = ref([])
const favorites = ref([])
// 📊 計算屬性
const cartCount = computed(() => cart.value.length)
const favoritesCount = computed(() => favorites.value.length)
const hasData = computed(() => products.value.length > 0 || recommendations.value.length > 0)
// 🎯 加入購物車
const handleAddToCart = (productId) => {
cart.value.push(productId)
console.log(`🛒 商品 ${productId} 已加入購物車`, cart.value)
alert(`商品已加入購物車!目前購物車有 ${cartCount.value} 個商品`)
}
// 收藏切換
const handleToggleFav = (productId) => {
const index = favorites.value.indexOf(productId)
let isNowFavorite
if (index > -1) {
// 如果已經收藏,就移除
favorites.value.splice(index, 1)
isNowFavorite = false
console.log(`💔 商品 ${productId} 已取消收藏`)
} else {
// 如果沒收藏,就加入
favorites.value.push(productId)
isNowFavorite = true
console.log(`❤️ 商品 ${productId} 已加入收藏`)
}
const message = isNowFavorite ? '已加入收藏' : '已取消收藏'
console.log(`商品 ${productId} ${message}`)
}
const isFavorite = (productId) => {
return favorites.value.includes(productId)
}
// 🚀 組件掛載時載入數據
onMounted(() => {
console.log('📱 組件已掛載,載入數據...')
// 模擬從外部載入商品數據
products.value = [
{ id: 1, name: 'iPhone 15 Pro', price: 36900 },
{ id: 2, name: 'MacBook Air M2', price: 37900 },
{ id: 3, name: 'iPad Pro 11"', price: 28900 },
]
// 模擬從外部載入推薦數據
recommendations.value = [
{ id: 4, name: 'AirPods Pro', price: 7490 },
{ id: 5, name: 'Apple Watch', price: 12900 },
{ id: 6, name: 'Magic Mouse', price: 2590 },
]
console.log('✅ 數據載入完成!', {
products: products.value,
recommendations: recommendations.value,
})
})
</script>
<template>
<div class="demo-container">
<h1>ProductCard 雙情境範例</h1>
<!-- ✅ 顯示商品內容 -->
<div v-if="hasData">
<!-- 情境 A:商品列表(正常版) -->
<section class="section">
<h2>🛍️ 情境 A:商品列表(正常版)</h2>
<p class="description">顯示完整功能:加入購物車 + 收藏按鈕</p>
<div v-if="products.length > 0" class="product-grid">
<ProductCard
v-for="p in products"
:key="p.id"
:id="p.id"
:name="p.name"
:price="p.price"
show-add
show-fav
:fav="isFavorite(p.id)"
@add-to-cart="handleAddToCart"
@toggle-fav="handleToggleFav"
/>
</div>
<div v-else class="empty-state">
<p>暫無商品資料</p>
</div>
<div class="code-example">
<h4>💻 使用範例:</h4>
<pre><code><ProductCard
:id="p.id" :name="p.name" :price="p.price"
show-add show-fav :fav="isFavorite(p.id)"
@add-to-cart="handleAddToCart"
@toggle-fav="handleToggleFav"
/></code></pre>
</div>
</section>
<!-- 情境 B:結帳推薦(迷你版) -->
<section class="section">
<h2>💳 情境 B:結帳推薦(迷你版)</h2>
<p class="description">緊湊版本:只顯示加入購物車,適合推薦區域</p>
<div v-if="recommendations.length > 0" class="recommendation-grid">
<ProductCard
v-for="r in recommendations"
:key="r.id"
:id="r.id"
:name="r.name"
:price="r.price"
variant="mini"
show-add
@add-to-cart="handleAddToCart"
/>
</div>
<div v-else class="empty-state">
<p>暫無推薦商品</p>
</div>
<div class="code-example">
<h4>💻 使用範例:</h4>
<pre><code><ProductCard
:id="r.id" :name="r.name" :price="r.price"
variant="mini" show-add
@add-to-cart="handleAddToCart"
/></code></pre>
</div>
</section>
<!-- 狀態顯示 -->
<section class="section">
<h2>📊 目前狀態</h2>
<div class="status">
<div class="status-item">
<strong>購物車:</strong>
<span
>{{ cartCount }} 個商品 {{ cartCount > 0 ? `(ID: ${cart.join(', ')})` : '' }}</span
>
</div>
<div class="status-item">
<strong>收藏:</strong>
<span
>{{ favoritesCount }} 個商品
{{ favoritesCount > 0 ? `(ID: ${favorites.join(', ')})` : '' }}</span
>
</div>
</div>
</section>
<!-- Props 對比表 -->
<section class="section">
<h2>⚙️ Props 配置對比</h2>
<table class="comparison-table">
<thead>
<tr>
<th>屬性</th>
<th>情境 A(正常版)</th>
<th>情境 B(迷你版)</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>variant</code></td>
<td>'default'(預設)</td>
<td>'mini'</td>
</tr>
<tr>
<td><code>showAdd</code></td>
<td>true</td>
<td>true</td>
</tr>
<tr>
<td><code>showFav</code></td>
<td>true</td>
<td>false(預設)</td>
</tr>
<tr>
<td><code>fav</code></td>
<td>動態綁定</td>
<td>false(預設)</td>
</tr>
</tbody>
</table>
</section>
</div>
<!-- 📝 空數據狀態 -->
<div v-else class="empty-container">
<h2>😶 暫無數據</h2>
<p>沒有可顯示的商品</p>
</div>
</div>
</template>
<style scoped>
.demo-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #1f2937;
margin-bottom: 30px;
}
.section {
margin-bottom: 40px;
padding: 20px;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
h2 {
color: #374151;
margin-bottom: 10px;
}
.description {
color: #6b7280;
margin-bottom: 20px;
font-style: italic;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.recommendation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.code-example {
background: #1f2937;
color: #f9fafb;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
}
.code-example h4 {
margin: 0 0 12px 0;
color: #60a5fa;
}
.code-example pre {
margin: 0;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 14px;
line-height: 1.5;
}
.status {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.status-item {
padding: 12px 16px;
background: white;
border-radius: 8px;
border: 1px solid #d1d5db;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.comparison-table th,
.comparison-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.comparison-table th {
background: #f3f4f6;
font-weight: 600;
color: #374151;
}
.comparison-table code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 13px;
}
/* 載入狀態樣式 */
.loading-section {
text-align: center;
padding: 40px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e2e8f0;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 錯誤狀態樣式 */
.error-section {
text-align: center;
padding: 40px;
background: #fef2f2;
border-radius: 12px;
border: 1px solid #fecaca;
color: #dc2626;
}
.retry-btn {
background: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
margin-top: 12px;
}
.retry-btn:hover {
background: #2563eb;
}
/* 空狀態樣式 */
.empty-state {
text-align: center;
padding: 20px;
color: #6b7280;
font-style: italic;
}
.empty-container {
text-align: center;
padding: 60px 20px;
color: #6b7280;
}
/* 生命週期說明樣式 */
.lifecycle-explanation {
display: flex;
flex-direction: column;
gap: 16px;
}
.step {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #3b82f6;
color: white;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
flex-shrink: 0;
}
.step-content {
flex: 1;
}
.step-content strong {
display: block;
margin-bottom: 4px;
color: #374151;
}
.step-content p {
margin: 0;
color: #6b7280;
font-size: 14px;
line-height: 1.5;
}
</style>
```
### Card
```
<!-- ProductCard.vue -->
<script setup>
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, required: true },
price: { type: Number, required: true },
variant: { type: String, default: 'default' }, // 'default' | 'mini'
showAdd: { type: Boolean, default: true },
showFav: { type: Boolean, default: false },
fav: { type: Boolean, default: false },
})
const emit = defineEmits(['add-to-cart', 'toggle-fav'])
const add = () => emit('add-to-cart', props.id)
const toggleFav = () => emit('toggle-fav', props.id)
</script>
<template>
<div :class="['card', props.variant === 'mini' && 'card--mini']">
<div class="title">{{ props.name }}</div>
<div class="price">NT$ {{ props.price.toLocaleString() }}</div>
<div class="actions">
<button v-if="props.showAdd" @click="add" class="btn btn--primary">
{{ props.variant === 'mini' ? '加入' : '加入購物車' }}
</button>
<button v-if="props.showFav" @click="toggleFav" class="btn btn--secondary">
{{ props.fav ? '★ 已收藏' : '☆ 收藏' }}
</button>
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #eee;
padding: 12px;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.card--mini {
padding: 8px;
font-size: 14px;
}
.title {
font-weight: 600;
font-size: 16px;
margin-bottom: 4px;
}
.card--mini .title {
font-size: 14px;
}
.price {
opacity: 0.8;
margin-top: 4px;
font-weight: 500;
color: #059669;
}
.actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.card--mini .actions {
margin-top: 6px;
gap: 6px;
}
.btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
}
.card--mini .btn {
padding: 4px 8px;
font-size: 11px;
}
.btn--primary {
background: #3b82f6;
color: white;
}
.btn--primary:hover {
background: #2563eb;
}
.btn--secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn--secondary:hover {
background: #e5e7eb;
}
</style>
```
:::

1. 同一組件,多種用途
ProductCard 組件透過不同的 props 配置,實現商品列表和推薦區域兩種不同的展示效果
2. Props 控制顯示
使用 variant、showAdd、showFav 等屬性來控制組件的外觀和功能
3. 事件統一處理
兩種情境都使用相同的事件處理函數,維持代碼的一致性和可維護性
4. 響應式狀態管理
購物車和收藏狀態在父組件中統一管理,自動同步到子組件
### **元件建構基本原則:**
1. 不要建構元件 → 先把功能寫出來再說
2. 不要把元件拆太細 → 等到程式碼太常再說
## 作業
【題目】: 餐點管理工具
作業使用者故事:
1. 點餐人員可以將左側的品項加入購物車
2. 點餐人員,可以刪除、調整購物車品項數量(Level 1, 2 可不做)
3. 點餐人員可加入備註
4. 點餐人員可以建立訂單
LV:
1. LV1. 僅將以上功能完成,不拆分成元件(可以**省略:刪除、調整數量的功能**)
2. LV2. 包含以上功能,至少額外拆分兩個元件
3. LV3. **不參考解答**製作菜單工具,不省略任何功能且至少包含額外三個元件(重複品項無法重複加入)
解答連結:
- 執行範例:https://www.casper.tw/2024-vue-homework/#/week3
- 原始碼:https://github.com/Wcc723/2024-vue-homework/blob/main/src/views/Week3View.vue
- 作業繳交連結:https://hackmd.io/nX7LspikSmmglEb1Hyu2IQ
### 預告
最終任務:
- 設計稿:https://www.figma.com/design/MFSk8P5jmmC2ns9V9YeCzM/TodoList?node-id=0-1&t=KzEgeE3s3r6JIPh6-1
- CSS 範例:https://codepen.io/hexschool/pen/qBzEMdm
- 同時挑戰證書任務:
- [最終挑戰 - Todolist 新手證書任務](https://rpg.hexschool.com/#/training/12062817613334763749/board/content/12062817613334763750_12062817613334763761?tid=12062817613343793193)
- [最終挑戰 - Todolist API 整合證書任務](https://rpg.hexschool.com/#/training/12062817613334763749/board/content/12062817613334763750_12062817613334763761?tid=12062817613343793192)
## 難度一:所有內容都寫在同個 .vue 檔,不用 props、emit

## 難度二:列表會重複利用

## 難度三:搜尋框、Tab、列表在其它地方也會需要用到

### 元件轉換原則
1. 先不要想著拆元件
2. 先不要急著拆元件
3. 試著拆元件,並確保功能與原本一致
- 下到上:$emit(托球)
- 口訣:emit 推,外 methods 接收
- 口訣:HTML 前內,後外