---
tags: Vue.js
---
# Nuxt3-project5 Todo List
原始碼:https://github.com/WangShuan/nuxt3-05-todolist
## 建立與啟動 Nuxt 專案
開啟終端機,`cd` 到桌面或任何希望創建該專案的位置
執行命令:
```shell
npx nuxi init 05-todo-list
```
完成後,根據提示
先 `cd` 到專案目錄 `05-todo-list` 中
執行命令:
```shell
npm install
```
安裝所有依賴項目
此時會發現專案目錄中**生成了 `node_modules` 資料夾**
確認您的專案已成功安裝好所有依賴後
即可執行命令:
```shell
npm run dev
```
啟動 Nuxt 應用程序。
## 專案說明
本專案使用 Nuxt3 的 server 目錄建立 API
主要有以下幾個 API:
1. GET: `/api/todo` - 獲取所有 todo 項目
2. POST: `/api/todo` - 新增一個 todo 項目
3. DELETE: `/api/todo` - 刪除已完成的 todo 項目
4. PUT: `/api/todo/:id` - 切換特定 id 的 todo 項目的完成狀態
5. DELETE: `/api/todo/:id` - 刪除特定 id 的 todo 項目
## Server 建立方式
在 Nuxt3 中透過結合 [Nitro Server](https://nitro.unjs.io/) 可以自行創建 Web Servers 以建立 API
其建立方式只需要在項目根目錄中新增 `Servers` 資料夾
並於 `Servers` 資料夾中新增 `api` 資料夾
最後於 `api` 資料夾中即可開始規劃自己的 API 路徑
比如本專案中 `/api/todo` 就表示要先在 `api` 資料夾底下新增一個 `todo` 資料夾,
接著於 `todo` 資料夾中建立對應的 `method` 檔案撰寫 HTTP Request
舉例如下:
```typescript
// 在 server 資料夾中新增 db.ts 存放一個 todos 陣列當作基底資料
import { db } from '@/server/db';
// 匯出一個 defineEventHandler 函數
export default defineEventHandler(() => {
// return db.todos 給前端接收 todos 的資料
return { todos: db.todos }
})
```
由上述舉例可知,在每個檔案預設需要匯出 defineEventHandler() 函數,
並在函數中執行一個新的函數以處理邏輯最終 return 給前端結果。
另外在 `/servers/api` 資料夾中,主要可以通過副檔名的方式指定其請求的 `method`
比如 GET 請求的副檔名就會是 `.get.ts`; POST 請求的副檔名則為 `.post.ts` 依此類推
而像 `/api/todo/:id` 的 `api` 檔案就如 `pages` 的路由建立方式一樣,
建立的檔案名稱為 `[id].put.ts` => 將 `id` 這個動態路由使用 `[]` 包起來即可
### GET 獲取所有 todo 項目
在 Servers 中可通過以下程式碼建立 API 傳遞資料給前端:
```typescript
// 檔案路徑: /server/api/todo/index.get.ts
import { db } from '@/server/db';
export default defineEventHandler(() => {
return { todos: db.todos }
})
```
在前端可通過以下程式碼獲取 todos 資料:
```typescript
const { data: todos, refresh: refreshTodos } = useAsyncData('todo', async () => {
const res = await $fetch('/api/todo')
return res.todos;
});
```
### POST 新增一個 todo 項目
在 Servers 中可通過以下程式碼建立 API 傳遞資料給前端:
```typescript
// 檔案路徑: /server/api/todo/index.post.ts
import { db } from '@/server/db';
import { v4 as uuidv4 } from 'uuid'; // 安裝並引入 uuid 已生成隨機亂數的 id
export default defineEventHandler(async (e) => {
const body = await readBody(e); // 獲取 post body 內容
if (!body.content) { // 如果 post body 不存在則拋出錯誤
throw createError({
statusCode: 400,
statusMessage: "錯誤!無法建立空項目。"
})
};
const newTodo = { // 建立新 todo
content: body.content, // 傳入 post body 內容
checked: false, // 設置初始 checked
id: uuidv4() // 設定 id 為 uuid
};
db.todos.push(newTodo); // 往 todos 資料 push 新增 todo
return newTodo;
})
```
在前端可通過以下程式碼新增 todo:
```typescript
const addTodo = (item: string) => { // item 從 app.vue 檔案中,通過 `() => addTodo(todoTemp)` 傳入
if (!item) return;
useFetch('/api/todo', {
method: "POST",
body: { content: item } // 傳入 todo 事項內容
}).then(() => {
item = ""; // 清空 todo 事項內容
refreshTodos(); // 重新獲取 todos 資料
});
};
```
### DELETE 刪除已完成的 todo 項目
在 Servers 中可通過以下程式碼建立 API 傳遞資料給前端:
```typescript
// 檔案路徑: /server/api/todo/index.delete.ts
import { db } from '@/server/db';
export default defineEventHandler(() => {
db.todos = db.todos.filter(item => item.checked === false)
return db.todos
})
```
在前端可通過以下程式碼刪除已完成的 todo 項目:
```typescript
const deleteAllDone = () => {
if (confirm('是否確定要清除所有已完成項目?注意!此動作無法復原!')) {
useFetch('/api/todo', {
method: 'delete'
}).then(() => refreshTodos()); // 重新獲取 todos 資料
}
};
```
### PUT 切換特定 id 的 todo 項目的完成狀態
在 Servers 中可通過以下程式碼建立 API 傳遞資料給前端:
```typescript=
// 檔案路徑: /server/api/todo/[id].put.ts
import { db } from '@/server/db';
export default defineEventHandler((event) => {
const id = event.context.params?.id; // 獲取 id
const i = db.todos.findIndex(item => item.id == id); // 獲取索引值
if (!db.todos[i]) { // 如果 i 不存在則拋出錯誤
throw createError({
statusCode: 404,
statusMessage: "錯誤!項目不存在。"
})
};
const newTodo = { // 更新 todo 的完成狀態
...db.todos[i],
checked: !db.todos[i].checked
}
db.todos[i] = newTodo // 將 todos 中的項目更新
return newTodo
})
```
在前端可通過以下程式碼獲取 todos 資料:
```typescript
const updateTodo = (id) => {
useFetch(`/api/todo/${id}`, {
method: 'put',
}).then(() => refreshTodos()); // 重新獲取 todos 資料
};
```
### DELETE 刪除特定 id 的 todo 項目
在 Servers 中可通過以下程式碼建立 API 傳遞資料給前端:
```typescript
// 檔案路徑: /server/api/todo/[id].delete.ts
import { db } from '@/server/db';
export default defineEventHandler((event) => {
const id = event.context.params?.id; // 獲取 id
const i = db.todos.findIndex(item => item.id == id); // 獲取索引值
if (!db.todos[i]) { // 如果 i 不存在則拋出錯誤
throw createError({
statusCode: 404,
statusMessage: "錯誤!項目不存在。"
})
};
db.todos.splice(i, 1) // 從 todos 中刪除索引值開始的一個項目
return db.todos
})
```
在前端可通過以下程式碼刪除特定 id 的 todo 項目:
```typescript
const deleteTodo = (id) => {
useFetch(`/api/todo/${id}`, {
method: 'delete'
}).then(() => refreshTodos()); // 重新獲取 todos 資料
};
```
## 將前端 API 請求封裝為 composables
在項目根目錄中建立 composables 資料夾
在 composables 資料夾底下新增 useTodo.ts:
```typescript
const useTodo = () => {
// 獲取 todos 資料
const { data: todos, refresh: refreshTodos } = useAsyncData('todos', async () => {
const res = await $fetch('/api/todo');
return res.todos;
});
// 刪除所有已完成項目
const deleteAllDone = () => {
if (confirm('是否確定要清除所有已完成項目?注意!此動作無法復原!')) {
useFetch('/api/todo', {
method: 'delete'
}).then(() => refreshTodos());
}
};
// 新增項目
const addTodo = (item: string) => {
if (!item) return;
useFetch('/api/todo', {
method: "POST",
body: { content: item }
}).then(() => {
item = "";
refreshTodos();
});
};
// 刪除項目
const deleteTodo = (id: string) => {
useFetch(`/api/todo/${id}`, {
method: 'delete'
}).then(() => refreshTodos());
};
// 更新項目
const updateTodo = (id: string) => {
useFetch(`/api/todo/${id}`, {
method: 'put',
}).then(() => refreshTodos());
};
return { todos, addTodo, updateTodo, deleteAllDone, deleteTodo }
}
export default useTodo
```
## 於前端使用 composables
這邊簡單準備一個 template:
https://codepen.io/WangShuan/pen/gOrzLVa?editors=1100
>注意:此 template 有使用到 bootstrap 樣式
>請記得於 nuxt.config.ts 中添加 [bootstrap CSS](https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/css/bootstrap.min.css) 的 link
首先將 codepen 內容轉貼到 app.vue 檔案中
在輸入框的地方設定 v-model 為 todoTemp
並於新增項目的按鈕綁定 addTodo 方法:
```htmlembedded
<div class="row g-2 mt-3">
<div class="col-md-8 col-xxl-9">
<input type="text" v-model="todoTemp" placeholder="Enter todo's thing here..." class="w-100">
</div>
<div class="col-md-4 col-xxl-3">
<button @click="addNewTodo()" class="btn btn-sm btn-success w-100">Add</button>
</div>
</div>
```
設置 tab 欄切換:
```htmlembedded
<ul class="list-unstyled my-3">
<li class="row g-2">
<div class="col-4">
<button class="w-100 btn btn-sm custom-btn-tab" :class="{ 'active': toggleTab === 'all' }" @click="toggleTab = 'all'">All</button>
</div>
<div class="col-4">
<button class="w-100 btn btn-sm custom-btn-tab" :class="{ 'active': toggleTab === 'undo' }" @click="toggleTab = 'undo'">Undo</button>
</div>
<div class="col-4">
<button class="w-100 btn btn-sm custom-btn-tab" :class="{ 'active': toggleTab === 'done' }" @click="toggleTab = 'done'">Done</button>
</div>
</li>
</ul>
```
於列表 ul 中撰寫 v-for 顯示 todos 資料並綁定刪除與更新事件:
```htmlembedded
<ul class="list-unstyled todos my-3" v-if="filterTodos.length">
<li v-for="item in filterTodos" :key="item.id" class="todos-item">
<div class="col-9 col-xxl-10 hover-bg">
<input type="checkbox" v-model="item.checked" @click="() => updateTodo(item.id)" :id="item.id">
<label class="w-100" :for="item.id" :class="{ 'text-decoration-line-through': item.checked }">
{{ item.content }}</label>
</div>
<div class="col-3 col-xxl-2">
<button @click="() => deleteTodo(item.id)" class="btn custom-btn-delete btn-sm w-100">Delete</button>
</div>
</li>
</ul>
```
計算並顯示完成數量、綁定刪除所有已完成項目事件:
```htmlembedded
<div class="mt-3 d-flex justify-content-between align-items-center">
<p class="text-start">🎉 Already finish {{ doneCount }} thing !</p>
<button :class="{ 'disabled': doneCount === 0 }" class="btn btn-sm custom-btn-dark" @click.prevent="() => deleteAllDone()">Delete All Done</button>
</div>
```
撰寫上方用到的所有 JS:
```htmlembedded
<script setup>
// 引入 composables
const { todos, updateTodo, deleteTodo, deleteAllDone, addTodo } = useTodo();
// 紀錄當前的 tab
const toggleTab = ref("all");
// 通過 computed 搭配 filter 方法呈現不同 tab 的 todo 項目
const filterTodos = computed(() => {
if (toggleTab.value === 'all') {
return todos.value;
} else if (toggleTab.value === 'undo') {
return todos.value.filter(item => item.checked === false);
} else {
return todos.value.filter(item => item.checked === true);
}
});
// 計算已完成的數量
const doneCount = computed(() => {
const arr = todos.value.filter(item => item.checked === true);
return arr.length;
});
// 紀錄輸入框內容
const todoTemp = ref("");
// 新增項目並將輸入框內容清空
const addNewTodo = async () => {
await addTodo(todoTemp.value);
todoTemp.value = "";
};
</script>
```