跳至主要內容
Skip to content

Vue 表單、非同步狀態與資料流設計

表單和非同步狀態是 Vue 專案裡最容易變亂的地方。看起來只是幾個 input 和一個 submit,但實務上會牽涉:初始資料、草稿狀態、驗證、loading、錯誤回填、取消請求、重試、樂觀更新、離開頁面提醒,以及資料到底要放在元件、Composable 還是 Store。

一句話回答:Vue 表單與非同步資料流的核心,是把「表單草稿」、「後端原始資料」、「驗證錯誤」、「提交狀態」分清楚;用 v-model 或受控元件維持明確資料流,用 computed/watch 處理衍生狀態與副作用,提交時管理 loading/error/cancel/retry,必要時把可復用流程抽成 Composable。


一、 必考觀念

1. 表單狀態不等於後端資料

常見錯誤是直接把 API 回來的資料綁到表單上:

typescript
const user = ref<User | null>(null)

async function loadUser() {
  user.value = await fetchUser()
}

然後模板直接編輯 user

問題是:

  • 使用者還沒按儲存,原始資料已經被改掉。
  • 取消編輯時不好回復。
  • dirty 狀態不好判斷。
  • 後端錯誤回填和本地草稿混在一起。

更好的模型是分開:

text
server data:後端原始資料
form draft:表單草稿
validation errors:驗證錯誤
submit state:提交狀態

2. 表單資料流要明確

簡單表單可以直接在元件內管理:

typescript
const form = reactive({
  name: '',
  email: '',
})

複雜表單要清楚定義:

  • 初始值從哪裡來?
  • 使用者修改的是哪份資料?
  • 按取消時怎麼還原?
  • 按儲存時送出哪些欄位?
  • 後端錯誤怎麼顯示?
  • 切路由時草稿要不要保留?

這些問題比使用哪個 UI library 更重要。

3. 非同步狀態至少要有四件事

一個基本請求流程通常包含:

typescript
const data = ref(null)
const loading = ref(false)
const error = ref<unknown>(null)

以及一個動作:

typescript
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 是:

vue
<UserForm
  :model-value="form"
  @update:model-value="form = $event"
/>

或:

vue
<UserForm v-model="form" />

這代表:

  • 父層擁有表單資料。
  • 子層負責渲染 input。
  • 子層透過 emit 回報變更。

延伸讀:v-model 雙向綁定的原理

2. 子元件不要偷改 props

不建議:

typescript
props.modelValue.name = 'Ada'

這會讓資料流變得不清楚。

更好的做法是 emit 新值:

typescript
const emit = defineEmits<{
  'update:modelValue': [value: UserForm]
}>()

function updateName(name: string) {
  emit('update:modelValue', {
    ...props.modelValue,
    name,
  })
}

這樣父層仍然是狀態歸屬點。

3. 複雜表單可以內部維護 draft

如果表單內部很複雜,也可以在子元件建立 draft,但要定義同步策略。

typescript
const draft = reactive(cloneForm(props.modelValue))

watch(
  () => props.modelValue,
  value => {
    Object.assign(draft, cloneForm(value))
  }
)

function submit() {
  emit('submit', cloneForm(draft))
}

這種模式適合:

  • Modal 編輯。
  • 需要取消還原。
  • 表單中間有很多暫存互動。
  • 只有按確認才送回父層。

三、 表單驗證

1. 驗證分層

表單驗證通常有三層:

類型範例
前端同步驗證必填、格式、長度
前端非同步驗證帳號是否重複、邀請碼是否有效
後端驗證權限、業務規則、資料一致性

前端驗證改善體驗,但不能取代後端驗證。

2. 驗證錯誤要有結構

不要只用一個字串保存所有錯誤。

typescript
const errors = reactive<Record<string, string>>({})

例如:

typescript
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,不要每個字都請求。

typescript
watch(
  () => form.username,
  value => {
    debouncedCheckUsername(value)
  }
)

4. 後端錯誤要能回填欄位

後端可能回:

json
{
  "message": "Validation failed",
  "fields": {
    "email": "Email already exists"
  }
}

前端應該能映射到欄位錯誤:

typescript
function applyServerErrors(fields: Record<string, string>) {
  for (const [field, message] of Object.entries(fields)) {
    errors[field] = message
  }
}

不要只顯示一個籠統 toast,否則使用者不知道哪裡要改。


四、 提交流程與非同步狀態

1. 標準提交流程

一個清楚的 submit 流程:

text
檢查本地驗證
-> 設定 submitting
-> 清空舊錯誤
-> 發 API
-> 成功後更新資料或跳轉
-> 失敗時顯示錯誤
-> finally 還原 submitting

範例:

typescript
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:

vue
<button
  :disabled="submitting"
  @click="submit"
>
  儲存
</button>

但只 disabled 還不夠,submit 函式本身也應該防守:

typescript
async function submit() {
  if (submitting.value) {
    return
  }

  // ...
}

因為使用者可能用 Enter、快捷鍵或其他方式觸發提交。

3. 取消請求

如果表單依賴非同步查詢,例如搜尋使用者、驗證帳號、載入城市資料,就要處理取消。

typescript
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 管全部。

更清楚:

typescript
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。

typescript
const isEmpty = computed(() => {
  return !loading.value && !error.value && list.value.length === 0
})

Empty state 應該給使用者下一步:

  • 調整篩選。
  • 新增第一筆資料。
  • 清除搜尋條件。

4. Dirty state

Dirty 代表表單目前內容和初始內容不同。

typescript
const dirty = computed(() => {
  return JSON.stringify(form) !== JSON.stringify(initialForm.value)
})

實務上大型表單不要直接用 JSON stringify,可以:

  • 只比較重要欄位。
  • 在 input change 時標記 dirty。
  • 使用表單庫的 dirty tracking。

Dirty 可用於:

  • 離開頁面提醒。
  • 儲存按鈕 disabled。
  • 顯示「尚未儲存」提示。

六、 樂觀更新與回滾

1. 什麼時候適合樂觀更新?

適合:

  • 收藏。
  • 點讚。
  • 勾選。
  • 排序。
  • 標記已讀。

這些操作成功率高、失敗可回滾,適合先更新 UI。

不適合:

  • 金流。
  • 庫存。
  • 權限。
  • 高衝突協作。

2. 回滾要保存快照

typescript
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 範例

typescript
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。
  • 支援本地驗證。
  • 提交時防重複提交。
  • 後端欄位錯誤回填。
  • 取消時還原。

可以這樣設計:

typescript
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。

延伸閱讀