上一篇,我完成了新增與顯示寵物資料的流程。
使用者填完表單後,資料可以送到後端、寫進資料庫,最後顯示成 Dashboard 上的寵物卡片。
第一次看到自己的寵物出現在畫面上時,我真的很有成就感。
但資料建立完成後,很快又會遇到下一個很實際的需求:
如果寵物資料填錯了,要怎麼修改?
一開始,我覺得修改功能應該和新增差不多。
反正都是一張表單,只是新增是空白表單,修改則是先把原本的資料放進去。
使用者改完之後,再把整張表單送給後端就好了。
真正做過之後,我才發現,修改資料沒有想像中那麼單純。
我不只需要處理表單原始資料、寵物 ID、API 和資料庫更新,還第一次遇到 PUT 和 PATCH 的差別。
製作新增功能時,表單一開始是空白的。
使用者填入寵物名字、生日、體重和其他資料後,再按下送出。
但修改資料時,表單不能是空白的。
使用者按下寵物卡片上的「修改資料」後,應該先看到原本已經儲存的內容。
流程大概是:
點擊寵物卡片
↓
開啟寵物基本資料彈窗
↓
點擊修改資料
↓
帶入原本的寵物資料
↓
使用者修改欄位後儲存
↓
前端送出 PATCH 請求
↓
後端更新資料庫
↓
前端顯示最新資料
當時的我只覺得:
把原本的資料放進表單,再送一次不就好了嗎?
但開始實作後,我才知道中間有很多地方需要處理。
例如:
這些問題,都是我在真正開始製作修改功能後才遇到的。
新增與修改功能會處理不少相同的寵物資料。
新增表單包含寵物名字、種類、品種、性別、生日、體重、晶片號碼、結紮狀態、血型、毛色、備註和照片。
PawPal 的 PetProfileModal 編輯狀態會回填名字、品種、性別、生日、體重、晶片號碼、結紮狀態、血型、毛色、備註和照片;種類不是這個編輯彈窗提供修改的欄位。
但兩個功能的起始狀態並不一樣。
新增時,表單資料大多從空值開始。
修改時,Dashboard 先把點擊的寵物交給 PetProfileModal,彈窗再從 props.pet 建立編輯狀態,讓使用者可以在原本內容上繼續修改。
以下是 PetProfileModal 的「簡化概念」:
function createEditForm() {
const pet = props.pet ?? {}
return {
name: pet.name ?? '',
breed: pet.breed ?? '',
gender: normalizeGender(pet.gender),
birthday: formatBirthdayForDateInput(pet.birthday),
weight: pet.weight ?? '',
microchipNumber: pet.microchipNumber ?? pet.microchip_number ?? '',
neutered: pet.neutered ? '已結紮' : '未結紮',
// 其餘可編輯欄位依同樣方式帶入
}
}
日期會整理成日期輸入框需要的 YYYY-MM-DD;體重保留為可輸入的值;結紮狀態則在送出前轉回布林值。
以前我看到「表單預填」時,只覺得是把資料顯示在輸入框裡。
但真正實作後才理解,這些欄位同時也是之後要送出的資料狀態。
如果一開始帶入的格式不正確,使用者即使只改了一個欄位,送出時也會讓其他欄位出問題。
處理更新 API 時,我第一次比較認真接觸 PUT 和 PATCH。
在一開始的理解裡,我只知道它們都和「修改資料」有關。
看起來好像只是兩種不同的寫法。
後來才慢慢知道,它們在概念上有不同的使用情境。
PUT 通常用來更新或取代一整筆資料。
PATCH 通常用來更新其中一部分資料。
例如,先用「簡化概念」表示一筆寵物資料:
{
"name": "小花",
"weight": 8,
"furColor": "棕色"
}
如果使用者只修改體重,PATCH 的概念可以只送出:
{
"weight": 9
}
而 PUT 的概念則接近把更新後的整筆資料重新送出:
{
"name": "小花",
"weight": 9,
"furColor": "棕色"
}
PawPal 的寵物更新路由實際使用 PATCH /api/v1/pets/:id,沒有為這個功能設定 PUT 路由。
前端的 src/api/pet.js 會先整理 payload,再以 Axios 送出請求:
const request = updatePetRequestData(data, token)
const response = await axios.patch(
`${API_BASE_URL}/api/v1/pets/${id}`,
request.data,
{ headers: request.headers },
)
Request header 會帶入 Authorization: Bearer <token>。JSON payload 使用 snake_case,可提供 name、species、breed、gender、birthday、weight、microchip_number、neutered、blood_type、fur_color、notes 與 avatar_url 中的一個或多個欄位;選擇新照片時,payload 會改為帶有 avatar 檔案的 FormData。
後端不會把整筆資料一律覆蓋,而是從 Request 中取出有提供的欄位,組成 UPDATE pets SET ...。因此,PawPal 的 HTTP method 是 PATCH,資料庫也只更新 payload 內的欄位。
更新成功時,後端回傳 200 OK 與 { pet: ... };這個 pet 包含更新後的資料庫欄位。Request 資料驗證失敗時回傳 400,未登入或 Token 無效時回傳 401,找不到目標或不屬於登入會員時回傳 404,晶片號碼重複時回傳 409,未預期錯誤則回傳 500。前端 API 層會把後端的錯誤訊息整理成畫面可使用的繁體中文提示。
這讓我理解到,HTTP method 和表單實際送出的欄位範圍要分開看。
當時製作編輯功能時,我的想法比較接近:
不管使用者修改了哪一個欄位,就把整張表單全部送出去。
因為表單裡已經有原本資料,即使使用者只修改體重,其他欄位也會繼續保留原本的值。
這種做法對當時的我來說比較容易理解。
我不需要另外判斷每個欄位是否改變,只要把目前表單的狀態送給後端即可。
PawPal 的編輯彈窗也是從目前表單狀態建立更新資料;送出前的 mapPetToApi 會把前端欄位轉成後端欄位,並略過空字串、null 和 undefined,同時保留 false 與 0。
以下只列出部分欄位,作為「簡化概念」:
{
microchip_number: microchipNumber,
neutered,
blood_type: bloodType,
fur_color: furColor,
notes: note,
}
這樣一來,microchipNumber、bloodType、furColor 和 note 不會直接混進後端使用的 snake_case payload。
我也開始思考:
照片沒有重新選擇時,selectedPhotoFile 維持空值,更新請求不會帶 avatar 檔案或新的 avatar_url;資料庫的原本照片欄位因此保留。選擇新照片後,前端改用 FormData,把裁切後檔案放進 avatar 欄位,後端完成擁有權檢查後才上傳並寫入新的 avatar_url。
修改功能和新增功能雖然都在送資料,但更新舊資料時,還需要更小心保留原本的內容。
新增資料時,還沒有一筆已存在的寵物可以指定。
但修改功能不一樣。
後端必須知道使用者目前要修改的是哪一筆資料。
所以修改請求除了表單內容,還需要知道目標寵物的 ID。
PawPal 的彈窗送出時會帶入目前寵物的 id:
emit('update', {
id: props.pet?.id,
data: buildUpdatePayload(),
})
Dashboard 接到事件後,呼叫 petStore.updatePet(id, data, authStore.token);API 路徑中的 :id 就是這個寵物 ID。
後端先用 Bearer Token 取得登入者的 userId。
資料庫更新條件可以用「簡化概念」表示為:
UPDATE pets
SET ...
WHERE id = $1 AND user_id = $2
RETURNING ...
其中:
id 用來指定要修改哪一隻寵物。user_id 確保只能更新登入會員自己的寵物。這也是我第一次更明確地理解,資料庫裡的 ID 不只是流水號。
對前端來說,它是找到特定資料的重要依據。
找不到 ID、寵物不存在,或寵物不屬於登入者時,後端都回傳 404;它不以不同訊息暴露其他會員的寵物資料。
完成更新 API 後,我原本以為,只要後端回傳成功,修改功能就完成了。
但我很快又遇到和新增功能相似的問題:
API 已經成功了,為什麼卡片還是顯示舊資料?
資料庫更新成功,只代表後端資料已經改變。
如果前端沒有同步更新寵物清單,畫面就可能繼續保留修改前的狀態。
因此更新完成後,前端需要用後端回傳的新資料替換原本的寵物資料,再讓畫面重新渲染。
PawPal 的 petStore.updatePet 會直接更新同一個 ID 的項目,不會在更新成功後重新呼叫 GET:
pets.value = pets.value.map((pet) => {
if (pet.id !== petId) return pet
return normalizePet({ ...pet, ...updatedPet })
})
Dashboard 的 dashboardPets 是從 petStore.pets 計算出來的資料,接著交給 PetCard 顯示。因此 store 更新後,卡片會跟著重新渲染,不需要手動重新整理頁面。
這個問題也讓我更理解,新增、修改和刪除雖然是不同操作,但都有一個共同點:
資料庫改變後,前端狀態也要跟著更新。
在測試修改功能時,我曾遇過一個讓我很困惑的情況。
畫面顯示修改成功後,寵物卡片沒有立刻變化。
我需要手動重新整理頁面,才會看到新的內容。
當時我第一個反應是:
修改到底有沒有成功?
因為對使用者來說,如果按下儲存後畫面沒有變化,就很難知道操作是否真的完成。
這件事讓我發現,API 是否成功和使用者是否感受到成功,是兩件不同的事。
即使資料庫已經更新,如果畫面仍然顯示舊資料,使用者還是會認為功能壞掉了。
PawPal 的更新流程最後改為直接替換 petStore.pets 裡同一個 ID 的資料。當時的 PR 測試紀錄也確認,儲存後彈窗會顯示更新後的資料,Dashboard 卡片不必再依賴手動重新整理。
除了卡片資料是否更新,我也開始注意操作後的提示。
使用者按下儲存後,需要知道:
PawPal 在送出更新時會先把 isPetSaving 設為 true,儲存按鈕會顯示「儲存中...」並停用,避免重複送出。
更新成功後,Dashboard 會呼叫 toastStore.showToast(result.message || '寵物資料更新成功')。ToastNotification 掛在 App 最上層,以固定在畫面上方的成功提示顯示,預設停留 2.5 秒。
更新失敗時,錯誤訊息不會由這段更新流程送進 Toast,而是放進 petUpdateError,顯示在寵物基本資料彈窗內。
這讓我知道,除了更新資料之外,操作結果是否有明確提示,同樣會影響使用者是否知道功能成功。
第一次把自己的寵物新增到首頁時,我已經覺得很有成就感。
但完成修改功能後,那種感覺又更進一步。
因為畫面上的資料不再只是固定顯示。
我可以打開編輯表單,修改寵物內容,再看到卡片跟著改變。
這讓整個功能變得更像一個真的可以使用的產品。
使用者不需要因為填錯資料就刪掉重來,也不需要永遠接受第一次輸入的內容。
資料可以被調整、修正和持續使用。
對現在的我來說,這已經是一個很基本的功能。
但對當時的我來說,它代表我開始理解:
前端畫面不是只負責顯示資料,也要讓使用者可以管理資料。
如果現在重新製作修改功能,我會先確認幾件事:
null 和未提供欄位要如何處理。以前我比較容易把注意力放在:
API 有沒有成功送出?
現在則會繼續問:
使用者看到的結果,是否真的和資料庫一致?
這也是修改功能帶給我最重要的學習。
完成修改資料功能後,我學到:
以前我覺得修改功能只是把資料再送一次。
真正實作後才發現,更新資料其實同時牽涉到原始資料、表單狀態、API 格式、資料庫與前端畫面。
也因為這次經驗,我開始知道:
功能完成的標準,不只是後端回傳成功,而是使用者能立即看到正確的結果。
完成新增、顯示與修改之後,寵物資料還差最後一個基本操作:
刪除。
原本我以為刪除功能只要呼叫 DELETE API 就完成了。
但實際測試時,我卻遇到資料看起來消失,重新整理後又跑回來的情況。
下一篇:
Day 10|刪除功能看似簡單,但我踩了一個大坑。