iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 9

Day 7 : 如何把複雜的咒語變簡單:組件化設計

  • 分享至 

  • xImage
  •  

前言|延續 Day1 ~ Day6 的成果,走向「組件化」

過去六天,我們已經把飲料點單系統從 狀態控制 → 事件觸發 → 雙向綁定 → 狀態樣式 → 清單管理 → 差異比較 一步步完成。

雖然功能完整,但若專案規模再擴大,重複的程式碼與維護成本會迅速增加。

今天開始,我們正式進入 Chapter 2:組件化

在前導說明中,我們提過組件的好處:拆分、重用、維護

現在就要把前面寫好的頁面拆解成多個 Vue component,讓程式結構更清晰、更易擴充。

https://ithelp.ithome.com.tw/upload/images/20250923/20121052MEgoDysJb2.png


1. 先從 UI 設計稿開始想

真正的開發流程通常是 先有設計稿,再決定哪些區塊可以抽成 component。

例如:表單的每一組選項、訂單清單、統計表,其實都可以獨立成可重用的元件,避免到處複製貼上。

雖然我們code已經完成了,但是實際上還是要先從UI稿開始拆解compnent開始喔!!~

這邊我們就先假裝code還沒完成!! 看到UI稿之後,會怎麼去拆比較好來著手執行


2. Component 的概念:更像「器官/配件或單位」而不是「像父子關係」

很多人把元件想成一個獨立的器官/配件/單位,但我更喜歡以下比喻:

  • 眼鏡的例子

    • 眼鏡元件(GlassComponent)需要父層提供度數、顏色等屬性。
    • 只有「戴」在使用者身上(父組件)時,這些屬性才有意義。
    • 即使你把眼鏡拿去眼鏡店展示,也需要店長(另一個上層組件裝上它)給它展示用的度數與顏色。
  • 西裝的例子

    • 你做了一件 SuitComponent,可以依照身材、顏色調整。
    • 穿在不同人身上(不同父層)會呈現不同樣貌;脫離使用情境,它的「價值」就減少。

這裡的「沒有意義」並不是說元件完全無用,而是 在需求思考與實際情境中,它必須被父層賦予屬性才有完整價值

大概會像這樣來舉例圖案

大家在寫component的感覺更像這樣/images/emoticon/emoticon39.gif

https://ithelp.ithome.com.tw/upload/images/20250923/20121052lb3Syw4CQP.png


3. 與 class 的差異

  • class 具有繼承特性,父層可直接傳遞屬性與方法。
  • component 若沒有 props 或 import,不會自動繼承父組件的資料與邏輯
    必須透過 props / emit / provide-inject 來建立溝通。

今天的目標
把既有的點單程式拆解成組件(OrderForm, OrderList, OrderStats 與三個 OptionGroup),
讓專案更易維護,也為未來的 API 串接、狀態管理、路由拆頁 等進階功能打下基礎。

一、需求分析

1. 需求拆解&需求表格

今天的需求分析角度會以前端工程師的腳色來分析,不是以系統user來思考

  • 作為「下單者」,我需要一個表單能選「飲料/甜度/冰量」並送出訂單。
  • 作為「管理者」,我需要看到訂單清單、能編輯或刪除指定訂單。
  • 作為「統計者」,我需要統計表(飲料 × 甜度 × 冰量)自動更新。
  • 作為「前端工程師」,我希望把重複的選項群組抽成可重用元件,減少重複碼。

在開始公布答案前大家可以複習一下我們UI從昨天的field來說還有什麼可以拆的

https://ithelp.ithome.com.tw/upload/images/20250923/20121052J3b4W4noga.png

3.2.1....

公布答案

如果是我,我看到UI會這樣拆

https://ithelp.ithome.com.tw/upload/images/20250923/20121052GXBHC9SdPH.png

大概會變成下面這張表:

模組 職責 輸入 (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就掛載近來

2. 時序圖

我們可以看到

這個系統在交互應用的時候怎麼樣的流程

也可以透過時序圖幫我們整理

https://ithelp.ithome.com.tw/upload/images/20250923/20121052wRpHQPJYPX.png

props是大家昨天就知道的東西

今天會學得什麼是事件接收 emit的功用

二、對應的Vue技術

1. 今天需要使用到的vue技術表格

  • 組件通訊:props(單向資料流)、emit(子→父回傳事件)
  • 表單雙向:v-modelmodelValue/update:modelValue 自定義
  • 狀態中心:reactive([]) 管理 orders;computed 做統計
  • 列表渲染:v-for :key
  • 條件渲染/樣式:v-ifv-show:class
  • 就地更新:Object.assign(orders[i], payload)orders.splice()

2.Emit傳遞事件

上層組件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帶進去給父組件

這樣就是簡單的傳遞組件的功效了

3. 程式設計圖

https://ithelp.ithome.com.tw/upload/images/20250923/201210527Jf34IusXI.png

好了之後我們就可以把程式抽成這樣

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>

整理起來大概就會是

App.vue(頁面容器、狀態中心、彙整統計)

  • 集中管理 orders 與統計資料,負責狀態的唯一來源。
  • 接收子元件傳回的 submiteditremove 事件並更新狀態。
  • 將最新的 orderssummary 透過 props 傳給各子元件。
  • 組件拆解後仍應保持資料流單向:父層管理狀態、子層純粹展示與回傳事件。

OrderForm(下單表單)

  • 接收 props: menus / options,並 emit submit(payload)
  • 內部再拆分 OptionGroup 子元件,透過 v-modelupdate:modelValue 同步選項。
  • 需驗證必填欄位與步驟完成度,並在送出後清空表單。
  • 保持為「純表單」邏輯,避免直接操作父層狀態。

OrderList(訂單清單)

  • 接收 props: orders,並 emit edit(payload)remove(index)
  • 就地編輯:維護自己的 editIndexeditForm 狀態,儲存或取消時發事件通知父層。
  • 刪除與編輯必須保持對父層單一狀態的尊重,不要直接改 props
  • 操作按鈕與顯示分區清晰,方便使用者在大量資料時維護。

OrderStats(統計表)

  • 接收 props: orders / summary,僅負責資料統計顯示。
  • 利用父層提供的彙整結果,避免在自己內部重複計算。
  • 表格結構應保持語意化(thead / tbody / tfoot)以利擴充。
  • 專注於呈現,不帶任何修改狀態的行為。

好啦~這樣就拆分完畢了~
明天我們就要展開API的旅程搂


上一篇
Chapter 2:前端架構升級 – 元件化與資料流拓展
系列文
需求至上的 Vue 魔法之旅9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言