Appearance
实战 2:待办事项(TodoList)应用
在本章中,我们将创建一个待办事项(TodoList)应用,这是一个非常适合新手练习的基础项目。通过这个项目,你将学习如何使用 React 的状态管理、组件交互以及本地存储等核心概念。
12.1 项目搭建与组件拆分
12.1.1 项目初始化
步骤 1:创建 Next.js 项目
bash
npx create-next-app@latest todo-list --tailwind --eslint --app --src-dir步骤 2:安装依赖
bash
cd todo-list
npm install12.1.2 项目结构规划
src/
app/
layout.js # 全局布局
page.js # 首页(TodoList 应用)
components/ # 组件
TodoForm.js # 待办事项表单
TodoItem.js # 待办事项项
TodoList.js # 待办事项列表
Filter.js # 过滤组件
lib/ # 工具函数
storage.js # 本地存储工具
styles/ # 样式文件
globals.css # 全局样式12.1.3 组件拆分
我们将应用拆分为以下组件:
- TodoForm:用于添加新的待办事项
- TodoItem:展示单个待办事项,包含编辑、删除和完成功能
- TodoList:管理所有待办事项的列表
- Filter:用于过滤待办事项(全部、已完成、未完成)
12.2 状态管理
我们将使用 React 的 useState 和 useReducer 来管理待办事项的状态。
12.2.1 状态结构设计
javascript
// 待办事项状态结构
const initialState = {
todos: [
{
id: 1,
text: '学习 Next.js',
completed: false,
editing: false
},
{
id: 2,
text: '完成 TodoList 应用',
completed: false,
editing: false
}
],
filter: 'all' // 'all', 'completed', 'active'
};12.2.2 使用 useReducer 管理状态
javascript
// src/app/page.js
'use client';
import { useReducer, useEffect } from 'react';
import TodoForm from '@/components/TodoForm';
import TodoList from '@/components/TodoList';
import Filter from '@/components/Filter';
import { loadTodos, saveTodos } from '@/lib/storage';
// 定义 action 类型
const ACTIONS = {
ADD_TODO: 'add-todo',
TOGGLE_TODO: 'toggle-todo',
DELETE_TODO: 'delete-todo',
EDIT_TODO: 'edit-todo',
SET_FILTER: 'set-filter',
SET_TODOS: 'set-todos'
};
// Reducer 函数
function reducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_TODO:
const newTodo = {
id: Date.now(),
text: action.payload,
completed: false,
editing: false
};
const updatedTodos = [...state.todos, newTodo];
saveTodos(updatedTodos);
return { ...state, todos: updatedTodos };
case ACTIONS.TOGGLE_TODO:
const toggledTodos = state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
saveTodos(toggledTodos);
return { ...state, todos: toggledTodos };
case ACTIONS.DELETE_TODO:
const filteredTodos = state.todos.filter(todo => todo.id !== action.payload);
saveTodos(filteredTodos);
return { ...state, todos: filteredTodos };
case ACTIONS.EDIT_TODO:
const editedTodos = state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text, editing: false }
: todo
);
saveTodos(editedTodos);
return { ...state, todos: editedTodos };
case ACTIONS.SET_FILTER:
return { ...state, filter: action.payload };
case ACTIONS.SET_TODOS:
return { ...state, todos: action.payload };
default:
return state;
}
}
export default function Home() {
const [state, dispatch] = useReducer(reducer, { todos: [], filter: 'all' });
// 从本地存储加载待办事项
useEffect(() => {
const todos = loadTodos();
dispatch({ type: ACTIONS.SET_TODOS, payload: todos });
}, []);
return (
<div className="container mx-auto px-4 py-8 max-w-md">
<h1 className="text-3xl font-bold mb-8 text-center">待办事项</h1>
<TodoForm dispatch={dispatch} />
<TodoList state={state} dispatch={dispatch} />
<Filter filter={state.filter} dispatch={dispatch} />
</div>
);
}12.3 功能实现
12.3.1 TodoForm 组件
javascript
// src/components/TodoForm.js
import { useState } from 'react';
import { ACTIONS } from '@/app/page';
export default function TodoForm({ dispatch }) {
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch({ type: ACTIONS.ADD_TODO, payload: text });
setText('');
}
};
return (
<form onSubmit={handleSubmit} className="mb-6">
<div className="flex">
<input
type="text"
placeholder="添加新的待办事项..."
value={text}
onChange={(e) => setText(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-l-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded-r-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
添加
</button>
</div>
</form>
);
}12.3.2 TodoItem 组件
javascript
// src/components/TodoItem.js
import { useState } from 'react';
import { ACTIONS } from '@/app/page';
export default function TodoItem({ todo, dispatch }) {
const [editText, setEditText] = useState(todo.text);
const handleEdit = () => {
dispatch({ type: ACTIONS.EDIT_TODO, payload: { id: todo.id, text: editText } });
};
return (
<li className="flex items-center justify-between p-3 border-b border-gray-200">
{todo.editing ? (
<div className="flex-1">
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleEdit}
onKeyPress={(e) => e.key === 'Enter' && handleEdit()}
className="w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
</div>
) : (
<>
<div className="flex items-center flex-1">
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: ACTIONS.TOGGLE_TODO, payload: todo.id })}
className="mr-3 h-5 w-5 text-blue-600 rounded focus:ring-blue-500"
/>
<span className={`${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.text}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => dispatch({ type: ACTIONS.DELETE_TODO, payload: todo.id })}
className="text-red-500 hover:text-red-700 focus:outline-none"
>
删除
</button>
<button
onClick={() => dispatch({ type: ACTIONS.EDIT_TODO, payload: { id: todo.id, editing: true } })}
className="text-blue-500 hover:text-blue-700 focus:outline-none"
>
编辑
</button>
</div>
</>
)}
</li>
);
}12.3.3 TodoList 组件
javascript
// src/components/TodoList.js
import TodoItem from './TodoItem';
export default function TodoList({ state, dispatch }) {
const { todos, filter } = state;
// 根据过滤器筛选待办事项
const filteredTodos = todos.filter(todo => {
if (filter === 'completed') return todo.completed;
if (filter === 'active') return !todo.completed;
return true;
});
return (
<div className="mb-6">
<ul className="bg-white rounded-md shadow-sm">
{filteredTodos.length === 0 ? (
<li className="p-3 text-center text-gray-500">
{filter === 'all' ? '没有待办事项' :
filter === 'completed' ? '没有已完成的待办事项' : '没有未完成的待办事项'}
</li>
) : (
filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} dispatch={dispatch} />
))
)}
</ul>
<div className="mt-3 text-sm text-gray-500">
剩余 {todos.filter(todo => !todo.completed).length} 项待办
</div>
</div>
);
}12.3.4 Filter 组件
javascript
// src/components/Filter.js
import { ACTIONS } from '@/app/page';
export default function Filter({ filter, dispatch }) {
return (
<div className="flex justify-center space-x-4">
<button
onClick={() => dispatch({ type: ACTIONS.SET_FILTER, payload: 'all' })}
className={`px-3 py-1 rounded-full ${filter === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}`}
>
全部
</button>
<button
onClick={() => dispatch({ type: ACTIONS.SET_FILTER, payload: 'active' })}
className={`px-3 py-1 rounded-full ${filter === 'active' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}`}
>
未完成
</button>
<button
onClick={() => dispatch({ type: ACTIONS.SET_FILTER, payload: 'completed' })}
className={`px-3 py-1 rounded-full ${filter === 'completed' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}`}
>
已完成
</button>
</div>
);
}12.4 本地存储持久化
为了让待办事项在页面刷新后仍然存在,我们将使用本地存储(localStorage)来保存数据。
12.4.1 存储工具函数
javascript
// src/lib/storage.js
const STORAGE_KEY = 'todos';
export function saveTodos(todos) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('Error saving todos to localStorage:', error);
}
}
export function loadTodos() {
try {
const todos = localStorage.getItem(STORAGE_KEY);
return todos ? JSON.parse(todos) : [];
} catch (error) {
console.error('Error loading todos from localStorage:', error);
return [];
}
}12.4.2 集成到应用中
我们已经在 page.js 中集成了本地存储功能:
- 使用
useEffect在组件挂载时从本地存储加载待办事项 - 在
reducer函数中,每当待办事项发生变化时,将其保存到本地存储
12.5 自定义工具函数封装
我们可以封装一些工具函数来处理待办事项的操作逻辑,使代码更加清晰和可维护。
12.5.1 待办事项操作工具
javascript
// src/lib/todoUtils.js
import { saveTodos } from './storage';
// 添加待办事项
export function addTodo(todos, text) {
const newTodo = {
id: Date.now(),
text,
completed: false,
editing: false
};
const updatedTodos = [...todos, newTodo];
saveTodos(updatedTodos);
return updatedTodos;
}
// 切换待办事项状态
export function toggleTodo(todos, id) {
const updatedTodos = todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
saveTodos(updatedTodos);
return updatedTodos;
}
// 删除待办事项
export function deleteTodo(todos, id) {
const updatedTodos = todos.filter(todo => todo.id !== id);
saveTodos(updatedTodos);
return updatedTodos;
}
// 编辑待办事项
export function editTodo(todos, id, text) {
const updatedTodos = todos.map(todo =>
todo.id === id ? { ...todo, text, editing: false } : todo
);
saveTodos(updatedTodos);
return updatedTodos;
}
// 过滤待办事项
export function filterTodos(todos, filter) {
switch (filter) {
case 'completed':
return todos.filter(todo => todo.completed);
case 'active':
return todos.filter(todo => !todo.completed);
default:
return todos;
}
}
// 获取未完成的待办事项数量
export function getActiveCount(todos) {
return todos.filter(todo => !todo.completed).length;
}12.5.2 使用工具函数重构应用
javascript
// src/app/page.js (重构后)
'use client';
import { useReducer, useEffect } from 'react';
import TodoForm from '@/components/TodoForm';
import TodoList from '@/components/TodoList';
import Filter from '@/components/Filter';
import { loadTodos } from '@/lib/storage';
import * as todoUtils from '@/lib/todoUtils';
// 定义 action 类型
const ACTIONS = {
ADD_TODO: 'add-todo',
TOGGLE_TODO: 'toggle-todo',
DELETE_TODO: 'delete-todo',
EDIT_TODO: 'edit-todo',
SET_FILTER: 'set-filter',
SET_TODOS: 'set-todos'
};
// Reducer 函数
function reducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_TODO:
return {
...state,
todos: todoUtils.addTodo(state.todos, action.payload)
};
case ACTIONS.TOGGLE_TODO:
return {
...state,
todos: todoUtils.toggleTodo(state.todos, action.payload)
};
case ACTIONS.DELETE_TODO:
return {
...state,
todos: todoUtils.deleteTodo(state.todos, action.payload)
};
case ACTIONS.EDIT_TODO:
return {
...state,
todos: todoUtils.editTodo(state.todos, action.payload.id, action.payload.text)
};
case ACTIONS.SET_FILTER:
return { ...state, filter: action.payload };
case ACTIONS.SET_TODOS:
return { ...state, todos: action.payload };
default:
return state;
}
}
export default function Home() {
const [state, dispatch] = useReducer(reducer, { todos: [], filter: 'all' });
// 从本地存储加载待办事项
useEffect(() => {
const todos = loadTodos();
dispatch({ type: ACTIONS.SET_TODOS, payload: todos });
}, []);
return (
<div className="container mx-auto px-4 py-8 max-w-md">
<h1 className="text-3xl font-bold mb-8 text-center">待办事项</h1>
<TodoForm dispatch={dispatch} />
<TodoList state={state} dispatch={dispatch} />
<Filter filter={state.filter} dispatch={dispatch} />
</div>
);
}12.6 完整代码与优化建议
12.6.1 完整代码
项目结构:
src/app/page.js:主页面,包含状态管理和组件组织src/components/TodoForm.js:添加待办事项的表单src/components/TodoItem.js:单个待办事项的展示和操作src/components/TodoList.js:待办事项列表的展示和过滤src/components/Filter.js:待办事项的过滤选项src/lib/storage.js:本地存储工具函数src/lib/todoUtils.js:待办事项操作工具函数
12.6.2 优化建议
性能优化:
- 使用
useCallback优化事件处理函数,避免不必要的重新渲染 - 使用
useMemo优化过滤后的待办事项列表,避免每次渲染都重新计算
- 使用
用户体验优化:
- 添加动画效果,使待办事项的添加、删除和完成状态变化更加平滑
- 添加键盘快捷键支持,如按 Enter 添加新待办事项,按 Escape 取消编辑
- 添加清空已完成待办事项的功能
代码质量优化:
- 使用 TypeScript 为组件和函数添加类型定义
- 添加单元测试,确保功能的正确性
- 使用 ESLint 和 Prettier 保持代码风格一致
功能扩展:
- 添加待办事项的优先级设置
- 添加待办事项的截止日期
- 添加待办事项的分类标签
- 实现待办事项的拖拽排序
12.7 项目总结
通过本实战项目的学习,你已经掌握了以下核心概念:
- 状态管理:使用
useState和useReducer管理应用状态 - 组件通信:通过 props 和回调函数实现组件之间的通信
- 本地存储:使用
localStorage实现数据持久化 - 表单处理:处理表单提交和输入验证
- 条件渲染:根据状态渲染不同的 UI 内容
- 过滤和排序:根据条件过滤待办事项
这个 TodoList 应用虽然简单,但它涵盖了 React 开发的核心概念,是一个非常好的新手练习项目。通过不断优化和扩展这个项目,你可以进一步巩固和提升你的 React 开发技能。
