過去六天,我們已經把飲料點單系統從 狀態控制 → 事件觸發 → 雙向綁定 → 狀態樣式 → 清單管理 → 差異比較 一步步完成。
雖然功能完整,但若專案規模再擴大,重複的程式碼與維護成本會迅速增加。
今天開始,我們正式進入 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的旅程搂