全栈 Todo 应用开发实践:从后端到双前端
项目概述
欢迎来到这个全栈 Todo 应用的开发实践教程!通过这个项目,你将学习如何使用现代技术栈从零开始构建一个功能完整的 Web 应用。
我们将涵盖以下技术:
-
后端:
-
Go: 一门以其性能和并发性而闻名的静态类型语言。
-
Gin: 一个 Go 语言中流行的高性能 Web 框架。
-
GORM: Go 语言的 ORM 库,让数据库操作变得简单直观。
-
SQLite: 一个轻量级的、无服务器的数据库,非常适合本地开发和小型项目。
-
-
前端:
-
React: 一个由 Facebook 开发的用于构建用户界面的 JavaScript 库。
-
Vue: 一套用于构建用户界面的渐进式框架。
-
Vite: 一个极速的现代前端构建工具。
-
TypeScript: JavaScript 的超集,增加了静态类型检查。
-
Tailwind CSS: 一个功能类优先的 CSS 框架,用于快速构建自定义设计。
-
最终,你将拥有一个 Go 驱动的 API 后端,以及两个功能完全相同的前端应用(一个使用 React,一个使用 Vue)来消费这些 API。
第一部分:构建 Go 后端
我们的第一步是构建为应用提供数据和逻辑的后端 API 服务。
- 初始化 Go 项目
首先,创建一个名为 go-todo
的新目录,并初始化 Go 模块。
mkdir go-todo
cd go-todo
go mod init todo
接着,获取我们项目所需的依赖包:
go get -u github.com/gin-gonic/gin
go get -u gorm.io/driver/sqlite
go get -u gorm.io/gorm
知识点:
-
go mod init
用于创建一个新的模块,初始化go.mod
文件,该文件定义了模块的路径和依赖关系。 -
go get
用于下载和安装指定的包及其依赖项。
- 定义数据模型
我们需要定义 Todo
项目的数据结构,并设置数据库连接。
在 go-todo
目录下创建 models
文件夹,并在其中创建 todo.go
文件。
文件路径: go-todo/models/todo.go
package models // 定义 models 包,用于存放数据模型和数据库操作
import (
"time" // 导入时间包,用于处理时间相关字段
"gorm.io/driver/sqlite" // 导入 GORM 的 SQLite 驱动
"gorm.io/gorm" // 导入 GORM ORM 框架
)
type Todo struct { // 定义 Todo 结构体,表示待办事项的数据模型
ID uint `json:"id" gorm:"primaryKey"` // ID 字段,无符号整数类型,作为主键,JSON 序列化时字段名为 "id"
Title string `json:"title" gorm:"not null"` // 标题字段,字符串类型,数据库中不允许为空
Description string `json:"description"` // 描述字段,字符串类型,可选内容
Completed bool `json:"completed" gorm:"default:false"` // 完成状态字段,布尔类型,默认值为 false(未完成)
CreatedAt time.Time `json:"created_at"` // 创建时间字段,GORM 会自动管理此字段
UpdatedAt time.Time `json:"updated_at"` // 更新时间字段,GORM 会自动管理此字段
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间字段,JSON 序列化时忽略("-"),并在数据库中创建索引,用于实现软删除功能
}
var DB *gorm.DB // 声明全局数据库连接对象,指向 GORM 数据库实例
func InitDB() { // 初始化数据库连接的函数
var err error // 声明错误变量
DB, err = gorm.Open(sqlite.Open("todo.db"), &gorm.Config{}) // 打开 SQLite 数据库连接,数据库文件名为 "todo.db",使用默认配置
if err != nil { // 检查数据库连接是否出错
panic("failed to connect database") // 如果连接失败,触发 panic 终止程序
}
DB.AutoMigrate(&Todo{}) // 自动迁移数据库结构,根据 Todo 结构体创建或更新数据库表
}
知识点:
-
GORM 模型: 在 GORM 中,一个标准的 Go 结构体可以映射成一个数据库表。结构体字段上的
gorm:"..."
标签用于定义列的属性,如primaryKey
(主键)、not null
(非空)等。 -
gorm.DeletedAt
: 这是一个特殊的 GORM 类型,用于实现“软删除”。当调用DB.Delete()
时,GORM 不会从数据库中物理删除记录,而是将deleted_at
字段设置为当前时间。在查询时,GORM 会自动忽略这些被软删除的记录。 -
InitDB
函数: 这个函数负责初始化数据库连接。它使用 GORM 打开一个 SQLite 数据库文件(如果todo.db
不存在,GORM 会自动创建它)。DB.AutoMigrate(&Todo{})
会自动检查Todo
结构体对应的表是否存在,如果不存在,则会创建该表。
- 编写 API 处理器
处理器(Handlers)负责处理传入的 HTTP 请求,执行业务逻辑,并返回响应。
在 go-todo
目录下创建 handlers
文件夹,并在其中创建 todo.go
文件。
文件路径: go-todo/handlers/todo.go
package handlers // 定义 handlers 包,用于存放 HTTP 请求处理函数
import (
"strconv" // 导入字符串转换包,用于将字符串转换为数字类型
"todo/models" // 导入 models 包,用于访问数据模型和数据库
"github.com/gin-gonic/gin" // 导入 Gin 框架,用于处理 HTTP 请求和响应
)
func CreatePost(c *gin.Context) { // 创建新的待办事项的处理函数,c 为 Gin 上下文对象
var todo models.Todo // 声明一个 Todo 结构体变量,用于接收请求数据
if err := c.ShouldBindJSON(&todo); err != nil { // 尝试将请求体的 JSON 数据绑定到 todo 变量,检查绑定是否出错
c.JSON(400, gin.H{"error": err.Error()}) // 如果绑定失败,返回 400 错误状态码和错误信息
return // 函数返回,不继续执行
}
result := models.DB.Create(&todo) // 使用 GORM 将 todo 对象插入数据库
if result.Error != nil { // 检查数据库操作是否出错
c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 服务器错误和错误信息
return // 函数返回
}
c.JSON(201, todo) // 如果成功,返回 201 创建成功状态码和创建的 todo 对象
}
func GetPosts(c *gin.Context) { // 获取所有待办事项的处理函数
var todos []models.Todo // 声明一个 Todo 切片,用于存储查询结果
result := models.DB.Find(&todos) // 使用 GORM 从数据库查询所有 Todo 记录
if result.Error != nil { // 检查数据库查询是否出错
c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 错误和错误信息
return // 函数返回
}
c.JSON(200, todos) // 如果成功,返回 200 成功状态码和所有 todo 对象的列表
}
func GetPost(c *gin.Context) { // 根据 ID 获取单个待办事项的处理函数
id, err := strconv.ParseUint(c.Param("id"), 10, 32) // 从 URL 参数中获取 ID,将字符串转换为无符号 32 位整数
if err != nil { // 检查字符串转换是否出错
c.JSON(400, gin.H{"error": "Invalid ID"}) // 如果 ID 格式无效,返回 400 错误
return // 函数返回
}
var todo models.Todo // 声明一个 Todo 结构体变量,用于存储查询结果
result := models.DB.First(&todo, id) // 使用 GORM 根据 ID 从数据库查询单条记录
if result.Error != nil { // 检查查询是否出错(如记录不存在)
c.JSON(404, gin.H{"error": "Todo not found"}) // 如果记录不存在,返回 404 未找到错误
return // 函数返回
}
c.JSON(200, todo) // 如果成功,返回 200 成功状态码和查询到的 todo 对象
}
func UpdatePost(c *gin.Context) { // 更新待办事项的处理函数
id, err := strconv.ParseUint(c.Param("id"), 10, 32) // 从 URL 参数中获取 ID,转换为无符号整数
if err != nil { // 检查 ID 转换是否出错
c.JSON(400, gin.H{"error": "Invalid ID"}) // 如果 ID 无效,返回 400 错误
return // 函数返回
}
var updates map[string]interface{} // 声明一个 map 变量,用于存储需要更新的字段(键为字符串,值为任意类型)
if err := c.ShouldBindJSON(&updates); err != nil { // 将请求体的 JSON 数据绑定到 updates map,检查是否出错
c.JSON(400, gin.H{"error": err.Error()}) // 如果绑定失败,返回 400 错误和错误信息
return // 函数返回
}
result := models.DB.Model(&models.Todo{}).Where("id = ?", id).Updates(updates) // 使用 GORM 根据 ID 更新指定 Todo 记录的字段
if result.Error != nil { // 检查数据库更新操作是否出错
c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 服务器错误和错误信息
return // 函数返回
}
if result.RowsAffected == 0 { // 检查是否有记录被更新(RowsAffected 表示受影响的行数)
c.JSON(404, gin.H{"error": "Todo not found"}) // 如果没有记录被更新,说明记录不存在,返回 404 错误
return // 函数返回
}
var todo models.Todo // 声明一个 Todo 变量,用于获取更新后的最新数据
models.DB.First(&todo, id) // 根据 ID 重新查询最新的 Todo 记录
c.JSON(200, todo) // 返回 200 成功状态码和更新后的 todo 对象
}
func DeletePost(c *gin.Context) { // 删除待办事项的处理函数
id, err := strconv.ParseUint(c.Param("id"), 10, 32) // 从 URL 参数中获取 ID,转换为无符号整数
if err != nil { // 检查 ID 转换是否出错
c.JSON(400, gin.H{"error": "Invalid ID"}) // 如果 ID 无效,返回 400 错误
return // 函数返回
}
result := models.DB.Delete(&models.Todo{}, id) // 使用 GORM 根据 ID 删除 Todo 记录(执行软删除)
if result.Error != nil { // 检查数据库删除操作是否出错
c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 服务器错误和错误信息
return // 函数返回
}
if result.RowsAffected == 0 { // 检查是否有记录被删除
c.JSON(404, gin.H{"error": "Todo not found"}) // 如果没有记录被删除,说明记录不存在,返回 404 错误
return // 函数返回
}
c.JSON(200, gin.H{"message": "Todo deleted"}) // 如果成功,返回 200 成功状态码和删除成功的消息
}
知识点:
-
Gin 上下文 (
*gin.Context
): Gin 的核心是Context
对象,它封装了 HTTP 请求和响应的所有信息。我们可以从中获取请求参数、路径、头信息,并用它来返回 JSON、HTML 等格式的响应。 -
CRUD 操作:
-
CreatePost
: 使用c.ShouldBindJSON
将请求体中的 JSON 数据绑定到Todo
结构体。然后使用DB.Create
将其保存到数据库。 -
GetPosts
: 使用DB.Find
查询todos
表中的所有记录。 -
GetPost
: 从 URL 路径中获取id
参数,并使用DB.First
按 ID 查询单个记录。 -
UpdatePost
: 获取id
参数和请求体中的更新数据。使用DB.Model(&models.Todo{}).Where("id = ?", id).Updates(updates)
来更新特定字段,而不是替换整个对象。 -
DeletePost
: 根据id
参数使用DB.Delete
来软删除记录。
-
-
错误处理: 在每个数据库操作后,我们都检查
result.Error
是否为nil
。如果不是,就意味着操作出错了,我们会向客户端返回一个 500(服务器内部错误)或 404(未找到)的响应。
- 设置主程序和路由
main.go
是我们应用的入口点。在这里,我们将初始化数据库、创建 Gin 引擎、配置路由和中间件,并启动服务器。
在 go-todo
项目的根目录下创建 main.go
文件。
文件路径: go-todo/main.go
package main // 定义 main 包,Go 程序的入口包
import (
"todo/handlers" // 导入 handlers 包,包含所有 HTTP 请求处理函数
"todo/models" // 导入 models 包,用于数据库初始化和数据模型
"github.com/gin-gonic/gin" // 导入 Gin Web 框架
)
func main() { // 主函数,程序的入口点
models.InitDB() // 调用数据库初始化函数,连接数据库并执行自动迁移
r := gin.Default() // 创建一个默认的 Gin 路由引擎,包含 Logger 和 Recovery 中间件
r.Use(func(c *gin.Context) { // 添加一个全局中间件,用于处理跨域资源共享(CORS)
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") // 设置允许所有来源的跨域请求("*" 表示任意域名)
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") // 设置允许的 HTTP 请求方法
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // 设置允许的请求头字段
if c.Request.Method == "OPTIONS" { // 检查是否是预检请求(OPTIONS 方法)
c.AbortWithStatus(204) // 如果是预检请求,返回 204 无内容状态码,并终止后续处理
return // 函数返回
}
c.Next() // 继续执行下一个中间件或处理函数
})
api := r.Group("/api") // 创建一个路由组,所有路由都以 "/api" 为前缀
{ // 在路由组中定义 API 端点
api.GET("/todos", handlers.GetPosts) // GET /api/todos - 获取所有待办事项列表
api.POST("/todos", handlers.CreatePost) // POST /api/todos - 创建新的待办事项
api.GET("/todos/:id", handlers.GetPost) // GET /api/todos/:id - 根据 ID 获取单个待办事项
api.PUT("/todos/:id", handlers.UpdatePost) // PUT /api/todos/:id - 根据 ID 更新待办事项
api.DELETE("/todos/:id", handlers.DeletePost) // DELETE /api/todos/:id - 根据 ID 删除待办事项
}
r.Run(":8080") // 启动 HTTP 服务器,监听 8080 端口
}
知识点:
-
CORS 中间件: 由于我们的前端和后端将运行在不同的端口上(开发环境下),浏览器会阻止跨域请求。我们通过一个自定义的 Gin 中间件来设置
Access-Control-Allow-Origin
等响应头,从而允许来自任何源的请求。 -
路由分组:
r.Group("/api")
创建了一个路由组。组内的所有路由都会自动加上/api
前缀,这有助于组织和版本化我们的 API。 -
r.Run(":8080")
: 启动 HTTP 服务器并监听在 8080 端口。
至此,后端部分已经完成!你可以在 go-todo
目录下运行 go run main.go
来启动它。
第二部分:构建 React 前端
现在我们来构建第一个前端应用。
- 初始化 React 项目
在与 go-todo
同级的目录下,使用 Vite 创建一个新的 React + TypeScript 项目。
Bash
pnpm create vite react-todo --template react-ts
cd react-todo
然后,安装我们需要的额外依赖:tailwindcss
和它的 Vite 插件。
Bash
pnpm install -D tailwindcss @tailwindcss/vite
知识点:
-
Vite: Vite 使用原生 ES 模块和 esbuild 来提供极快的冷启动和热更新(HMR),大大提升了开发体验。
-
pnpm: 一个快速、节省磁盘空间的包管理工具。
- 配置 Tailwind CSS
为了让 Tailwind CSS 工作,我们需要进行一些配置。
首先,在 react-todo
根目录下创建 vite.config.ts
文件。
文件路径: react-todo/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(),tailwindcss()],
})
然后,在 src
目录下创建一个 index.css
文件。
文件路径: react-todo/src/index.css
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
最后,在 src/main.tsx
中引入这个 CSS 文件,确保它在应用中生效。
文件路径: react-todo/src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
知识点:
-
@tailwindcss/vite
插件简化了在 Vite 项目中集成 Tailwind CSS 的过程。 -
@import "tailwindcss";
是 Tailwind v4+ 的新语法,用于在 CSS 文件中引入 Tailwind 的所有功能类、基础样式和组件。
- 构建应用组件
我们将把所有逻辑都放在 App.tsx
这个主组件中。
文件路径: react-todo/src/App.tsx
import { useEffect, useState } from "react" // 导入 React hooks:useEffect 用于副作用操作,useState 用于状态管理
interface Todo { // 定义 Todo 接口,描述待办事项的数据结构
id: number // 待办事项的唯一标识符
title: string // 待办事项的标题
completed: boolean // 待办事项的完成状态
}
function App() { // 定义 App 组件,应用的主组件
const [title, setTitle] = useState("") // 声明 title 状态,用于存储新增待办事项的标题输入
const [content, setContent] = useState<Todo[]>([]) // 声明 content 状态,用于存储所有待办事项的数组
const [loading, setLoading] = useState(false) // 声明 loading 状态,用于标识是否正在添加新待办事项
const [editingId, setEditingId] = useState<number | null>(null) // 声明 editingId 状态,用于记录当前正在编辑的待办事项 ID,null 表示没有正在编辑的项
const [editTitle, setEditTitle] = useState("") // 声明 editTitle 状态,用于存储编辑模式下的标题临时值
const [operatingId, setOperatingId] = useState<number | null>(null) // 声明 operatingId 状态,用于记录当前正在执行操作(删除/更新/保存)的待办事项 ID
const fetchTodos = () => { // 定义 fetchTodos 函数,用于从后端获取所有待办事项
fetch("http://localhost:8080/api/todos") // 发送 GET 请求到后端 API
.then(response => response.json()) // 将响应转换为 JSON 格式
.then(data => setContent(data || [])) // 更新 content 状态,如果 data 为 null/undefined 则使用空数组
.catch(error => console.error("获取数据失败:", error)) // 捕获并打印错误信息
}
useEffect(() => { // 使用 useEffect hook,在组件挂载时执行
fetchTodos() // 组件首次渲染时获取待办事项列表
}, []) // 空依赖数组表示只在组件挂载时执行一次
const handleAdd = () => { // 定义 handleAdd 函数,用于添加新的待办事项
if (!title.trim()) return // 如果标题为空或只包含空格,直接返回不执行添加操作
setLoading(true) // 设置 loading 状态为 true,显示加载中状态
fetch("http://localhost:8080/api/todos", { // 发送 POST 请求到后端 API
method: "POST", // 指定 HTTP 方法为 POST
headers: { "Content-Type": "application/json" }, // 设置请求头,告知服务器发送的是 JSON 数据
body: JSON.stringify({ title, completed: false }) // 将待办事项对象转换为 JSON 字符串作为请求体
})
.then(response => response.json()) // 将响应转换为 JSON 格式
.then(() => { // 添加成功后执行的操作
setTitle("") // 清空输入框
fetchTodos() // 重新获取待办事项列表以更新显示
})
.catch(error => console.error("添加失败:", error)) // 捕获并打印错误信息
.finally(() => setLoading(false)) // 无论成功还是失败,最终都将 loading 状态设为 false
}
const handleDelete = (id: number) => { // 定义 handleDelete 函数,用于删除指定 ID 的待办事项
setOperatingId(id) // 设置当前操作的 ID,用于显示加载状态
fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 DELETE 请求到后端 API,URL 中包含待删除项的 ID
method: "DELETE" // 指定 HTTP 方法为 DELETE
})
.then(() => fetchTodos()) // 删除成功后重新获取待办事项列表
.catch(error => console.error("删除失败:", error)) // 捕获并打印错误信息
.finally(() => setOperatingId(null)) // 操作完成后清除操作 ID
}
const handleToggle = (id: number, completed: boolean) => { // 定义 handleToggle 函数,用于切换待办事项的完成状态
setOperatingId(id) // 设置当前操作的 ID
fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
method: "PUT", // 指定 HTTP 方法为 PUT
headers: { "Content-Type": "application/json" }, // 设置请求头
body: JSON.stringify({ completed: !completed }) // 将完成状态取反后发送到服务器
})
.then(() => fetchTodos()) // 更新成功后重新获取待办事项列表
.catch(error => console.error("更新失败:", error)) // 捕获并打印错误信息
.finally(() => setOperatingId(null)) // 操作完成后清除操作 ID
}
const handleEdit = (id: number, currentTitle: string) => { // 定义 handleEdit 函数,用于进入编辑模式
setEditingId(id) // 设置正在编辑的项的 ID
setEditTitle(currentTitle) // 将当前标题填充到编辑输入框
}
const handleSaveEdit = (id: number) => { // 定义 handleSaveEdit 函数,用于保存编辑后的标题
if (!editTitle.trim()) return // 如果编辑后的标题为空或只包含空格,直接返回不保存
setOperatingId(id) // 设置当前操作的 ID
fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
method: "PUT", // 指定 HTTP 方法为 PUT
headers: { "Content-Type": "application/json" }, // 设置请求头
body: JSON.stringify({ title: editTitle }) // 将新标题发送到服务器
})
.then(() => { // 保存成功后执行的操作
fetchTodos() // 重新获取待办事项列表
setEditingId(null) // 清除编辑状态
setEditTitle("") // 清空编辑输入框
})
.catch(error => console.error("更新失败:", error)) // 捕获并打印错误信息
.finally(() => setOperatingId(null)) // 操作完成后清除操作 ID
}
const handleCancelEdit = () => { // 定义 handleCancelEdit 函数,用于取消编辑操作
setEditingId(null) // 清除编辑状态
setEditTitle("") // 清空编辑输入框
}
return (
<div className="min-h-screen bg-sky-300"> {/* 最外层容器,最小高度为屏幕高度,天蓝色背景 */}
<div className="max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl mx-auto px-4"> {/* 响应式容器,不同屏幕尺寸下有不同的最大宽度,水平居中,左右内边距 */}
<nav className="bg-sky-400 -mx-4 sm:mx-0 sm:rounded-b-lg"> {/* 导航栏,深天蓝色背景,小屏幕全宽,大屏幕有圆角 */}
<h1 className="text-2xl sm:text-3xl font-bold text-center text-gray-800 py-4">Todo List</h1> {/* 标题,响应式字体大小,粗体,居中,深灰色文字,上下内边距 */}
</nav>
<main className="py-4 sm:py-6"> {/* 主内容区域,响应式上下内边距 */}
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center mb-4 bg-white p-3 rounded-md shadow"> {/* 添加待办事项的输入区域,小屏幕垂直布局,大屏幕水平布局,白色背景,圆角,阴影 */}
<input
type="text" // 文本输入框
value={title} // 绑定 title 状态
onChange={(e) => setTitle(e.target.value)} // 输入变化时更新 title 状态
onKeyDown={(e) => e.key === 'Enter' && handleAdd()} // 按下 Enter 键时触发添加操作
placeholder="输入待办事项..." // 占位符文本
className="flex-1 p-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-sky-500" // 样式:占据剩余空间,内边距,圆角,灰色边框,聚焦时显示天蓝色环形轮廓
/>
<button
onClick={handleAdd} // 点击时触发添加操作
disabled={loading || !title.trim()} // 当正在加载或标题为空时禁用按钮
className="bg-sky-500 px-4 py-2 rounded-md text-white hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto" // 样式:天蓝色背景,白色文字,悬停时变深,禁用时半透明且鼠标显示禁止图标,小屏幕全宽,大屏幕自适应宽度
>
{loading ? "添加中..." : "添加"} {/* 根据 loading 状态显示不同文本 */}
</button>
</div>
<div className="space-y-2"> {/* 待办事项列表容器,子元素之间垂直间距 */}
{content.length === 0 ? ( // 如果待办事项列表为空
<p className="text-center text-gray-600 mt-8">暂无待办事项</p> // 显示空状态提示
) : (
content.map((item) => ( // 遍历待办事项数组,为每个项渲染一个卡片
<div
key={item.id} // React 列表渲染必需的 key 属性,使用 ID 作为唯一标识
className="flex flex-col sm:flex-row sm:items-center gap-3 bg-white p-3 rounded-md shadow hover:shadow-md transition-shadow" // 样式:响应式布局,白色背景,圆角,阴影,悬停时阴影加深,过渡动画
>
<div className="flex items-center gap-3 flex-1 min-w-0"> {/* 待办事项内容区域,垂直居中对齐,占据剩余空间 */}
<input
type="checkbox" // 复选框
checked={item.completed} // 根据 completed 状态设置选中状态
onChange={() => handleToggle(item.id, item.completed)} // 改变时切换完成状态
disabled={operatingId === item.id} // 如果正在操作此项则禁用
className="w-5 h-5 cursor-pointer flex-shrink-0 disabled:opacity-50" // 样式:固定宽高,鼠标指针,不缩小,禁用时半透明
/>
{editingId === item.id ? ( // 如果当前项正在编辑
<input
type="text" // 文本输入框
value={editTitle} // 绑定 editTitle 状态
onChange={(e) => setEditTitle(e.target.value)} // 输入变化时更新 editTitle 状态
onKeyDown={(e) => { // 键盘事件处理
if (e.key === 'Enter') handleSaveEdit(item.id) // 按 Enter 键保存
if (e.key === 'Escape') handleCancelEdit() // 按 Escape 键取消
}}
autoFocus // 自动聚焦到此输入框
className="flex-1 p-2 rounded border border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500 min-w-0" // 样式:占据剩余空间,内边距,圆角,天蓝色边框,聚焦时显示天蓝色环形轮廓
/>
) : (
<p className={`flex-1 text-gray-800 break-words ${item.completed ? 'line-through text-gray-400' : ''}`}> {/* 待办事项标题,如果已完成则显示删除线和浅灰色 */}
{item.title} {/* 显示标题文本 */}
</p>
)}
</div>
<div className="flex gap-2 flex-shrink-0"> {/* 操作按钮区域,按钮之间有间距,不缩小 */}
{editingId === item.id ? ( // 如果当前项正在编辑,显示保存和取消按钮
<>
<button
onClick={() => handleSaveEdit(item.id)} // 点击保存编辑
disabled={!editTitle.trim() || operatingId === item.id} // 如果标题为空或正在操作则禁用
className="bg-green-500 px-3 py-1 rounded-md text-white hover:bg-green-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" // 样式:绿色背景,白色文字,悬停变深,小字体,禁用时半透明
>
{operatingId === item.id ? "保存中..." : "保存"} {/* 根据操作状态显示不同文本 */}
</button>
<button
onClick={handleCancelEdit} // 点击取消编辑
disabled={operatingId === item.id} // 如果正在操作则禁用
className="bg-gray-500 px-3 py-1 rounded-md text-white hover:bg-gray-600 text-sm disabled:opacity-50" // 样式:灰色背景,白色文字,悬停变深
>
取消
</button>
</>
) : ( // 如果未在编辑模式,显示编辑和删除按钮
<>
<button
onClick={() => handleEdit(item.id, item.title)} // 点击进入编辑模式
disabled={operatingId === item.id || editingId !== null} // 如果正在操作此项或有其他项正在编辑则禁用
className="bg-blue-500 px-3 py-1 rounded-md text-white hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" // 样式:蓝色背景,白色文字,悬停变深
>
编辑
</button>
<button
onClick={() => handleDelete(item.id)} // 点击删除此项
disabled={operatingId === item.id || editingId !== null} // 如果正在操作此项或有其他项正在编辑则禁用
className="bg-red-500 px-3 py-1 rounded-md text-white hover:bg-red-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" // 样式:红色背景,白色文字,悬停变深
>
{operatingId === item.id ? "删除中..." : "删除"} {/* 根据操作状态显示不同文本 */}
</button>
</>
)}
</div>
</div>
))
)}
</div>
</main>
</div>
</div>
)
}
export default App // 导出 App 组件作为默认导出
知识点:
-
React Hooks:
-
useState
: 用于在函数组件中添加和管理状态。我们用它来管理输入框内容 (title
)、todo 列表 (content
)、加载状态 (loading
) 等。 -
useEffect
: 用于处理副作用,如数据获取。useEffect(..., [])
中的空依赖数组意味着这个 effect 只会在组件首次挂载时运行一次,非常适合用来初始化数据。
-
-
数据获取: 我们使用浏览器原生的
fetch
API 与后端进行通信。所有 API 请求都指向我们之前在 Go 后端中定义的http://localhost:8080/api/todos
端点。 -
条件渲染: 我们使用三元运算符来根据不同状态显示不同的 UI。例如,
content.length === 0 ? <p>暂无...</p> : content.map(...)
用于在列表为空时显示提示信息。同样,我们用它来切换显示模式和编辑模式。 -
事件处理: 通过
onClick
、onChange
、onKeyDown
等事件处理器来响应用户交互,并调用相应的函数(如handleAdd
,handleDelete
)来更新状态和调用 API。 -
Tailwind CSS 类: 你可以看到组件的
className
中使用了大量的 Tailwind CSS 功能类(如min-h-screen
,bg-sky-300
,flex
,rounded-md
),这些类直接在 HTML 中定义了元素的样式,无需编写单独的 CSS 文件。
第三部分:构建 Vue 前端
接下来,我们用 Vue 来实现一个功能完全相同的前端应用。
- 初始化 Vue 项目
同样,在与 go-todo
同级的目录下,使用 Vite 创建一个新的 Vue + TypeScript 项目。
pnpm create vite vue-todo --template vue-ts
cd vue-todo
安装 tailwindcss
依赖:
pnpm install -D tailwindcss @tailwindcss/vite
- 配置 Tailwind CSS
配置过程与 React 项目几乎完全相同,再次不做过多解释。
- 构建应用组件
在 Vue 中,我们同样将所有逻辑放在主组件 App.vue
中。
文件路径: vue-todo/src/App.vue
<script setup lang="ts"> // 使用 Vue 3 Composition API 的 setup 语法糖,启用 TypeScript
import { ref, onMounted } from 'vue' // 导入 Vue 3 的响应式 API:ref 用于创建响应式数据,onMounted 用于组件挂载后的生命周期钩子
interface Todo { // 定义 Todo 接口,描述待办事项的数据结构
id: number // 待办事项的唯一标识符
title: string // 待办事项的标题
completed: boolean // 待办事项的完成状态
}
const title = ref('') // 创建响应式引用,用于存储新增待办事项的标题输入
const content = ref<Todo[]>([]) // 创建响应式引用,用于存储所有待办事项的数组,类型为 Todo 数组
const loading = ref(false) // 创建响应式引用,用于标识是否正在添加新待办事项
const editingId = ref<number | null>(null) // 创建响应式引用,用于记录当前正在编辑的待办事项 ID,null 表示没有正在编辑的项
const editTitle = ref('') // 创建响应式引用,用于存储编辑模式下的标题临时值
const operatingId = ref<number | null>(null) // 创建响应式引用,用于记录当前正在执行操作(删除/更新/保存)的待办事项 ID
const fetchTodos = () => { // 定义 fetchTodos 函数,用于从后端获取所有待办事项
fetch('http://localhost:8080/api/todos') // 发送 GET 请求到后端 API
.then(response => response.json()) // 将响应转换为 JSON 格式
.then(data => content.value = data || []) // 更新 content 的值,如果 data 为 null/undefined 则使用空数组(注意:Vue 3 的 ref 需要通过 .value 访问)
.catch(error => console.error('获取数据失败:', error)) // 捕获并打印错误信息
}
onMounted(() => { // 使用 onMounted 生命周期钩子,在组件挂载到 DOM 后执行
fetchTodos() // 组件挂载后立即获取待办事项列表
})
const handleAdd = () => { // 定义 handleAdd 函数,用于添加新的待办事项
if (!title.value.trim()) return // 如果标题为空或只包含空格,直接返回不执行添加操作
loading.value = true // 设置 loading 状态为 true,显示加载中状态
fetch('http://localhost:8080/api/todos', { // 发送 POST 请求到后端 API
method: 'POST', // 指定 HTTP 方法为 POST
headers: { 'Content-Type': 'application/json' }, // 设置请求头,告知服务器发送的是 JSON 数据
body: JSON.stringify({ title: title.value, completed: false }) // 将待办事项对象转换为 JSON 字符串作为请求体
})
.then(response => response.json()) // 将响应转换为 JSON 格式
.then(() => { // 添加成功后执行的操作
title.value = '' // 清空输入框
fetchTodos() // 重新获取待办事项列表以更新显示
})
.catch(error => console.error('添加失败:', error)) // 捕获并打印错误信息
.finally(() => loading.value = false) // 无论成功还是失败,最终都将 loading 状态设为 false
}
const handleDelete = (id: number) => { // 定义 handleDelete 函数,用于删除指定 ID 的待办事项
operatingId.value = id // 设置当前操作的 ID,用于显示加载状态
fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 DELETE 请求到后端 API,URL 中包含待删除项的 ID
method: 'DELETE' // 指定 HTTP 方法为 DELETE
})
.then(() => fetchTodos()) // 删除成功后重新获取待办事项列表
.catch(error => console.error('删除失败:', error)) // 捕获并打印错误信息
.finally(() => operatingId.value = null) // 操作完成后清除操作 ID
}
const handleToggle = (id: number, completed: boolean) => { // 定义 handleToggle 函数,用于切换待办事项的完成状态
operatingId.value = id // 设置当前操作的 ID
fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
method: 'PUT', // 指定 HTTP 方法为 PUT
headers: { 'Content-Type': 'application/json' }, // 设置请求头
body: JSON.stringify({ completed: !completed }) // 将完成状态取反后发送到服务器
})
.then(() => fetchTodos()) // 更新成功后重新获取待办事项列表
.catch(error => console.error('更新失败:', error)) // 捕获并打印错误信息
.finally(() => operatingId.value = null) // 操作完成后清除操作 ID
}
const handleEdit = (id: number, currentTitle: string) => { // 定义 handleEdit 函数,用于进入编辑模式
editingId.value = id // 设置正在编辑的项的 ID
editTitle.value = currentTitle // 将当前标题填充到编辑输入框
}
const handleSaveEdit = (id: number) => { // 定义 handleSaveEdit 函数,用于保存编辑后的标题
if (!editTitle.value.trim()) return // 如果编辑后的标题为空或只包含空格,直接返回不保存
operatingId.value = id // 设置当前操作的 ID
fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
method: 'PUT', // 指定 HTTP 方法为 PUT
headers: { 'Content-Type': 'application/json' }, // 设置请求头
body: JSON.stringify({ title: editTitle.value }) // 将新标题发送到服务器
})
.then(() => { // 保存成功后执行的操作
fetchTodos() // 重新获取待办事项列表
editingId.value = null // 清除编辑状态
editTitle.value = '' // 清空编辑输入框
})
.catch(error => console.error('更新失败:', error)) // 捕获并打印错误信息
.finally(() => operatingId.value = null) // 操作完成后清除操作 ID
}
const handleCancelEdit = () => { // 定义 handleCancelEdit 函数,用于取消编辑操作
editingId.value = null // 清除编辑状态
editTitle.value = '' // 清空编辑输入框
}
</script>
<template> <!-- Vue 模板部分,定义组件的 HTML 结构 -->
<div class="min-h-screen bg-sky-300"> <!-- 最外层容器,最小高度为屏幕高度,天蓝色背景 -->
<div class="max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl mx-auto px-4"> <!-- 响应式容器,不同屏幕尺寸下有不同的最大宽度,水平居中,左右内边距 -->
<nav class="bg-sky-400 -mx-4 sm:mx-0 sm:rounded-b-lg"> <!-- 导航栏,深天蓝色背景,小屏幕全宽,大屏幕有圆角 -->
<h1 class="text-2xl sm:text-3xl font-bold text-center text-gray-800 py-4">Todo List</h1> <!-- 标题,响应式字体大小,粗体,居中,深灰色文字,上下内边距 -->
</nav>
<main class="py-4 sm:py-6"> <!-- 主内容区域,响应式上下内边距 -->
<div class="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center mb-4 bg-white p-3 rounded-md shadow"> <!-- 添加待办事项的输入区域,小屏幕垂直布局,大屏幕水平布局,白色背景,圆角,阴影 -->
<input
type="text" <!-- 文本输入框 -->
v-model="title" <!-- 使用 v-model 双向绑定 title 响应式数据 -->
@keydown.enter="handleAdd" <!-- 监听 Enter 键按下事件,触发 handleAdd 函数 -->
placeholder="输入待办事项..." <!-- 占位符文本 -->
class="flex-1 p-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-sky-500" <!-- 样式:占据剩余空间,内边距,圆角,灰色边框,聚焦时显示天蓝色环形轮廓 -->
/>
<button
@click="handleAdd" <!-- 监听点击事件,触发 handleAdd 函数 -->
:disabled="loading || !title.trim()" <!-- 使用 v-bind 绑定 disabled 属性,当正在加载或标题为空时禁用按钮 -->
class="bg-sky-500 px-4 py-2 rounded-md text-white hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto" <!-- 样式:天蓝色背景,白色文字,悬停时变深,禁用时半透明且鼠标显示禁止图标,小屏幕全宽,大屏幕自适应宽度 -->
>
{{ loading ? '添加中...' : '添加' }} <!-- 使用插值表达式,根据 loading 状态显示不同文本 -->
</button>
</div>
<div class="space-y-2"> <!-- 待办事项列表容器,子元素之间垂直间距 -->
<p v-if="content.length === 0" class="text-center text-gray-600 mt-8">暂无待办事项</p> <!-- 使用 v-if 条件渲染,如果列表为空则显示空状态提示 -->
<div
v-else <!-- 如果列表不为空,则执行下面的渲染 -->
v-for="item in content" <!-- 使用 v-for 遍历 content 数组,为每个待办事项渲染一个卡片 -->
:key="item.id" <!-- 绑定 key 属性,Vue 列表渲染必需,使用 ID 作为唯一标识 -->
class="flex flex-col sm:flex-row sm:items-center gap-3 bg-white p-3 rounded-md shadow hover:shadow-md transition-shadow" <!-- 样式:响应式布局,白色背景,圆角,阴影,悬停时阴影加深,过渡动画 -->
>
<div class="flex items-center gap-3 flex-1 min-w-0"> <!-- 待办事项内容区域,垂直居中对齐,占据剩余空间 -->
<input
type="checkbox" <!-- 复选框 -->
:checked="item.completed" <!-- 使用 v-bind 绑定 checked 属性,根据 completed 状态设置选中状态 -->
@change="handleToggle(item.id, item.completed)" <!-- 监听 change 事件,改变时切换完成状态 -->
:disabled="operatingId === item.id" <!-- 如果正在操作此项则禁用复选框 -->
class="w-5 h-5 cursor-pointer flex-shrink-0 disabled:opacity-50" <!-- 样式:固定宽高,鼠标指针,不缩小,禁用时半透明 -->
/>
<input
v-if="editingId === item.id" <!-- 使用 v-if 条件渲染,如果当前项正在编辑则显示输入框 -->
type="text" <!-- 文本输入框 -->
v-model="editTitle" <!-- 使用 v-model 双向绑定 editTitle 响应式数据 -->
@keydown.enter="handleSaveEdit(item.id)" <!-- 监听 Enter 键按下事件,保存编辑 -->
@keydown.esc="handleCancelEdit" <!-- 监听 Escape 键按下事件,取消编辑 -->
autofocus <!-- 自动聚焦到此输入框 -->
class="flex-1 p-2 rounded border border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500 min-w-0" <!-- 样式:占据剩余空间,内边距,圆角,天蓝色边框,聚焦时显示天蓝色环形轮廓 -->
/>
<p
v-else <!-- 如果未在编辑模式,则显示标题文本 -->
:class="['flex-1 text-gray-800 break-words', item.completed ? 'line-through text-gray-400' : '']" <!-- 使用 v-bind 绑定 class,动态添加样式类,如果已完成则显示删除线和浅灰色 -->
>
{{ item.title }} <!-- 使用插值表达式显示标题文本 -->
</p>
</div>
<div class="flex gap-2 flex-shrink-0"> <!-- 操作按钮区域,按钮之间有间距,不缩小 -->
<template v-if="editingId === item.id"> <!-- 使用 template 和 v-if 条件渲染,如果当前项正在编辑,显示保存和取消按钮 -->
<button
@click="handleSaveEdit(item.id)" <!-- 点击保存编辑 -->
:disabled="!editTitle.trim() || operatingId === item.id" <!-- 如果标题为空或正在操作则禁用 -->
class="bg-green-500 px-3 py-1 rounded-md text-white hover:bg-green-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" <!-- 样式:绿色背景,白色文字,悬停变深,小字体,禁用时半透明 -->
>
{{ operatingId === item.id ? '保存中...' : '保存' }} <!-- 根据操作状态显示不同文本 -->
</button>
<button
@click="handleCancelEdit" <!-- 点击取消编辑 -->
:disabled="operatingId === item.id" <!-- 如果正在操作则禁用 -->
class="bg-gray-500 px-3 py-1 rounded-md text-white hover:bg-gray-600 text-sm disabled:opacity-50" <!-- 样式:灰色背景,白色文字,悬停变深 -->
>
取消
</button>
</template>
<template v-else> <!-- 如果未在编辑模式,显示编辑和删除按钮 -->
<button
@click="handleEdit(item.id, item.title)" <!-- 点击进入编辑模式 -->
:disabled="operatingId === item.id || editingId !== null" <!-- 如果正在操作此项或有其他项正在编辑则禁用 -->
class="bg-blue-500 px-3 py-1 rounded-md text-white hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" <!-- 样式:蓝色背景,白色文字,悬停变深 -->
>
编辑
</button>
<button
@click="handleDelete(item.id)" <!-- 点击删除此项 -->
:disabled="operatingId === item.id || editingId !== null" <!-- 如果正在操作此项或有其他项正在编辑则禁用 -->
class="bg-red-500 px-3 py-1 rounded-md text-white hover:bg-red-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" <!-- 样式:红色背景,白色文字,悬停变深 -->
>
{{ operatingId === item.id ? '删除中...' : '删除' }} <!-- 根据操作状态显示不同文本 -->
</button>
</template>
</div>
</div>
</div>
</main>
</div>
</div>
</template>
知识点:
-
Vue Composition API (
<script setup>
): 这是 Vue 3 中推荐的编写组件逻辑的方式。它让我们能更灵活地组织代码。-
ref
: 用于创建响应式变量。当ref
的.value
属性改变时,Vue 会自动更新模板中所有用到它的地方。 -
onMounted
: 一个生命周期钩子,在组件被挂载到 DOM 后调用。我们在这里调用fetchTodos
来初始化数据,类似于 React 的useEffect(..., [])
。
-
-
模板语法:
-
v-model
: 用于在表单输入和应用状态之间创建双向绑定。 -
@click
,@keydown.enter
: 是v-on:click
和v-on:keydown.enter
的缩写,用于监听 DOM 事件。 -
:disabled
: 是v-bind:disabled
的缩写,用于动态地绑定一个属性。 -
v-if
,v-else
: 用于条件渲染,根据表达式的真假来渲染一个块。 -
v-for
: 用于循环渲染一个列表。
-
-
逻辑对比: 你会发现 Vue 组件中的逻辑(如
handleAdd
,handleDelete
等函数)与 React 组件中的几乎完全一样。主要的区别在于状态管理和模板更新的方式:React 使用useState
和显式的set
函数,而 Vue 使用ref
和自动的响应式系统。
第四部分:运行整个应用
现在,你已经完成了所有编码工作!要运行整个应用,你需要:
-
启动后端:
-
打开一个终端,进入
go-todo
目录。 -
运行
go run main.go
。 -
你的 API 服务器现在应该运行在
http://localhost:8080
。
-
-
启动 React 前端:
-
打开第二个终端,进入
react-todo
目录。 -
运行
pnpm dev
。 -
你的 React 应用现在应该运行在(通常是)
http://localhost:5173
。
-
-
启动 Vue 前端:
-
打开第三个终端,进入
vue-todo
目录。 -
运行
pnpm dev
。 -
你的 Vue 应用现在应该运行在(通常是)
http://localhost:5174
。
-
现在你可以打开浏览器访问 React 或 Vue 应用的地址,并开始添加、编辑和删除你的待办事项了!
总结
恭喜你!你已经成功构建了一个包含 Go 后端和 React/Vue 双前端的全栈应用。通过这个项目,你实践了:
-
使用 Gin 和 GORM 构建 RESTful API。
-
使用 Vite 快速搭建现代前端开发环境。
-
在 React (Hooks) 和 Vue (Composition API) 中进行状态管理、数据获取和事件处理。
-
使用 Tailwind CSS 高效地构建 UI。
希望这个教程能帮助你更好地理解和掌握这些技术。你可以此为基础,继续探索更多功能,例如用户认证、更复杂的数据模型或部署到生产环境。