Vue 表單、非同步狀態與資料流設計
表單和非同步狀態是 Vue 專案裡最容易變亂的地方。看起來只是幾個 input 和一個 submit,但實務上會牽涉:初始資料、草稿狀態、驗證、loading、錯誤回填、取消請求、重試、樂觀更新、離開頁面提醒,以及資料到底要放在元件、Composable 還是 Store。
一句話回答:Vue 表單與非同步資料流的核心,是把「表單草稿」、「後端原始資料」、「驗證錯誤」、「提交狀態」分清楚;用 v-model 或受控元件維持明確資料流,用 computed/watch 處理衍生狀態與副作用,提交時管理 loading/error/cancel/retry,必要時把可復用流程抽成 Composable。
一、 必考觀念
1. 表單狀態不等於後端資料
常見錯誤是直接把 API 回來的資料綁到表單上:
const user = ref<User | null>(null)
async function loadUser() {
user.value = await fetchUser()
}然後模板直接編輯 user。
問題是:
- 使用者還沒按儲存,原始資料已經被改掉。
- 取消編輯時不好回復。
- dirty 狀態不好判斷。
- 後端錯誤回填和本地草稿混在一起。
更好的模型是分開:
server data:後端原始資料
form draft:表單草稿
validation errors:驗證錯誤
submit state:提交狀態2. 表單資料流要明確
簡單表單可以直接在元件內管理:
const form = reactive({
name: '',
email: '',
})複雜表單要清楚定義:
- 初始值從哪裡來?
- 使用者修改的是哪份資料?
- 按取消時怎麼還原?
- 按儲存時送出哪些欄位?
- 後端錯誤怎麼顯示?
- 切路由時草稿要不要保留?
這些問題比使用哪個 UI library 更重要。
3. 非同步狀態至少要有四件事
一個基本請求流程通常包含:
const data = ref(null)
const loading = ref(false)
const error = ref<unknown>(null)以及一個動作:
async function loadData() {
loading.value = true
error.value = null
try {
data.value = await fetchData()
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}再往上會加:
- cancel。
- retry。
- stale data。
- empty state。
- background refreshing。
- optimistic update。
延伸讀:Vue 請求、快取與體驗優化。
二、 受控表單與 v-model
1. 表單元件要能被父層控制
如果做一個 UserForm,常見 API 是:
<UserForm
:model-value="form"
@update:model-value="form = $event"
/>或:
<UserForm v-model="form" />這代表:
- 父層擁有表單資料。
- 子層負責渲染 input。
- 子層透過 emit 回報變更。
延伸讀:v-model 雙向綁定的原理。
2. 子元件不要偷改 props
不建議:
props.modelValue.name = 'Ada'這會讓資料流變得不清楚。
更好的做法是 emit 新值:
const emit = defineEmits<{
'update:modelValue': [value: UserForm]
}>()
function updateName(name: string) {
emit('update:modelValue', {
...props.modelValue,
name,
})
}這樣父層仍然是狀態歸屬點。
3. 複雜表單可以內部維護 draft
如果表單內部很複雜,也可以在子元件建立 draft,但要定義同步策略。
const draft = reactive(cloneForm(props.modelValue))
watch(
() => props.modelValue,
value => {
Object.assign(draft, cloneForm(value))
}
)
function submit() {
emit('submit', cloneForm(draft))
}這種模式適合:
- Modal 編輯。
- 需要取消還原。
- 表單中間有很多暫存互動。
- 只有按確認才送回父層。
三、 表單驗證
1. 驗證分層
表單驗證通常有三層:
| 類型 | 範例 |
|---|---|
| 前端同步驗證 | 必填、格式、長度 |
| 前端非同步驗證 | 帳號是否重複、邀請碼是否有效 |
| 後端驗證 | 權限、業務規則、資料一致性 |
前端驗證改善體驗,但不能取代後端驗證。
2. 驗證錯誤要有結構
不要只用一個字串保存所有錯誤。
const errors = reactive<Record<string, string>>({})例如:
function validate() {
errors.name = form.name ? '' : '請輸入姓名'
errors.email = isEmail(form.email) ? '' : 'Email 格式不正確'
return !errors.name && !errors.email
}這樣 input 可以各自顯示錯誤。
3. 驗證時機要看場景
常見時機:
- submit 時驗證。
- blur 時驗證。
- input 時驗證。
- debounce 後驗證。
例如 email 格式可以 input 或 blur 驗證;帳號是否重複應該 debounce 後發 API,不要每個字都請求。
watch(
() => form.username,
value => {
debouncedCheckUsername(value)
}
)4. 後端錯誤要能回填欄位
後端可能回:
{
"message": "Validation failed",
"fields": {
"email": "Email already exists"
}
}前端應該能映射到欄位錯誤:
function applyServerErrors(fields: Record<string, string>) {
for (const [field, message] of Object.entries(fields)) {
errors[field] = message
}
}不要只顯示一個籠統 toast,否則使用者不知道哪裡要改。
四、 提交流程與非同步狀態
1. 標準提交流程
一個清楚的 submit 流程:
檢查本地驗證
-> 設定 submitting
-> 清空舊錯誤
-> 發 API
-> 成功後更新資料或跳轉
-> 失敗時顯示錯誤
-> finally 還原 submitting範例:
async function submit() {
if (!validate()) {
return
}
submitting.value = true
submitError.value = null
try {
await updateUser(form)
showToast('儲存成功')
} catch (error) {
submitError.value = error
applyServerError(error)
} finally {
submitting.value = false
}
}2. 防止重複提交
按鈕要根據 submitting disabled:
<button
:disabled="submitting"
@click="submit"
>
儲存
</button>但只 disabled 還不夠,submit 函式本身也應該防守:
async function submit() {
if (submitting.value) {
return
}
// ...
}因為使用者可能用 Enter、快捷鍵或其他方式觸發提交。
3. 取消請求
如果表單依賴非同步查詢,例如搜尋使用者、驗證帳號、載入城市資料,就要處理取消。
let controller: AbortController | null = null
async function checkUsername(username: string) {
controller?.abort()
controller = new AbortController()
try {
await checkUsernameApi(username, {
signal: controller.signal,
})
} catch (error) {
if ((error as Error).name !== 'AbortError') {
throw error
}
}
}延伸讀:接口請求一般放在哪個生命週期?為什麼?。
4. Retry 要看操作類型
適合 retry:
- 載入列表。
- 查詢資料。
- 送出可重試的草稿。
- 網路不穩下的讀取操作。
不適合盲目 retry:
- 付款。
- 建立訂單。
- 發送簡訊。
- 會產生副作用且沒有 idempotency key 的操作。
資深回答要能說出:不是所有錯誤都應該自動重試。
五、 Loading、Error、Empty、Dirty
1. Loading 要分初次載入和提交中
不要一個 loading 管全部。
更清楚:
const initialLoading = ref(false)
const submitting = ref(false)
const refreshing = ref(false)不同狀態對應不同 UI:
- 初次載入:骨架屏。
- 提交中:按鈕 disabled + spinner。
- 背景刷新:小型 refreshing indicator。
2. Error 要分層
常見錯誤類型:
- 頁面載入失敗。
- 欄位驗證錯誤。
- 提交失敗。
- 權限不足。
- 網路中斷。
不同錯誤要不同處理:
- 欄位錯誤顯示在欄位下。
- 提交失敗可以 toast + 保留草稿。
- 載入失敗可以顯示重試區塊。
- 權限不足可能跳轉或顯示 forbidden。
3. Empty state 不等於 Error
查詢成功但沒有資料,是 empty,不是 error。
const isEmpty = computed(() => {
return !loading.value && !error.value && list.value.length === 0
})Empty state 應該給使用者下一步:
- 調整篩選。
- 新增第一筆資料。
- 清除搜尋條件。
4. Dirty state
Dirty 代表表單目前內容和初始內容不同。
const dirty = computed(() => {
return JSON.stringify(form) !== JSON.stringify(initialForm.value)
})實務上大型表單不要直接用 JSON stringify,可以:
- 只比較重要欄位。
- 在 input change 時標記 dirty。
- 使用表單庫的 dirty tracking。
Dirty 可用於:
- 離開頁面提醒。
- 儲存按鈕 disabled。
- 顯示「尚未儲存」提示。
六、 樂觀更新與回滾
1. 什麼時候適合樂觀更新?
適合:
- 收藏。
- 點讚。
- 勾選。
- 排序。
- 標記已讀。
這些操作成功率高、失敗可回滾,適合先更新 UI。
不適合:
- 金流。
- 庫存。
- 權限。
- 高衝突協作。
2. 回滾要保存快照
async function toggleFavorite(item: Item) {
const previous = item.favorite
item.favorite = !item.favorite
try {
await updateFavorite(item.id, item.favorite)
} catch (error) {
item.favorite = previous
showToast('更新失敗,已還原')
}
}如果操作牽涉多個狀態,要保存完整 patch 或 snapshot。
3. 樂觀更新要處理衝突
如果多人同時編輯,或 server 回傳結果和前端預期不同,就要以 server 為準並重新同步。
面試時可以說:
樂觀更新不是假裝成功,而是先給使用者即時回饋;一旦 server 拒絕或回傳最新狀態,就要回滾或同步。
七、 抽成 Composable
1. 什麼時候抽?
適合抽成 Composable:
- 多個頁面都有相同表單提交流程。
- loading/error/cancel/retry 模式重複。
- 驗證與請求流程可獨立測試。
- 表單很大,需要把邏輯從模板中移開。
不適合:
- 只有單一表單使用。
- 抽完參數很多,反而更難讀。
- 強依賴某個 UI 元件細節。
延伸讀:Composition API 實戰與 Composable 設計。
2. useSubmit 範例
export function useSubmit<TPayload>(
submitter: (payload: TPayload) => Promise<void>
) {
const submitting = ref(false)
const error = ref<unknown>(null)
async function submit(payload: TPayload) {
if (submitting.value) {
return
}
submitting.value = true
error.value = null
try {
await submitter(payload)
} catch (err) {
error.value = err
throw err
} finally {
submitting.value = false
}
}
return {
submitting,
error,
submit,
}
}這種 Composable 適合收斂重複流程,但欄位驗證與 UI 顯示仍然可以留在業務元件。
3. 表單和 Store 的關係
表單草稿通常不一定要進 store。
適合進 store:
- 多步驟表單跨頁共享。
- 使用者離開再回來要保留草稿。
- 多個區塊共同編輯同一份業務狀態。
不適合進 store:
- 單頁 Modal 裡的一次性表單。
- 只在一個元件內使用的 input 狀態。
- loading / error 只服務當前按鈕。
延伸讀:Vue 狀態管理:Pinia、Vuex 與狀態歸屬。
八、 追問題庫
Q1:表單資料應該直接改 props 嗎?
不建議。
Props 是父層傳入的資料,子元件直接修改會讓資料流不清楚。受控表單應該 emit 新值,或內部建立 draft,等 submit 時再回報。
Q2:Loading 狀態怎麼設計?
不要一個 loading 管所有事情。
初次載入、提交中、背景刷新、欄位驗證中,應該拆成不同狀態,對應不同 UI。
Q3:前端驗證可以取代後端驗證嗎?
不行。
前端驗證改善體驗,後端驗證保證安全與業務規則。任何重要規則都必須在後端再驗證一次。
Q4:提交失敗要清空表單嗎?
通常不要。
提交失敗應該保留使用者輸入,顯示欄位錯誤或全域錯誤,讓使用者能修正後重試。
Q5:什麼時候表單狀態要放 Pinia?
當表單跨頁、跨步驟、需要離開後保留,或多個遠距元件共同編輯時,才比較適合放 Pinia。
單一頁面或 Modal 的表單草稿通常留在局部元件或 Composable 就好。
九、 實作題
題目:設計一個使用者編輯表單
需求:
- 載入使用者資料。
- 建立 form draft。
- 支援本地驗證。
- 提交時防重複提交。
- 後端欄位錯誤回填。
- 取消時還原。
可以這樣設計:
const initialForm = ref<UserForm | null>(null)
const form = reactive<UserForm>({
name: '',
email: '',
})
const errors = reactive<Record<string, string>>({})
const initialLoading = ref(false)
const submitting = ref(false)
async function loadUser(id: string) {
initialLoading.value = true
try {
const user = await fetchUser(id)
const nextForm = toUserForm(user)
initialForm.value = nextForm
Object.assign(form, nextForm)
} finally {
initialLoading.value = false
}
}
function validate() {
errors.name = form.name ? '' : '請輸入姓名'
errors.email = isEmail(form.email) ? '' : 'Email 格式不正確'
return !errors.name && !errors.email
}
async function submit() {
if (submitting.value || !validate()) {
return
}
submitting.value = true
try {
await updateUser(form)
initialForm.value = { ...form }
} catch (error) {
applyServerError(error, errors)
} finally {
submitting.value = false
}
}
function reset() {
if (initialForm.value) {
Object.assign(form, initialForm.value)
}
}面試時補充:
- 大型表單不要用簡單 shallow copy。
- 若路由切換要提醒,可以用 dirty state。
- 若 API 支援 partial update,可以只送 changed fields。
- 若欄位有非同步驗證,要 debounce 並取消舊請求。
十、 資深視角
1. 表單最怕狀態混在一起
壞味道:
- API 原始資料和 form draft 是同一份。
- loading、submitting、refreshing 共用一個布林。
- 後端錯誤只用 toast,不回填欄位。
- 表單草稿過早放進全域 store。
- watch 太多,導致資料流難追。
表單越大,越要分清楚資料層次。
2. 非同步流程要有一致模式
團隊最好統一:
- loading 命名。
- error 結構。
- API client 錯誤格式。
- 欄位錯誤回填格式。
- retry 策略。
- cancel 策略。
- toast / modal 顯示規範。
這會比每個頁面自己寫一套更穩。
3. 體驗要尊重使用者輸入
使用者最討厭:
- 表單填到一半被清空。
- 送出失敗後不知道哪裡錯。
- 按了按鈕沒有反應。
- 重複提交造成重複資料。
- 離開頁面才發現沒儲存。
好的資料流設計最後會回到使用者體驗。
總結
| 問題 | 回答重點 |
|---|---|
| 表單資料怎麼管理 | 分清 server data、form draft、errors、submit state |
| 受控表單 | 父層擁有狀態,子層 emit 變更 |
| 驗證怎麼做 | 前端改善體驗,後端保證規則,欄位錯誤要結構化 |
| 非同步狀態 | initialLoading、submitting、refreshing、error 分層 |
| 失敗怎麼處理 | 保留草稿、回填錯誤、允許重試 |
| 什麼時候抽象 | 重複流程抽 Composable,跨頁共享才進 Store |
一句完整的面試回答可以是:
Vue 表單和非同步資料流我會先分清楚後端原始資料、表單草稿、驗證錯誤和提交狀態。簡單表單可以在元件內用
reactive管理,受控表單則由父層透過v-model或 props/emit 控制,子元件不要直接改 props。驗證要分前端同步、前端非同步和後端驗證,後端欄位錯誤要能回填。提交時要防止重複提交,管理 submitting/error,必要時取消舊請求或提供 retry。表單草稿通常不急著放 Pinia,只有跨頁、多步驟或需要保留時才放 store;重複的 loading/error/cancel 流程則可以抽成 Composable。