待办事项列表
构建一个支持 CRUD 操作的完整待办事项列表应用
在本教程中,你将构建一个功能完备的待办事项列表应用。本教程比二维码服务教程更进一步——你将学习如何管理状态、处理多项操作,以及创建精致的用户界面。
你将构建:
- 一个具备添加、完成和删除功能的完整待办事项应用
- 线程安全的状态管理(对桌面应用很重要)
- 现代的玻璃拟态 UI 设计
- 全部使用原生 JavaScript 实现,无需框架
你将学到:
- CRUD 操作(创建、读取、更新、删除)
- 在 Go 中安全地管理可变状态
- 处理和验证用户输入
- 构建具有原生应用体验的响应式 UI

完成所需时间:20分钟
创建项目
生成项目
首先,创建一个新的 Wails 项目。我们将使用默认的原生模板,以便从一个简洁的基础开始:
wails3 init -n todo-app
cd todo-app
这会创建一个具有基本结构的新项目:Go 后端位于根目录,前端代码位于frontend/目录中。
创建待办事项服务
待办事项服务将管理应用状态,并提供执行 CRUD 操作的方法。Web 服务器的每个请求彼此隔离,而桌面应用可能同时执行多项操作,因此我们需要采用线程安全的状态管理方式。
删除greetservice.go,然后创建新文件todoservice.go:
package main
import (
"errors"
"sync"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
type TodoService struct {
todos []Todo
nextID int
mu sync.RWMutex
}
func NewTodoService() *TodoService {
return &TodoService{
todos: []Todo{},
nextID: 1,
}
}
func (t *TodoService) GetAll() []Todo {
t.mu.RLock()
defer t.mu.RUnlock()
return t.todos
}
func (t *TodoService) Add(title string) (*Todo, error) {
if title == "" {
return nil, errors.New("title cannot be empty")
}
t.mu.Lock()
defer t.mu.Unlock()
todo := Todo{
ID: t.nextID,
Title: title,
Completed: false,
}
t.todos = append(t.todos, todo)
t.nextID++
return &todo, nil
}
func (t *TodoService) Toggle(id int) error {
t.mu.Lock()
defer t.mu.Unlock()
for i := range t.todos {
if t.todos[i].ID == id {
t.todos[i].Completed = !t.todos[i].Completed
return nil
}
}
return errors.New("todo not found")
}
func (t *TodoService) Delete(id int) error {
t.mu.Lock()
defer t.mu.Unlock()
for i, todo := range t.todos {
if todo.ID == id {
t.todos = append(t.todos[:i], t.todos[i+1:]...)
return nil
}
}
return errors.New("todo not found")
}这里发生了什么:
Todo结构体:
- 使用 ID、Title 和 Completed 字段定义数据结构
json:标签告诉 Go 如何将此结构体转换为供前端使用的 JSON- 每个字段都已导出(首字母大写),因此绑定生成器可以识别它们
TodoService结构体:
todos []Todo——保存所有待办事项的切片nextID int——记录下一个要分配的 ID(模拟自增)mu sync.RWMutex——用于实现线程安全访问的读写互斥锁
使用sync.RWMutex实现线程安全:
- 桌面应用的 UI 可能同时发起多项操作
RLock()允许多个读取操作同时进行(例如,多次调用GetAll)Lock()为写入操作提供独占访问权限(例如Add、Toggle和Delete)defer确保即使函数提前返回或发生 panic,也会释放锁
方法:
GetAll()——返回所有待办事项(由于不修改数据,因此使用读锁)Add(title)——创建新的待办事项、验证输入并递增 IDToggle(id)——切换待办事项的完成状态Delete(id)——从切片中移除待办事项
错误处理:
- 遵循 Go 的惯例,我们将
error作为最后一个返回值 - 拒绝空标题
- 对不存在的待办事项执行操作时会返回错误
- 这些错误会在前端转换为 JavaScript 异常
更新 main.go
在 Wails 应用中注册待办事项服务。在main.go中找到Services部分,然后用 TodoService 替换 GreetService:
Services: []application.Service{
application.NewService(NewTodoService()),
},这里发生了什么:
- 我们移除默认的 GreetService,改为添加 TodoService
application.NewService()会封装我们的服务,以便 Wails 对其进行管理- Wails 会自动为此服务的所有公开方法生成 JavaScript 绑定
创建前端 UI
现在来构建前端。我们将在这里调用 Go 方法并显示 UI。为了保持简单,并直接展示绑定的工作方式,我们使用原生 JavaScript。
替换frontend/src/main.js:
import {TodoService} from "../bindings/changeme";
async function loadTodos() {
const todos = await TodoService.GetAll();
const list = document.getElementById('todo-list');
list.innerHTML = todos.map(todo => `
<div class="todo ${todo.completed ? 'completed' : ''}">
<input type="checkbox"
${todo.completed ? 'checked' : ''}
onchange="toggleTodo(${todo.id})">
<span>${todo.title}</span>
<button onclick="deleteTodo(${todo.id})">Delete</button>
</div>
`).join('');
}
window.addTodo = async () => {
const input = document.getElementById('todo-input');
const title = input.value.trim();
if (title) {
await TodoService.Add(title);
input.value = '';
await loadTodos();
}
}
window.toggleTodo = async (id) => {
await TodoService.Toggle(id);
await loadTodos();
}
window.deleteTodo = async (id) => {
await TodoService.Delete(id);
await loadTodos();
}
// Load todos on startup
loadTodos();这里发生了什么:
导入绑定:
import {TodoService} from "../bindings/changeme"——导入自动生成的 Go 绑定- 注意:
changeme应替换为go.mod中的实际模块名称
loadTodos()函数:
- 调用
TodoService.GetAll(),从 Go 获取所有待办事项 - 使用模板字面量为每个待办事项构建 HTML
- 动态添加或移除
completed类以应用样式 - 使用
onclick属性将按钮连接到我们的函数 - 将所有 HTML 拼接在一起并注入 DOM
CRUD 函数:
addTodo()——验证输入、调用 Go 的Add方法,然后刷新列表toggleTodo(id)——调用 Go 的Toggle方法,然后刷新列表deleteTodo(id)——调用 Go 的Delete方法,然后刷新列表- 所有函数都是异步函数,因为 Go 调用会返回 Promise
为什么要挂载到 window:
window.addTodo = ...使 HTML 的onclick属性能够访问这些函数- 这是一个适用于原生 JavaScript 的简单模式(框架的处理方式有所不同)
- 在生产环境中,可以改用规范的事件委托
刷新模式:
- 每次更改(添加、切换状态或删除)后,我们都会再次调用
loadTodos() - 这样可以确保 UI 与 Go 中的状态保持同步
- 另一种方式:让 Go 方法返回新状态,从而避免第二次调用
更新 HTML
HTML 为 TODO 应用提供结构。它简洁且语义清晰,真正发挥作用的是 JavaScript 和 CSS。
替换frontend/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>TODO App</title>
<link rel="stylesheet" href="./style.css"/>
</head>
<body>
<div class="container">
<h1>My TODOs</h1>
<div class="card">
<div class="input-box">
<input type="text"
id="todo-input"
class="input"
placeholder="Add a new todo..."
onkeypress="if(event.key==='Enter') addTodo()">
<button class="btn" onclick="addTodo()">Add</button>
</div>
<div id="todo-list"></div>
</div>
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>这里的工作原理:
结构:
container— 将应用居中并限制其宽度card— 容纳所有内容的白色主卡片input-box— 用于输入框和“添加”按钮的弹性容器todo-list— JavaScript 将各个待办事项插入此处
事件处理:
onkeypress="if(event.key==='Enter') addTodo()"— 按下 Enter 时添加待办事项onclick="addTodo()"— 单击按钮时添加待办事项- 内联事件处理程序很适合简单的原生 JavaScript 应用
模块脚本:
<script type="module">允许我们使用 ES6 导入- 我们的
main.js文件可以导入绑定并使用现代 JavaScript
设置应用样式
CSS 通过平滑过渡营造出具有现代感的玻璃拟态设计。我们的目标是打造精致的使用体验,让应用用起来更加愉悦。
替换frontend/public/style.css:
:root {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
font-size: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: rgba(255, 255, 255, 0.87);
}
body {
margin: 0;
display: flex;
place-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 600px;
padding: 20px;
}
h1 {
text-align: center;
color: white;
font-size: 2.5em;
font-weight: 300;
margin: 0 0 30px 0;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.input-box {
display: flex;
gap: 10px;
margin-bottom: 25px;
}
.input {
flex: 1;
border: 2px solid #e0e0e0;
border-radius: 12px;
height: 50px;
padding: 0 20px;
font-size: 16px;
transition: all 0.3s ease;
}
.input:focus {
border-color: #667eea;
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn {
height: 50px;
padding: 0 30px;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
#todo-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.todo {
display: flex;
align-items: center;
padding: 18px 20px;
background: white;
border: 2px solid #f0f0f0;
border-radius: 12px;
transition: all 0.3s ease;
gap: 15px;
}
.todo:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
transform: translateX(4px);
}
.todo.completed {
opacity: 0.6;
}
.todo.completed span {
text-decoration: line-through;
color: #999;
}
.todo input[type="checkbox"] {
width: 24px;
height: 24px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
border: 2px solid #667eea;
border-radius: 6px;
position: relative;
transition: all 0.3s ease;
flex-shrink: 0;
}
.todo input[type="checkbox"]:hover {
background: rgba(102, 126, 234, 0.1);
}
.todo input[type="checkbox"]:checked {
background: #667eea;
border-color: #667eea;
}
.todo input[type="checkbox"]:checked::after {
content: '✓';
position: absolute;
color: white;
font-size: 16px;
font-weight: bold;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.todo span {
flex: 1;
font-size: 16px;
color: #333;
}
.todo button {
padding: 8px 16px;
background: #ff4757;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
opacity: 0;
flex-shrink: 0;
}
.todo:hover button {
opacity: 1;
}
.todo button:hover {
background: #ee5a6f;
transform: scale(1.05);
}
#todo-list:empty::before {
content: "No todos yet. Add one above!";
display: block;
text-align: center;
padding: 40px 20px;
color: #999;
}这里的工作原理:
玻璃拟态设计:
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)— 紫色渐变背景backdrop-filter: blur(10px)— 在卡片上营造磨砂玻璃效果rgba(255, 255, 255, 0.95)— 使用半透明白色营造玻璃效果
自定义复选框样式:
appearance: none会移除浏览器的默认复选框- 我们使用
::after创建带有对勾的自定义圆角方框 - 当
checked时,会使用 Unicode 字符 ✓ 显示对勾
悬停交互:
- 悬停时,待办事项向右滑动(
transform: translateX(4px)) - 删除按钮会一直隐藏,直到悬停时才显示(
opacity: 0→opacity: 1) - 悬停时按钮会略微放大,以提供触感反馈
空状态:
- 没有待办事项时,
#todo-list:empty::before会显示一条消息 - 纯 CSS 解决方案,无需 JavaScript
运行应用
来看看实际效果!运行开发服务器:
wails3 dev
应用将完成编译并打开。请尝试以下操作:
- 输入待办事项,然后按 Enter 或单击“添加”
- 单击复选框,将其标记为已完成
- 将鼠标悬停在待办事项上,查看删除按钮出现
- 注意 UI 如何即时更新——这正是刷新模式在发挥作用
工作原理:
- Wails 自动为 TodoService 方法生成了绑定
- 开发模式包含热重载功能——尝试修改 CSS,观察它自动更新
- Go 代码以原生方式运行,无需转换或解释
工作原理
线程安全的状态管理
sync.RWMutex提供安全的并发访问:
func (t *TodoService) GetAll() []Todo {
t.mu.RLock() // Read lock - multiple readers allowed
defer t.mu.RUnlock()
return t.todos
}
func (t *TodoService) Add(title string) (*Todo, error) {
t.mu.Lock() // Write lock - exclusive access
defer t.mu.Unlock()
// ... mutations
}为什么这很重要:
- 多个前端调用可能并发发生
- 读取操作不会相互阻塞
- 写入操作会获得独占访问权
defer可确保锁始终得到释放
错误处理
对于无效操作,服务会返回错误:
func (t *TodoService) Add(title string) (*Todo, error) {
if title == "" {
return nil, errors.New("title cannot be empty")
}
// ...
}在前端中,可以捕获这些错误:
try {
await TodoService.Add(title);
} catch (err) {
alert('Error: ' + err);
}状态同步
每次更改后,我们都会重新加载完整列表:
window.addTodo = async () => {
await TodoService.Add(title); // Mutation
await loadTodos(); // Refresh
}另一种方式:让每个方法返回更新后的列表,从而避免第二次调用。
增强功能
添加统计信息
将以下内容添加到todoservice.go:
type TodoStats struct {
Total int `json:"total"`
Completed int `json:"completed"`
Active int `json:"active"`
}
func (t *TodoService) GetStats() TodoStats {
t.mu.RLock()
defer t.mu.RUnlock()
stats := TodoStats{
Total: len(t.todos),
}
for _, todo := range t.todos {
if todo.Completed {
stats.Completed++
} else {
stats.Active++
}
}
return stats
}在前端中显示:
async function loadTodos() {
const [todos, stats] = await Promise.all([
TodoService.GetAll(),
TodoService.GetStats()
]);
// Display stats
document.getElementById('stats').textContent =
`${stats.active} active, ${stats.completed} completed`;
// ... render todos
}添加“清除已完成项”
func (t *TodoService) ClearCompleted() int {
t.mu.Lock()
defer t.mu.Unlock()
removed := 0
newTodos := []Todo{}
for _, todo := range t.todos {
if !todo.Completed {
newTodos = append(newTodos, todo)
} else {
removed++
}
}
t.todos = newTodos
return removed
}添加持久化
对于生产应用,请使用最适合应用的存储解决方案添加持久化,例如 SQLite、PostgreSQL 或托管服务。
构建生产版本
准备分发 TODO 应用时,请构建生产版本:
wails3 build
这会在bin/中创建经过优化的原生可执行文件:
- 启用优化来编译 Go 代码
- 为生产环境构建前端
- 将所有内容打包到一个可执行文件中
- 生成的应用通常为10-20MB(相比之下,Electron 应用为150MB 以上)
你可以直接运行该可执行文件——无需运行时,也无需启动服务器。它是真正的原生应用。
你构建的内容
你刚刚构建了一个完整的 TODO 应用,其中包括:
完整的 CRUD 实现:
- 创建了一个提供创建、读取、更新和删除操作的服务
- 添加了输入验证和错误处理
- 了解了 Go 错误如何转换为 JavaScript 异常
线程安全的状态管理:
- 使用
sync.RWMutex安全处理并发访问 - 理解了读锁(RLock)与写锁(Lock)之间的区别
- 了解了
defer如何通过保证执行清理操作来防止死锁
现代而精致的 UI:
- 构建了带有渐变和模糊效果的玻璃拟态界面
- 不使用任何框架,创建了自定义样式的复选框
- 添加了悬停交互和过渡效果,营造原生应用体验
- 使用纯 CSS 实现了空状态
Wails 基础知识:
- 服务注册和自动生成绑定
- 使用 async/await 从 JavaScript 调用 Go 方法
- 在 Go 与前端之间同步状态
- 构建和打包原生桌面应用
后续步骤
现在你已经了解 CRUD 操作和状态管理,可以尝试:
- 添加持久化:使用 SQLite 或其他存储解决方案,让待办事项在应用重启后仍然保留
- 添加更多功能:筛选(全部/未完成/已完成)、编辑现有待办事项、批量操作
- 探索 Notes 教程:了解Notes中的文件操作如何工作
- 构建真正实用的应用:运用这些概念构建你自己的应用!