過去六天,我們已經把飲料點單系統從 狀態控制 → 事件觸發 → 雙向綁定 → 狀態樣式 → 清單管理 → 差異比較 一步步完成。
雖然功能完整,但若專案規模再擴大,重複的程式碼與維護成本會迅速增加。
今天開始,我們正式進入 Chapter 2:組件化。
在前導說明中,我們提過組件的好處:拆分、重用、維護。
現在就要把前面寫好的頁面拆解成多個 Vue component,讓程式結構更清晰、更易擴充。
真正的開發流程通常是 先有設計稿,再決定哪些區塊可以抽成 component。
例如:表單的每一組選項、訂單清單、統計表,其實都可以獨立成可重用的元件,避免到處複製貼上。
雖然我們code已經完成了,但是實際上還是要先從UI稿開始拆解compnent開始喔!!~
這邊我們就先假裝code還沒完成!! 看到UI稿之後,會怎麼去拆比較好來著手執行
很多人把元件想成一個獨立的器官/配件/單位
,但我更喜歡以下比喻:
眼鏡的例子
GlassComponent
)需要父層提供度數、顏色等屬性。西裝的例子
SuitComponent
,可以依照身材、顏色調整。這裡的「沒有意義」並不是說元件完全無用,而是 在需求思考與實際情境中,它必須被父層賦予屬性才有完整價值。
大概會像這樣來舉例圖案
大家在寫component的感覺更像這樣
今天的目標
把既有的點單程式拆解成組件(OrderForm
,OrderList
,OrderStats
與三個OptionGroup
),
讓專案更易維護,也為未來的 API 串接、狀態管理、路由拆頁 等進階功能打下基礎。
今天的需求分析角度會以前端工程師
的腳色來分析,不是以系統user來思考
在開始公布答案前大家可以複習一下我們UI從昨天的field來說還有什麼可以拆的
3.2.1....
公布答案
如果是我,我看到UI會這樣拆
大概會變成下面這張表:
模組 | 職責 | 輸入 (props) | 輸出 (emit/回傳) |
---|---|---|---|
App.vue | 頁面容器、狀態中心、彙整統計 | menus, options | submit、edit、remove |
OrderForm | 下單表單 | options(三組) | submit(payload)、update:modelValue(選項群組) |
OrderList | 訂單清單 | orders | edit(payload)、remove(index) |
OrderStats | 統計表 | orders/summary | - |
OptionGroup-* | 單一選項群組 | options、modelValue | update:modelValue |
App.vue是 vue的所有組件的程式進入點,所以我們把它當作最上層組件來使用。
其他的component就掛載近來
我們可以看到
這個系統在交互應用的時候怎麼樣的流程
也可以透過時序圖
幫我們整理
props是大家昨天就知道的東西
今天會學得什麼是事件接收 emit的功用
props
(單向資料流)、emit
(子→父回傳事件)v-model
的 modelValue
/update:modelValue
自定義reactive([])
管理 orders;computed
做統計v-for :key
v-if
、v-show
、:class
Object.assign(orders[i], payload)
、orders.splice()
上層組件App.vue
<script setup>
import { ref } from 'vue'
import ChildButton from './ChildButton.vue'
const count = ref(0)
// 接收子元件傳回的事件
function increase(amount) {
count.value += amount
}
</script>
<template>
<h2>emit 範例</h2>
<p>目前計數:{{ count }}</p>
<!-- 監聽子元件的自訂事件 add -->
<ChildButton @add="increase" />
</template>
我們可以在上層的組建中自己定義一個事件叫做add
然後後面的increase
function就是父組件執行的funrtcion
下層組件的ChildButton.vue
<script setup>
// 宣告一個自訂事件名稱 add
const emit = defineEmits(['add'])
function handleClick() {
// 傳遞一個 payload 給父元件
emit('add', 1)
}
</script>
<template>
<button @click="handleClick">+1</button>
</template>
透過綁定add這個事件我們可以呼叫emit('add')
這種方式來call funcion並且把參數這邊是1
帶進去給父組件
這樣就是簡單的傳遞組件的功效了
好了之後我們就可以把程式抽成這樣
1.App.vue
<script setup>
import { reactive, computed } from 'vue'
import OrderForm from './OrderForm.vue'
import OrderList from './OrderList.vue'
import OrderStats from './OrderStats.vue'
/** 訂單列表:Day5 完整流程 */
const orders = reactive([
{ name: 'alice', note: '', drink: '紅茶', sweetness: '正常甜', ice: '正常冰' },
{ name: 'roni', note: '主次', drink: '紅茶', sweetness: '去糖', ice: '去冰' },
{ name: 'corgi', note: '7F office', drink: '綠茶', sweetness: '正常甜', ice: '去冰' }
])
/** 統計 */
const summaryMap = computed(() => {
const m = new Map()
for (const o of orders) {
const k = `${o.drink}|${o.sweetness}|${o.ice}`
m.set(k, (m.get(k) || 0) + 1)
}
return m
})
const summaryRows = computed(() =>
Array.from(summaryMap.value.entries()).map(([key, count]) => {
const [d, s, i] = key.split('|')
return { key, drink: d, sweetness: s, ice: i, count }
})
)
/** 事件 */
function addOrder(order) {
orders.push(order)
}
function editOrder({ index, patch }) {
Object.assign(orders[index], patch)
}
function removeOrder(index) {
orders.splice(index, 1)
}
</script>
<template>
<main class="container">
<OrderForm @submit="addOrder" />
<OrderList :orders="orders" @edit="editOrder" @remove="removeOrder" />
<OrderStats :orders="orders" :summary="summaryRows" />
</main>
</template>
<style>
body { font-family: sans-serif; margin: 0; padding: 0; }
.container { max-width: 900px; margin: auto; padding: 16px; display: grid; gap: 20px; }
</style>
2.OrderForm.vue (下單表單)
<script setup>
import { ref, computed } from 'vue'
const emit = defineEmits(['submit'])
/* 表單欄位 */
const name = ref('')
const note = ref('')
const drink = ref('')
const sweetness = ref('')
const ice = ref('')
/* 狀態驗證 */
const hasDrink = computed(() => !!drink.value)
const hasSweetness = computed(() => !!sweetness.value)
const hasIce = computed(() => !!ice.value)
const canSubmit = computed(() => !!(name.value && hasDrink.value && hasSweetness.value && hasIce.value))
/* 事件 */
function addOrder() {
if (!canSubmit.value) return
emit('submit', {
name: name.value, note: note.value,
drink: drink.value, sweetness: sweetness.value, ice: ice.value
})
name.value = note.value = drink.value = sweetness.value = ice.value = ''
}
</script>
<template>
<section>
<h2>新增訂單</h2>
<div :class="['block', name ? 'complete' : 'invalid']">
<label>姓名(必填)
<input type="text" v-model.trim="name" />
</label>
<p v-if="!name" class="hint">尚未填寫姓名</p>
</div>
<div class="block">
<label>備註(選填)
<textarea v-model.trim="note"></textarea>
</label>
</div>
<fieldset :class="['block', hasDrink ? 'complete' : 'invalid']">
<legend>飲料</legend>
<label><input type="radio" value="紅茶" v-model="drink" /> 紅茶</label>
<label><input type="radio" value="綠茶" v-model="drink" /> 綠茶</label>
<p v-if="!hasDrink" class="hint">尚未選取飲料</p>
</fieldset>
<fieldset :class="['block', hasSweetness ? 'complete' : 'invalid']">
<legend>甜度</legend>
<label><input type="radio" value="正常甜" v-model="sweetness" /> 正常甜</label>
<label><input type="radio" value="去糖" v-model="sweetness" /> 去糖</label>
<p v-if="!hasSweetness" class="hint">尚未選擇甜度</p>
</fieldset>
<fieldset :class="['block', hasIce ? 'complete' : 'invalid']">
<legend>冰量</legend>
<label><input type="radio" value="正常冰" v-model="ice" /> 正常冰</label>
<label><input type="radio" value="去冰" v-model="ice" /> 去冰</label>
<p v-if="!hasIce" class="hint">尚未選擇冰量</p>
</fieldset>
<button :disabled="!canSubmit" :class="['submit', canSubmit ? 'enabled' : 'disabled']" @click="addOrder">
{{ canSubmit ? '送出' : '請完成所有必填' }}
</button>
</section>
</template>
<style scoped>
.block { padding:8px; border:1px solid #ddd; border-radius:8px; margin:10px 0; }
.invalid { border-color:#e57373; background:#fff5f5; }
.complete { border-color:#66bb6a; background:#f3fff3; }
.hint { font-size:12px; color:#c62828; margin-top:4px; }
.submit { padding:8px 12px; border-radius:6px; border:1px solid #ccc; cursor:pointer; }
.submit.enabled { background:#1976d2; color:#fff; border-color:#1976d2; }
.submit.disabled { background:#f0f0f0; color:#888; cursor:not-allowed; }
</style>
3.OrderList.vue (訂單清單:含就地編輯與刪除)
<script setup>
import { reactive, ref } from 'vue'
const props = defineProps({ orders: { type: Array, required: true } })
const emit = defineEmits(['edit', 'remove'])
const editIndex = ref(-1)
const editForm = reactive({ name:'', note:'', drink:'', sweetness:'', ice:'' })
function toggleEdit(i){
if(editIndex.value === i){ editIndex.value=-1; return }
editIndex.value = i
Object.assign(editForm, props.orders[i])
}
function applyEdit(){
emit('edit', { index: editIndex.value, patch: { ...editForm } })
editIndex.value = -1
}
function cancelEdit(){ editIndex.value = -1 }
function removeOrder(i){
emit('remove', i)
if(editIndex.value === i) editIndex.value = -1
}
</script>
<template>
<section v-if="props.orders.length">
<h2>訂單清單</h2>
<ul>
<li v-for="(o, i) in props.orders" :key="i" class="order">
<div class="row">
<div class="col">
<span class="idx">{{ i + 1 }}.</span>
<span class="name">{{ o.name }}</span>
<span class="pill">{{ o.drink }}</span>
<span class="pill" :class="o.ice==='去冰'?'is-noice':'is-ice'">{{ o.ice }}</span>
<span class="pill" :class="o.sweetness==='去糖'?'is-nosugar':'is-sugar'">{{ o.sweetness }}</span>
<span v-if="o.note" class="note">備註:{{ o.note }}</span>
</div>
<div class="actions">
<button class="btn btn-sm" @click="toggleEdit(i)">{{ editIndex===i?'收合':'編輯' }}</button>
<button class="btn btn-sm del" @click="removeOrder(i)">刪除</button>
</div>
</div>
<transition name="fade">
<div v-if="editIndex===i" class="edit-card">
<div class="edit-row">
<label>姓名:<input v-model.trim="editForm.name"/></label>
<label>備註:<input v-model.trim="editForm.note"/></label>
</div>
<div class="edit-grid">
<fieldset class="block">
<legend>飲料</legend>
<label><input type="radio" value="紅茶" v-model="editForm.drink"/> 紅茶</label>
<label><input type="radio" value="綠茶" v-model="editForm.drink"/> 綠茶</label>
</fieldset>
<fieldset class="block">
<legend>甜度</legend>
<label><input type="radio" value="正常甜" v-model="editForm.sweetness"/> 正常甜</label>
<label><input type="radio" value="去糖" v-model="editForm.sweetness"/> 去糖</label>
</fieldset>
<fieldset class="block">
<legend>冰量</legend>
<label><input type="radio" value="正常冰" v-model="editForm.ice"/> 正常冰</label>
<label><input type="radio" value="去冰" v-model="editForm.ice"/> 去冰</label>
</fieldset>
</div>
<div class="edit-actions">
<button class="btn btn-sm primary" @click="applyEdit">儲存</button>
<button class="btn btn-sm" @click="cancelEdit">取消</button>
</div>
</div>
</transition>
</li>
</ul>
</section>
</template>
<style scoped>
.order { border:1px solid #eee; border-radius:8px; padding:8px; margin:8px 0; background:#fff; }
.row { display:flex; justify-content:space-between; gap:8px; }
.col { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.idx { width:24px; color:#666; text-align:right; }
.name { font-weight:600; margin-right:6px; }
.pill { padding:2px 8px; border-radius:9999px; border:1px solid #ccc; font-size:12px; }
.is-ice { background:#e3f2fd; border-color:#90caf9; }
.is-noice { background:#e8f5e9; border-color:#a5d6a7; }
.is-sugar { background:#fff3e0; border-color:#ffcc80; }
.is-nosugar { background:#fce4ec; border-color:#f48fb1; }
.note { color:#555; font-size:12px; }
.actions { display:inline-flex; gap:6px; }
.btn { padding:4px 10px; border:1px solid #999; border-radius:6px; background:#fff; cursor:pointer; }
.btn-sm { padding:2px 8px; font-size:12px; }
.btn.primary { border-color:#1976d2; background:#1976d2; color:#fff; }
.btn.del { border-color:#e57373; color:#e57373; }
.edit-card { margin-top:8px; border:1px dashed #ddd; border-radius:8px; padding:8px; background:#fafafa; }
.edit-row { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; }
.edit-grid { display:grid; grid-template-columns:repeat(3,minmax(160px,1fr)); gap:8px; }
.edit-actions { display:inline-flex; gap:8px; }
.fade-enter-active,.fade-leave-active { transition:opacity .18s ease; }
.fade-enter-from,.fade-leave-to { opacity:0; }
</style>
4.OrderStats.vue (統計表)
<script setup>
const props = defineProps({
orders: { type: Array, required: true },
summary: { type: Array, required: true }
})
</script>
<template>
<section v-if="props.orders.length">
<h2>統計結果</h2>
<table class="table">
<thead>
<tr><th>飲料</th><th>甜度</th><th>冰量</th><th>數量</th></tr>
</thead>
<tbody>
<tr v-for="row in props.summary" :key="row.key">
<td>{{ row.drink }}</td>
<td>{{ row.sweetness }}</td>
<td>{{ row.ice }}</td>
<td class="qty">{{ row.count }}</td>
</tr>
</tbody>
<tfoot>
<tr><td colspan="3">總杯數</td><td class="qty">{{ props.orders.length }}</td></tr>
</tfoot>
</table>
</section>
</template>
<style scoped>
.table { border-collapse: collapse; width: 100%; }
.table th, .table td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
.table thead { background: #fafafa; }
.qty { text-align: right; font-variant-numeric: tabular-nums; }
</style>
整理起來大概就會是
submit
、edit
、remove
事件並更新狀態。orders
、summary
透過 props
傳給各子元件。props: menus / options
,並 emit submit(payload)
。v-model
或 update:modelValue
同步選項。props: orders
,並 emit edit(payload)
與 remove(index)
。editIndex
與 editForm
狀態,儲存或取消時發事件通知父層。props
。props: orders / summary
,僅負責資料統計顯示。thead / tbody / tfoot
)以利擴充。好啦~這樣就拆分完畢了~
明天我們就要展開API的旅程搂