iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Vue.js

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

Day 7.5 : 組件化的魔法延續及所需的魔法道具

  • 分享至 

  • xImage
  •  

前言

原計畫今天要進入 API 串接,但我們的 OrderForm 還有一段組件化沒拆完(OptionGroup)。/images/emoticon/emoticon02.gif

另外在實戰裡,厲害的魔法師也需要可靠的魔法裝備:除了線上Vue Playground 快速嘗鮮,正式開發仍需要本機/Server 的工具鏈來支撐 開發、打包、部署、CI/CD。

所以今天我們做兩件事:

  1. 補完組件化:將 OrderForm 的三組選項抽成通用的 OptionGroup;

  2. 導入 Vite:用最輕量的開發工具開始本地專案,支援 HMR、打包與未來串接後端。 => 不過今天不會講完打包,會使用dev的方執行app,畢竟我們今天的主要目的是拆component再放到開發環境中。

一、需求與拆解

1.User Story(延伸 Day 7)

  • 作為開發者,我希望把表單的「飲料 / 甜度 / 冰量」抽成可重用元件,減少重複碼。
  • 作為使用者,我希望表單維持與昨天相同的操作手感與驗證體驗。
  • 作為架構維護者,我希望元件具備良好邊界:用 props 控制,用 emit 回傳,樣式可覆寫。

2.需求表格

元件 職責 輸入 (props) 輸出 (emit)
OrderForm 下單表單容器 無(內建本地狀態;接 App 的 submit 事件) submit(payload)
OptionGroup 通用「單選群組」 label, options[], modelValue, name(可選) update:modelValue(val)

3. 使用到的Vue技術

  • 自訂 v-model:以 modelValue / update:modelValue 打造可雙向綁定的子元件
  • 單向資料流:父層(OrderForm)管狀態;子層(OptionGroup)純展示/回傳
  • 可存取性 (a11y):<fieldset><legend/> 包住單選群組;確保 name 一致

這邊我們簡單介紹一下v-model在上下組件的應用

我們腦袋先想像視覺圖大概會是
1.上層組件負責顯示,user選擇的飲料
2.下層組件選擇,並且透過雙向綁定把值傳上去給上層組件,這樣上層組件就可以知道要render什麼值出來

https://ithelp.ithome.com.tw/upload/images/20250924/20121052ISqDo9AbNe.png

  • 藍色的就是上層組件App.vue
  • 紅色框起來就是下層組件DrinkSelector.vue

App.vue

<template>
  <h2>自訂 v-model 範例</h2>
  <DrinkSelector v-model="drink" />
  <p>目前選擇:{{ drink || '尚未選擇' }}</p>
</template>

<script setup>
import { ref } from 'vue'
import DrinkSelector from './DrinkSelector.vue'

const drink = ref('') // 父層資料
</script>

DrinkSelector.vue

<template>
  <fieldset>
    <legend>選擇飲料</legend>
    <label>
      <input
        type="radio"
        value="紅茶"
        :checked="modelValue === '紅茶'"
        @change="$emit('update:modelValue', '紅茶')"
      />
      紅茶
    </label>
    <label>
      <input
        type="radio"
        value="綠茶"
        :checked="modelValue === '綠茶'"
        @change="$emit('update:modelValue', '綠茶')"
      />
      綠茶
    </label>
  </fieldset>
</template>

<script setup>
/** 接收父層的 modelValue */
defineProps({
  modelValue: String
})
</script>

我們的使用重點有以下幾點,整理成表格方便大家參閱


重點解析

步驟 子層 父層
1. 接值 props: { modelValue } v-model="drink"
2. 回傳 $emit('update:modelValue', newValue) 自動同步 drink
3. 使用 :checked="modelValue === ..." {{ drink }}

💡 小提醒

  • 自訂 v-model 的預設 prop 名稱是 modelValue,事件名稱必須是 update:modelValue

  • 如果需要多個 v-model(例如 v-model:namev-model:note),可以在子元件中分別定義對應的 props$emit(),例如:

    • props: { name: String }
    • $emit('update:name', newName)
  • 這樣父層就可以同時使用 v-model:name="..."v-model:note="..." 來綁定多個欄位。

這時候你可能會問:checked="modelValue === '綠茶'" 還需要 @change="$emit('update:modelValue', '紅茶')" 嗎?

這是一個非常好問題!這兩個屬性其實扮演 不同角色,是自訂 v-model 的關鍵組合。
以下用簡單對照來說明


1. :checked="modelValue === '紅茶'"

  • 功能:控制 radio 的顯示狀態
  • 依照父層傳進來的 modelValue,決定這顆 radio 是否被勾選
  • 例如:如果 modelValue'紅茶',那麼紅茶的 radio 會呈現勾選。

