今天我們要補充 什麼是「受控表單設計」。
其實我們過去的飲料訂單程式早就屬於受控表單:父元件 App.vue
儲存了表單的唯一狀態,子元件像 OrderForm.vue
、OptionGroup.vue
都透過 v-model
與 emit('update:modelValue')
與父元件同步 (sync)。
這就是「受控」的核心──所有輸入的真實值都由 Vue 的響應式狀態管理。
其實大家不用想的這麼複雜,其實簡單想
資料錯亂
或不同步的問題
簡潔越好
盡量不要也帶有data也就是在乾淨的component call API?什麼意思呢?由optionform 去call api
再把 data 綁定傳給乾淨的component (optiongroup)上層管控你的資料
v-model
、ref
、reactive
等機制,把輸入值集中在父層。:disabled="!canSubmit"
)。<form>
的 .reset()
清空輸入,但 Vue 父層不知道,state 仍保留舊值。先重溫我們的程式,其實就是標準的受控表單。
介紹「單一真相」SSoT 的概念。
Demo 一個極簡範例:受控 vs 非受控
透過這個對比,你會更清楚 為什麼我們從一開始就選擇受控表單設計,以及保持單一資料真相的重要性。
我們一樣先把需求訂出來,其實這次的需求比較貼近前端工程師(就是正在寫程式的你) 應該要注意的設計模式
作為 | 我想要 | 以便於 |
---|---|---|
訂單管理者(前端工程師) | 由父層集中管理所有輸入狀態(name, drink…) | 能準確計算 canSubmit 、統計、快照/回填、不會出現 UI 與狀態不一致 |
使用者(點單者) | 未填完時送出鈕自動鎖定 | 降低誤送出、流程更清楚 |
產品/QA | 重置或回填時所有欄位一致 | 易於驗證與除錯,規則可重現 |
作為 | 我想要 | 以便於 |
---|---|---|
工程師 | 直接用 DOM 操作(快速 Demo) | 少寫 state、快速起步 |
使用者 | 「看起來」有 reset 功能 | 但實際上父層不知道,可能導致送出空資料 |
產品/QA | 理解風險 | 知道 reset 後仍可送出、且值可能為空(需額外防呆) |
其實今天我們沒有特別的技術
來簡單的範例給大家看
App-Controlled.vue
<script setup>
import { reactive, computed } from 'vue'
import ControlledChild from './ControlledChild.vue'
// 單一真相來源(SSoT)
const form = reactive({ name: 'Alice', drink: '紅茶' })
const canSubmit = computed(() => !!form.name && !!form.drink)
function resetForm() {
Object.assign(form, { name: '', drink: '' })
}
function submitForm() {
alert('受控送出:' + JSON.stringify(form))
}
</script>
<template>
<h2>受控(Controlled + SSoT)</h2>
<ControlledChild v-model:name="form.name" v-model:drink="form.drink" />
<div style="margin-top:8px; display:flex; gap:8px;">
<button @click="resetForm">Reset</button>
<button :disabled="!canSubmit" @click="submitForm">送出</button>
</div>
<small>父層 state:{{ form }} | canSubmit = {{ canSubmit }}</small>
</template>
ControlledChild.vue
<script setup>
const props = defineProps({
name: { type: String, default: '' },
drink: { type: String, default: '' },
})
const emit = defineEmits(['update:name', 'update:drink'])
</script>
<template>
<div>
<label>姓名:
<input :value="name" @input="emit('update:name', $event.target.value)" placeholder="請輸入姓名" />
</label>
<fieldset style="margin-top:8px;">
<legend>飲料</legend>
<label>
<input type="radio" value="紅茶" :checked="drink === '紅茶'" @change="emit('update:drink', '紅茶')" />
紅茶
</label>
<label style="margin-left:12px;">
<input type="radio" value="綠茶" :checked="drink === '綠茶'" @change="emit('update:drink', '綠茶')" />
綠茶
</label>
</fieldset>
</div>
</template>
App-Uncontrolled.vue
<script setup>
import { reactive, ref, computed } from 'vue'
import UncontrolledChild from './UncontrolledChild.vue'
// 父層只提供初始值(不追蹤之後變化)
const ghost = reactive({ name: 'Alice', drink: '紅茶' })
const ucRef = ref(null)
// 父層以 ghost 判斷是否可送出(可能與實際 DOM 不一致)
const canSubmit = computed(() => !!ghost.name && !!ghost.drink)
function handleSubmit(payload) {
alert('非受控送出:' + JSON.stringify(payload))
}
function resetDom() {
// 只會把子層還原為 defaultValue/defaultChecked
ucRef.value?.reset()
}
</script>
<template>
<h2>非受控(Uncontrolled)</h2>
<UncontrolledChild ref="ucRef" :ghost="ghost" @submit="handleSubmit" />
<div style="margin-top:8px; display:flex; gap:8px;">
<button @click="resetDom">Reset(只清 DOM)</button>
<button :disabled="!canSubmit" @click="ucRef?.submit()">送出</button>
</div>
<small>父層 ghost:{{ ghost }} | canSubmit = {{ canSubmit }}</small>
<p style="color:#c00;margin-top:6px;">
⚠ Reset 只還原預設值,不會改父層 ghost;可能送出**過期或空資料**。
</p>
</template>
UncontrolledChild.vue
<script setup>
import { ref } from 'vue'
const props = defineProps({
ghost: { type: Object, required: true }, // { name, drink }
})
const emit = defineEmits(['submit'])
const formRef = ref(null)
function submit() {
const fd = new FormData(formRef.value)
const payload = Object.fromEntries(fd.entries())
emit('submit', payload)
}
function reset() {
// 回到 defaultValue/defaultChecked(非清空)
formRef.value.reset()
}
defineExpose({ submit, reset })
</script>
<template>
<form ref="formRef" @submit.prevent="submit">
<label>姓名:
<input name="name" :defaultValue="ghost.name" placeholder="請輸入姓名" />
</label>
<fieldset style="margin-top:8px;">
<legend>飲料</legend>
<label>
<input type="radio" name="drink" value="紅茶" :defaultChecked="ghost.drink === '紅茶'" />
紅茶
</label>
<label style="margin-left:12px;">
<input type="radio" name="drink" value="綠茶" :defaultChecked="ghost.drink === '綠茶'" />
綠茶
</label>
</fieldset>
</form>
</template>
面向 | 受控(Controlled + SSoT) | 非受控(Uncontrolled) |
---|---|---|
真相來源 | 父層 state(單一真相) | 子層 DOM(父層只給初始值 ghost) |
子元件資料 | 以 v-model / emit('update:…') 與父層 雙向同步 |
用 <input defaultValue / defaultChecked> ,資料停在 DOM |
Reset 行為 | Object.assign(form, initial) → 父層 state 改變,畫面自動清空 |
form.reset() → 只還原 DOM,父層 state 不變 |
送出資料 | 直接使用父層 state → 穩定一致 | 需從 FormData(formRef) 取值 → 以 DOM 為準 |
按鈕 Disable 判斷 | :disabled="!canSubmit" (基於父層 state)→ 可靠 |
常見寫法也用 :disabled="!canSubmit" (基於父層 ghost)→ 可能錯誤(ghost 沒同步) |
顯示狀態 | 畫面 = state = 送出內容 → 三者一致 | 畫面(DOM)≠ 父層 ghost ≠ 送出內容 → 容易不一致 |
典型 Bug | 幾乎無(除非邏輯寫錯) | Reset 後仍可送出、送出過期資料、Disable 判斷失靈 |
這邊在設計的時候還是需要考慮一下資料的同步性跟SSOT的概念喔~!!!