# 🏅 Day 20 - Nuxt3 狀態管理 - Pinia - ( 2 ) ## 今日學習目標 - 學習如何在 Nuxt3 中操作 Pinia,並結合伺服器端的狀態管理 - 學習如何在 API 請求之後,將取得的資料同步至 Pinia - 理解伺服器端與客戶端操作 Pinia 的差異 ## 前言 在 Day19 的學習範例中,我們使用 Pinia 建立了一個用於管理待辦事項列表 ( todolist ) 的 store ,並在客戶端實作新增、刪除待辦事項的功能。今天我們將學習如何透過 API 來取得、新增和刪除待辦事項,並使用 Pinia 來管理和同步這些資料狀態。 ### 範例準備 今天將使用以下三支 API 進行講解,在操作待辦事項之前,需要先註冊並登入 TodoList 的帳號 。 請求標頭、帶入資料與成功、失敗的訊息格式可以閱讀 [API 文件](https://todolist-api.hexschool.io/doc/#/%E4%BD%BF%E7%94%A8%E8%80%85) 。 - 取得待辦事項功能 : ``` https://todolist-api.hexschool.io/todos/ method:'GET' // 請求標頭 headers:{ Authorization // 需帶入 token } ``` - 新增待辦事項功能 : ``` https://todolist-api.hexschool.io/todos/ method:'POST' // 請求標頭 headers:{ Authorization // 需帶入 token } 請求資料格式: { "content": "事項" } ``` - 刪除待辦事項功能 : ``` https://todolist-api.hexschool.io/todos/{id} // 傳入 todo 的 id methods:'DELETE' // 請求標頭 headers:{ Authorization // 需帶入 token } ``` ## 在伺服器端與客戶端操作 Pinia ### 伺服器端 Nuxt 初始化時,Pinia 的 store 會在伺服器端被初始化。在伺服器端可以預先發送 API 請求並將取得的資料存入 store 。伺服器端初始化完成後,store 的狀態會與 HTML 一起傳遞至客戶端,這樣客戶端在首次載入時可以直接從 store 中取出資料。 在伺服器端可以透過 `useFetch` 或 `useAsyncData` 發送 API 請求並將回應結果存入 Pinia。例如下方結構使用了 `useFetch` 在頁面中取得任務列表的資料,並將 `tasks`同步至 `taskStore`。使用 `useFetch` 可以在伺服器端預先取得資料,但是傳遞至客戶端會再重複執行一次,將 `tasks.value` 資料寫入 `taskStore.taskList` 的動作。 ```html // 伺服器端執行: 1. 取出 useTaskStore() 2. await useFetch 發出 GET 請求並回傳資料。回傳的資料也會一起被傳遞至客戶端 3. 把 tasks 列表資料寫入 useTaskStore 4. 將 taskStore.taskList 渲染至 HTML 元素 ( 完成伺服器端渲染 ) // 客戶端執行: 1. 取出 useTaskStore() 2. 把 tasks列表資料寫入 useTaskStore 3. 將 taskStore.todos 渲染至 HTML 元素 <script setup> const taskStore = useTaskStore(); const { data: tasks } = await useFetch('https://example.com/tasks/', { headers: { Authorization }, }); // 將資料同步到 Pinia 中 taskStore.taskList= tasks.value; </script> <template> {{ taskStore.taskList }} </template> ``` 為了避免 `useFetch()` 在客戶端重複把 `tasks.value` 資料寫入 `taskStore.taskList`,可以改用 Pinia 官方 [建議的寫法](https://pinia.vuejs.org/ssr/nuxt.html#Awaiting-for-actions-in-pages) ,以 `useAsyncData`在伺服器端執行 `$fetch` 送出請求。`$fetch` 請求回傳後立刻將資料寫入伺服器端的 `useTaskStore`,而在客戶端只需直接取用伺服器端的資料。 ```html // 伺服器端執行: 1. 取出 useTaskStore() 2. await useAsyncData 發出 GET 請求,把 tasks 列表資料寫入 useTaskStore 3. 將 taskStore.todos 渲染至 HTML 元素 ( 完成伺服器端渲染 ) // 客戶端執行: 1. 取出 useTaskStore() 2. 將 taskStore.taskList 渲染至模板 <script setup> const taskStore = useTaskStore(); // 注意: 這邊不解構回傳的 data await useAsyncData('tasks', () => { const tasks = $fetch('https://example.com/tasks/', { headers: { Authorization }, }) // 在伺服器端執行,將資料寫入伺服器端的 useTaskStore taskStore.taskList = tasks; return tasks; }); </script> <template> {{ taskStore.taskList }} </template> ``` ### 客戶端 伺服器端初始化完畢,Pinia 的資料狀態將被傳遞至客戶端,客戶端可以繼續使用 Pinia store 的狀態。在客戶端,可以使用 `$fetch` 發送 API 請求,並在 Pinia store 中更新狀態。例如下方結構,在客戶端透過點擊事件新增代辦事項時,可以在 `$fetch` 發送請求之後將回傳的資料更新至`taskStore`。 ```jsx <script setup> const taskStore = useTaskStore(); const addTask = async (content) => { const newTask = await $fetch('https://example.com/tasks/', { method: 'POST', headers: { Authorization }, body: { content }, }); // 更新 Pinia store taskStore.taskList.push(newTask); }; </script> ``` ## 待辦事項功能實作 在開始實作代辦事項之間,需要先註冊並登入一個帳號再操作哦 ! 註冊和登入的功能已在今日講解範例的 `/pages/index.vue` 中提供。 ### 取得全部待辦事項 在 `stores/todo.js` 中設定 `getTodo` 方法,透過 `useAsyncData` 在伺服器端初始化待辦事項列表。當 `$fetch` 請求回傳後, 將待辦事項列表存入 `todoList`。 ```jsx // stores/todo.js const todoList = ref([]); const isFetch = ref(false); // 在伺服器端取得 todo 列表 const getTodo = async (token) => { await useAsyncData("getTodoList", async () => { const { data } = await $fetch( "https://todolist-api.hexschool.io/todos/", { headers: { Authorization: token }, } ); todoList.value = data; return data; }); }; ``` 接著,在 `/pages/todo.vue` 中使用 `await useAsyncData()` 執行 `getTodo`,並將從 cookie 取得的 token 傳入,以便伺服器端初始化資料。 ```html <!-- /pages/todo.vue --> <script setup> const cookie = useCookie("auth"); const todoStore = useTodoStore(); const { getTodo } = todoStore; // 在伺服器端初始化資料,傳遞給客戶端。 await useAsyncData("todos", () => getTodo(cookie.value).then(() => true)); </script> ``` ### 新增待辦事項 在 `stores/todo.js` 中設定 `addTodo` 方法。由於新增待辦事項是由使用者在客戶端點擊觸發,因此 請求需透過 `$fetch` 發出 。請求成功後,`$fetch` 會回傳新增的待辦事項 `newTodo`, 可以直接以 `push()` 的方式更新至 `todoList`。 ```jsx // stores/todo.js const todoList = ref([]); const isFetch = ref(false); // 新增待辦事項 ( 客戶端操作 ) const addTodo = async (content, token) => { isFetch.value = true; try { const { newTodo } = await $fetch( "https://todolist-api.hexschool.io/todos/", { method: "POST", headers: { Authorization: token }, body: { content }, } ); /* newTodo 為新增的待辦事項,格式如下 { "id", "createTime, "content", "status": } */ todoList.value.push(newTodo); } catch (error) { console.log(error); } finally { isFetch.value = false; } }; ``` ```html <!-- /pages/todo.vue --> <script setup> const cookie = useCookie("auth"); const todoStore = useTodoStore(); const { addTodo } = todoStore; const newTodo = ref(""); // 點擊後,透過 todoStore 的 addTodo 方法將新增代辦事項 const addNewTodo = () => { if (newTodo.value) { addTodo(newTodo.value, cookie.value); newTodo.value = ""; } }; </script> <template> <div class="input-group mb-3"> <input v-model.trim="newTodo" class="form-control" placeholder="新增待辦事項" /> <button @click="addNewTodo" class="btn btn-primary" :disabled="!newTodo.length || isFetch" > 新增 Todo </button> </div> </template> ``` 需要注意,如果將 `todoList.value.push(newTodo);`的資料更新方式替換為 `stores/todo.js` 中的 `getTodo` 方法,在瀏覽器 console 會發生下圖 `[nuxt] [useAsyncData] Component is already mounted, please use $fetch instead.`的警告。 ![day20-1](https://hackmd.io/_uploads/SkJEjapzJx.png) 造成警告的原因是 `getTodo` 方法內部使用了 `useAsyncData`,而 `useAsyncData` 主要用於伺服器端渲染,在元件初始化時加載資料。當元件已經掛載到客戶端後,繼續使用 `useAsyncData` 進行資料請求會觸發此警告。如果需要在客戶端進行資料請求,建議改用 `$fetch`,不過在這個範例的做法可以改用陣列 `push()` 方法將新增的資料更新至 `todoList` 資料,減少請求 API 的次數。 ```jsx // 新增待辦事項 ( 客戶端操作 ) const addTodo = async (content, token) => { isFetch.value = true; try { const { newTodo } = await $fetch( "https://todolist-api.hexschool.io/todos/", { method: "POST", headers: { Authorization: token }, body: { content }, } ); // await getTodo(token); // 因為不應在客戶端操作 useAsyncData,所以不使用 getTodo 方法更新 todoList 資料 // 改成使用陣列 push() 更新資料來以減少 API 請求次數 todoList.value.push(newTodo); } catch (error) { console.dir(error); } finally { isFetch.value = false; } }; ``` ### 刪除待辦事項 在 `stores/todo.js` 中設定 `removeTodo`方法。使用者點擊刪除按鈕後,將待辦事項的 `id` 與 `token` 傳入`$fetch` 發出 `DELETE` 請求。請求成功後,透過待辦事項的 `id` 將該筆事項從 `todoList` 刪除。 ```jsx // stores/todo.js const todoList = ref([]); const isFetch = ref(false); // 刪除待辦事項 ( 客戶端操作 ) const removeTodo = async (id, cookie) => { const todoIndex = todoList.value.findIndex((todo) => todo.id === id); isFetch.value = true; try { await $fetch(`https://todolist-api.hexschool.io/todos/${id}`, { method: "DELETE", headers: { Authorization: cookie }, }); todoList.value.splice(todoIndex, 1); } catch (error) { console.log(error); } finally { isFetch.value = false; } }; ``` ```html <!-- /pages/todo.vue --> <script setup> const cookie = useCookie("auth"); const todoStore = useTodoStore(); const { removeTodo } = todoStore; // 移除 Todo const deleteTodo = (id) => { removeTodo(id, cookie.value); }; </script> <template> <ul class="list-group"> <li v-for="todo in todoList" :key="todo.id" class="list-group-item d-flex justify-content-between align-items-center" > <span> {{ todo.content }} </span> <button class="btn btn-danger btn-sm" @click="deleteTodo(todo.id)" <=== 在客戶端,透過點擊事件處發刪除請求 :disabled="isFetch" > 刪除 </button> </li> </ul> </template> ``` <br> > 今日學習的[範例 Code - 資料夾: day20-pinia-api-example](https://github.com/hexschool/nuxt-daily-tasks-2024) ## 題目 根據最終任務訂房網站 [設計稿](https://www.figma.com/design/6pTFrdb5a1lYKmMnFeT5Mf/%E5%85%AD%E8%A7%92-Project-%2F-%E9%85%92%E5%BA%97%E8%A8%82%E6%88%BF%E7%B6%B2%E7%AB%99?t=wmvFC4GdPPgblvwT-0) ,在訂房流程中,當使用者在「房型詳細頁」點擊「立即預訂」按鈕後需呈現該房型的詳細資訊。因此需要將房型詳細資料加入全域狀態管理。 請 fork 這一份 [模板](https://github.com/jasonlu0525/nuxt3-live-question/tree/day20-pinia-api) ,完成以下條件 : - 在 `/pages/room.vue` 的房型列表中,點擊房型進入房型詳細頁(`/pages/room/[id].vue`),將取得的房型資料寫入 Pinia Store ,並渲染於頁面模板。 ```jsx // 將房型資料 data 改成使用 Pinia 管理 const { data, error } = await useAsyncData(`room-data`, async () => { const response = await $fetch(`/rooms/${id}`, { baseURL: "https://nuxr3.zeabur.app/api/v1", }); return response.result; }); ``` - 在房型詳細頁點擊「立即預訂」按鈕後,導向 `/pages/booking.vue` 預約頁面。在此頁面中,將房型資料從 Pinia Store 取出,渲染於頁面模板。 ```jsx // /pages/booking.vue <script setup> // 將 bookingInfo 改成使用 Pinia 的資料 const bookingInfo = ref({}); </script> ``` - 補充,Pinia Store 的檔案已於 `stores/booking.js` 提供,請將匯出的 Store 名稱命名為 `useBookingStore` 。 ```jsx /stores/booking.js // export const useBookingStore = ``` ## 回報流程 將答案上傳至 GitHub 並複製 GitHub repo 連結貼至底下回報就算完成了喔 ! 解答位置請參考下圖(需打開程式碼的部分觀看) ![](https://i.imgur.com/vftL5i0.png) <!-- 解答 : https://github.com/jasonlu0525/nuxt3-live-answer/tree/day20-pinia-api --> 回報區 --- | # | Discord | Github / 答案 | | --- | ----- | ----- | |1|眼睛|[Github](https://github.com/Thrizzacode/nuxt3-live-question/tree/day20-pinia-api)| |2|Steven|[Github](https://github.com/y7516552/nuxt3-live-question/tree/day20)| |3|dragon|[Github](https://github.com/peterlife0617/2024-nuxt-training-homework01/tree/feature/day20)| | 4 | Rocky |[Github](https://github.com/WuRocky/Nuxt-Day20-Pinia-2.git)| | 5 | LinaChen |[Github](https://github.com/Lina-SHU/nuxt3-live-question)| | 6 | Johnson |[Github](https://github.com/tttom3669/2024_hex_nuxt_daily/tree/day20-pinia-api)| | 7 | JimLin |[Github](https://github.com/junhoulin/Nuxt3-hw-day-after10/tree/day20)| | 8 | tanuki狸 |[Github](https://github.com/tanukili/Nuxt-2024-week01-2/tree/day20-pinia-api)| |9|Ariel|[Github](https://github.com/Ariel0508/nuxt3-hw/tree/day20-pinia-api)| |10|hsin yu|[Github](https://github.com/dogwantfly/nuxt3-daily-task-live-question/tree/day20-pinia-api)| <!-- |---|---|[Github]()| -->