iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Vue.js

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

Day8 : 讓你接受我的控制:受控表單設計

  • 分享至 

  • xImage
  •  

前言

今天我們要補充 什麼是「受控表單設計」
其實我們過去的飲料訂單程式早就屬於受控表單:父元件 App.vue 儲存了表單的唯一狀態,子元件像 OrderForm.vueOptionGroup.vue 都透過 v-modelemit('update:modelValue') 與父元件同步 (sync)
這就是「受控」的核心──所有輸入的真實值都由 Vue 的響應式狀態管理

其實大家不用想的這麼複雜,其實簡單想

  1. 如果下層組件有各自的data source 然後跟上層組件互相傳遞,設計不良可能會造成一些資料錯亂不同步的問題
  2. 我們在設計小組件的時候,應該要越簡潔越好盡量不要也帶有data也就是在乾淨的component call API?什麼意思呢?
  • 也就是今天以我們飲料單舉例,理應當飲料的品項、甜度等是由optionform 去call api再把 data 綁定傳給乾淨的component (optiongroup)
  • 如果你form的上層組件需要擴展,你的call api從option group做,那你要傳給其他的資料就會非常凌亂,所以應該由上層管控你的資料
  • 你可以想像今天你受傷了,應該是你帶有這份受傷的資料你才傳遞給身上的部件(你的程度客製化椅子、你的衣服需要撕開、食物需要配合你的身體發炎程度......等)
  1. 另外如果你在小組件call api那麼他在其他應用表單上可能就無法重複使用。 (比如說她在這邊call了飲料的api 跟他類似legend 跟name結構的選單,服飾店可能就不能用這個component 設計)

受控表單 (Controlled Form)

  • 使用 Vue 的 v-modelrefreactive 等機制,把輸入值集中在父層
  • 每次輸入都觸發更新,UI = State 的直接映射。
  • 具備單一真相 (Single Source of Truth, SSoT):畫面只是 state 的「投影」,狀態唯一且可預期。

為什麼需要受控

  • 可以即時驗證欄位、控制送出鈕 (例如 :disabled="!canSubmit")。
  • 容易重置或回填,所有欄位只需操作 state。

非受控表單 (Uncontrolled Form)

  • 依賴瀏覽器原生 DOM。
  • 例如使用 <form>.reset() 清空輸入,但 Vue 父層不知道,state 仍保留舊值。
  • 這會導致「父層以為有資料,但實際送出的卻是空資料」的潛在錯誤。

今日示範

  1. 先重溫我們的程式,其實就是標準的受控表單。

  2. 介紹「單一真相」SSoT 的概念。

  3. Demo 一個極簡範例:受控 vs 非受控

    • 受控:Reset 會連帶更新父層 state,不可誤送。
    • 非受控:只清 DOM,父層還以為資料存在,可能提交空內容。

透過這個對比,你會更清楚 為什麼我們從一開始就選擇受控表單設計,以及保持單一資料真相的重要性。


一、需求分析

我們一樣先把需求訂出來,其實這次的需求比較貼近前端工程師(就是正在寫程式的你) 應該要注意的設計模式

1.受控表單(Controlled + SSoT)

作為 我想要 以便於
訂單管理者(前端工程師) 由父層集中管理所有輸入狀態(name, drink…) 能準確計算 canSubmit、統計、快照/回填、不會出現 UI 與狀態不一致
使用者(點單者) 未填完時送出鈕自動鎖定 降低誤送出、流程更清楚
產品/QA 重置或回填時所有欄位一致 易於驗證與除錯,規則可重現

https://ithelp.ithome.com.tw/upload/images/20250925/201210527sNLCmqAD7.png

2.非受控表單(Uncontrolled)

作為 我想要 以便於
工程師 直接用 DOM 操作(快速 Demo) 少寫 state、快速起步
使用者 「看起來」有 reset 功能 但實際上父層不知道,可能導致送出空資料
產品/QA 理解風險 知道 reset 後仍可送出、且值可能為空(需額外防呆)

https://ithelp.ithome.com.tw/upload/images/20250925/20121052nZXqhjU59Y.png

二、程式實作

其實今天我們沒有特別的技術
來簡單的範例給大家看

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的概念喔~!!!


上一篇
Day 7.5 : 組件化的魔法延續及所需的魔法道具
下一篇
Day 9 : 連接世界的魔法:API 溝通術
系列文
需求至上的 Vue 魔法之旅13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言