原計畫今天要進入 API 串接,但我們的 OrderForm 還有一段組件化沒拆完(OptionGroup)。
另外在實戰裡,厲害的魔法師也需要可靠的魔法裝備:除了線上Vue Playground 快速嘗鮮,正式開發仍需要本機/Server 的工具鏈來支撐 開發、打包、部署、CI/CD。
所以今天我們做兩件事:
補完組件化
:將 OrderForm 的三組選項抽成通用的 OptionGroup;
導入 Vite
:用最輕量的開發工具開始本地專案,支援 HMR、打包與未來串接後端。 => 不過今天不會講完打包,會使用dev的方執行app,畢竟我們今天的主要目的是拆component再放到開發環境中。
元件 | 職責 | 輸入 (props) | 輸出 (emit) |
---|---|---|---|
OrderForm | 下單表單容器 | 無(內建本地狀態;接 App 的 submit 事件) | submit(payload) |
OptionGroup | 通用「單選群組」 | label, options[], modelValue, name(可選) | update:modelValue(val) |
modelValue / update:modelValue
打造可雙向綁定的子元件<fieldset><legend/>
包住單選群組;確保 name
一致這邊我們簡單介紹一下v-model在上下組件的應用
我們腦袋先想像視覺圖大概會是
1.上層組件負責顯示,user選擇的飲料
2.下層組件選擇,並且透過雙向綁定把值傳上去給上層組件,這樣上層組件就可以知道要render什麼值出來
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:name
、v-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
的關鍵組合。
以下用簡單對照來說明
:checked="modelValue === '紅茶'"
modelValue
,決定這顆 radio 是否被勾選。modelValue
是 '紅茶'
,那麼紅茶的 radio 會呈現勾選。@change="$emit('update:modelValue', '紅茶')"
modelValue
更新成 '紅茶'
。:checked
➜ 由外而內:根據父層值,控制畫面呈現。@change
➜ 由內而外:使用者操作後,把最新值傳回父層。如果少了其中一個:
- 只用
:checked
:畫面會顯示正確,但點選不會更新父層資料。- 只用
@change
:點選能送資料,但初始畫面可能勾選錯誤。
:checked
決定現在看起來是誰被選中@change
決定點下去後要告訴父層誰被選中
這就是自訂 v-model
必須同時使用 :checked
+ @change
的原因 ✅
有了上面的新魔法我們就可以思考怎麼樣去拆程式了
大概就會像是這樣
我們下面的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
的預設事件名,子元件用它把新值回寫給父層。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>
v-model
<template>
<OptionGroup
label="飲料"
:options="['紅茶','綠茶']"
:modelValue="drink" <!-- 父→子:把值給子 -->
@update:modelValue="drink = $event"<!-- 子→父:接新值回寫 -->
/>
</template>
ref
(例如 drink = ref('')
)。drink.value
傳給下層的 modelValue
。:checked="modelValue === opt"
控制哪個 radio 勾選(由外而內)。@change
→emit('update:modelValue', '紅茶')
。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
送對應的識別值。
接下來就完成拉~我們可以到playgroud去玩看看
2.使用vite安裝vue開發模板
其中的my-vue-app
是安裝完後folder的名稱,可以取名自己project的name
npm create vite@latest my-vue-app -- --template vue
3.接者它會問你兩個問題
Use rolldown-vite (Experimental)?: 這個是實驗性質的打包工具,就是加速開發的,但是官方說這個還在實驗階段可勾可不勾,如果你的專案是要上架請不要使用這個
但我們現在在教學所以可以勾
Install with npm and start now?
這邊你可以用npm也可以用yran但是我預設讓他使用npm
安裝完畢後應該會看到這個畫面
如果你跟我一樣第一次用這新電腦開發,vscode應該會推薦你裝vue的plugin,可以把它裝起來,加速你的開發速度。
首先你會看到這樣的folder結構
基本上大家會把組件塞在components folder ,不過今天為了方便demo
我們直接把它擺在App.vue旁邊
程式碼搬動完成後可以下
npm run dev
這時候可以直接在網頁瀏覽器 http://localhost:5173/ 來看畫面
沙小怎麼畫面爆炸了
其實我們透過觀察可以看到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;
}
接者就成功拉
另外我會強烈建議你把vue devtools裝起來
這個工具安裝完畢後可以按F12點選vue面板
之後就可以看到compoent的組件名稱
跟屬性
還有事件
非常的好用
好拉今天就到這邊了~~
我們明天繼續