2. @change="$emit('update:modelValue', '紅茶')"

  • 功能:處理使用者輸入事件
  • 當使用者真的去點選「紅茶」時,會通知父層,把 modelValue 更新成 '紅茶'
  • 也就是說,它觸發了 v-model 的 資料同步

🏁 為什麼需要兩者同時存在

  • :checked由外而內:根據父層值,控制畫面呈現。
  • @change由內而外:使用者操作後,把最新值傳回父層。

如果少了其中一個:

  • 只用 :checked:畫面會顯示正確,但點選不會更新父層資料。
  • 只用 @change:點選能送資料,但初始畫面可能勾選錯誤。

⚡ 簡化理解

:checked 決定現在看起來是誰被選中
@change 決定點下去後要告訴父層誰被選中

這就是自訂 v-model 必須同時使用 :checked + @change 的原因 ✅

二、程式實作

1.程式流程思考

有了上面的新魔法我們就可以思考怎麼樣去拆程式了

大概就會像是這樣https://ithelp.ithome.com.tw/upload/images/20250924/20121052TKHUloByxg.png

2.實作

我們下面的OptionGroup.vue

<script setup>
const props = defineProps({
  label: { type: String, required: true },
  options: { type: Array, required: true },
  modelValue: { type: String, default: '' },
  required: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <fieldset :class="['block', (!required || modelValue) ? 'complete' : 'invalid']">
    <legend>{{ label }}</legend>
    <label v-for="opt in options" :key="opt" style="margin-right:12px">
      <input
        type="radio"
        :checked="modelValue === opt"
        @change="emit('update:modelValue', opt)"
      />
      {{ opt }}
    </label>
    <p v-if="required && !modelValue" class="hint">尚未選擇:{{ label }}</p>
  </fieldset>
</template>

OrderForm.vue

<script setup>
import { ref, computed } from 'vue'
import OptionGroup from './OptionGroup.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>
  <!-- Day 3:姓名/備註 -->
  <div :class="['block', name ? 'complete' : 'invalid']">
    <label>姓名(必填)
      <input type="text" v-model.trim="name" placeholder="請輸入你的名字" />
    </label>
    <p class="hint" v-if="!name">尚未填寫姓名</p>
  </div>

  <div class="block">
    <label>備註(選填)
      <textarea v-model.trim="note" placeholder="例如:三點拿、少冰"></textarea>
    </label>
  </div>

  <!-- 三組選項 -->
  <OptionGroup
  label="步驟 1:選擇飲料"
  :options="['紅茶','綠茶']"
  v-model="drink"
  required
/>

<!-- 只有選了飲料才顯示甜度 -->
<OptionGroup
  v-if="drink"
  label="步驟 2:選擇甜度"
  :options="['正常甜','去糖']"
  v-model="sweetness"
  required
/>

<!-- 只有選了飲料 + 甜度才顯示冰量 -->
<OptionGroup
  v-if="drink && sweetness"
  label="步驟 3:選擇冰量"
  :options="['正常冰','去冰']"
  v-model="ice"
  required
/>

  <button :disabled="!canSubmit" @click="addOrder" :class="['submit', canSubmit ? 'enabled' : 'disabled']">
    {{ canSubmit ? '送出' : '請完成所有必填' }}
  </button>
</template>

下面用「父→子讀值、子→父回寫」兩個方向,把 OptionGroup.vue 跟上層的溝通講清楚。


這段程式在子元件做了什麼?

<script setup>
const props = defineProps({
  label: String,
  options: Array,
  modelValue: { type: String, default: '' }, // ← 父層傳進來的值(只讀)
  required: Boolean,
})
const emit = defineEmits(['update:modelValue']) // ← 回傳新值給父層的事件
</script>

<template>
  <fieldset :class="['block', (!required || modelValue) ? 'complete' : 'invalid']">
    <legend>{{ label }}</legend>

    <label v-for="opt in options" :key="opt">
      <input
        type="radio"
        :checked="modelValue === opt"          <!-- 讀:由父層值控制畫面勾選 -->
        @change="emit('update:modelValue', opt)" <!-- 寫:使用者選取→通知父層更新 -->
      />
      {{ opt }}
    </label>

    <p v-if="required && !modelValue" class="hint">尚未選擇:{{ label }}</p>
  </fieldset>
</template>
  • modelValue:自訂 v-model預設 prop 名。子元件只它,不修改。
  • emit('update:modelValue', 新值):自訂 v-model預設事件名,子元件用它把新值回寫給父層。

父層怎麼用?(兩種寫法)

1) 直覺版:v-model

<script setup>
import { ref } from 'vue'
import OptionGroup from './OptionGroup.vue'

const drink = ref('')
</script>

<template>
  <OptionGroup
    label="飲料"
    :options="['紅茶','綠茶']"
    v-model="drink"           <!-- 連動到 modelValue / update:modelValue -->
    required
  />
  <p>目前選擇:{{ drink || '尚未選擇' }}</p>
</template>

2) 拆解版:等同於 v-model

