:::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 ## 本課程架構 ![截圖 2025-08-12 中午12.28.57](https://hackmd.io/_uploads/BJkk4Sddge.png) ## **建立元件** ### 什麼是元件? ![截圖 2025-08-12 中午12.41.15](https://hackmd.io/_uploads/r18MIHOdeg.png) 故事:漂亮阿姨電腦壞了,想請小明幫忙組一台新的 原價屋: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 屬性一律是小寫 - 該資料如果不存在,則可能會導致內層出錯 ![空白](https://hackmd.io/_uploads/HJhyTX5q0.png) ## 使用範例 ## 卡片元件設計一: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> ``` ::: ![截圖 2025-08-13 中午12.03.02](https://hackmd.io/_uploads/B1It0Ktdgg.png) ## 兩張卡片元件:用 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> ``` ::: ![截圖 2025-08-13 中午12.07.59](https://hackmd.io/_uploads/ryDnk5tOee.png) ## 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> ``` ::: ![截圖 2025-08-13 中午12.39.30](https://hackmd.io/_uploads/ryT9v9K_gg.png) ## 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>&lt;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" /&gt;</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>&lt;ProductCard :id="r.id" :name="r.name" :price="r.price" variant="mini" show-add @add-to-cart="handleAddToCart" /&gt;</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> ``` ::: ![截圖 2025-08-13 下午1.57.22](https://hackmd.io/_uploads/SkMwFjFOle.png) 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 ![截圖 2025-08-13 下午5.03.09](https://hackmd.io/_uploads/HJHqSCKuee.png) ## 難度二:列表會重複利用 ![截圖 2025-08-13 下午5.05.41](https://hackmd.io/_uploads/SJg3B0Y_lx.png) ## 難度三:搜尋框、Tab、列表在其它地方也會需要用到 ![截圖 2025-08-13 下午4.57.40](https://hackmd.io/_uploads/HkKZ8Atuex.png) ### 元件轉換原則 1. 先不要想著拆元件 2. 先不要急著拆元件 3. 試著拆元件,並確保功能與原本一致 - 下到上:$emit(托球) - 口訣:emit 推,外 methods 接收 - 口訣:HTML 前內,後外