今天我們要補充 什麼是「受控表單設計」。
其實我們過去的飲料訂單程式早就屬於受控表單:父元件 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的概念喔~!!!