<template>
  <OptionGroup
    label="飲料"
    :options="['紅茶','綠茶']"
    :modelValue="drink"               <!-- 父→子:把值給子 -->
    @update:modelValue="drink = $event"<!-- 子→父:接新值回寫 -->
  />
</template>

資料流(一步步)

  1. 上層有一個 ref(例如 drink = ref(''))。
  2. 首次渲染時,上層把 drink.value 傳給下層的 modelValue
  3. 下層用 :checked="modelValue === opt" 控制哪個 radio 勾選(由外而內)。
  4. 使用者在下層點了某個選項,觸發 @change
    emit('update:modelValue', '紅茶')
  5. 上層的 v-model 自動把 '紅茶' 寫回 drink.value由內而外),上層與下層一起重新渲染。

重點:modelValue 負責「顯示目前值」,update:modelValue 負責「送出新值」。


小提醒(常見坑)

  • 不要在子層直接改 props.modelValue(props 是唯讀)。一定用 emit('update:modelValue', …)

  • 若要多個欄位一起雙向綁定,可用具名 v-model

    • 下層 props: { name: String } + emit('update:name', v)
    • 上層 <Child v-model:name="name" />
  • options 不是字串而是物件,請給穩定的 :key,並用 :value 或在 @change 送對應的識別值。

Day7組件化版

接下來就完成拉~我們可以到playgroud去玩看看

三、vite 來操作vue

1. 事前準備

  1. nodejs
    身為前端工程師,應該沒有人不知道吧~! 請安裝nodejs

https://nodejs.org/zh-tw

2.使用vite安裝vue開發模板

其中的my-vue-app是安裝完後folder的名稱,可以取名自己project的name

npm create vite@latest my-vue-app -- --template vue

3.接者它會問你兩個問題

  • Use rolldown-vite (Experimental)?: 這個是實驗性質的打包工具,就是加速開發的,但是官方說這個還在實驗階段可勾可不勾,如果你的專案是要上架請不要使用這個 但我們現在在教學所以可以勾
    https://ithelp.ithome.com.tw/upload/images/20250924/20121052NHlmzHu566.png

  • Install with npm and start now?

這邊你可以用npm也可以用yran但是我預設讓他使用npm

安裝完畢後應該會看到這個畫面
https://ithelp.ithome.com.tw/upload/images/20250924/20121052aI4oOz7No2.png

如果你跟我一樣第一次用這新電腦開發,vscode應該會推薦你裝vue的plugin,可以把它裝起來,加速你的開發速度。

2. 移植程式碼

首先你會看到這樣的folder結構

https://ithelp.ithome.com.tw/upload/images/20250924/20121052LkWqxz1sO6.png

基本上大家會把組件塞在components folder ,不過今天為了方便demo
我們直接把它擺在App.vue旁邊

程式碼搬動完成後可以下

npm run dev

這時候可以直接在網頁瀏覽器 http://localhost:5173/ 來看畫面

https://ithelp.ithome.com.tw/upload/images/20250924/201210528wWlY52YFA.png

沙小怎麼畫面爆炸了

其實我們透過觀察可以看到vite專案裡面
本來有預設style.css

我們可以reset css讓UI回到原本的狀態狀態

/* 重置基本樣式,與 App.vue 中的樣式保持一致 */
* { 
  box-sizing: border-box; 
}

body { 
  margin: 0; 
  font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Noto Sans';
  background-color: #ffffff;
  color: #213547;
}

#app {
  width: 100%;
  min-height: 100vh;
}

接者就成功拉

https://ithelp.ithome.com.tw/upload/images/20250924/20121052cEiy5wFiZ0.png

Vue.js devtools

另外我會強烈建議你把vue devtools裝起來

這個工具安裝完畢後可以按F12點選vue面板

之後就可以看到compoent的組件名稱屬性還有事件

非常的好用

好拉今天就到這邊了~~

我們明天繼續/images/emoticon/emoticon29.gif


上一篇
Day 7 : 如何把複雜的咒語變簡單:組件化設計
下一篇
Day8 : 讓你接受我的控制:受控表單設計
系列文
需求至上的 Vue 魔法之旅13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言