Skip to content

实战 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 install

12.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 的 useStateuseReducer 来管理待办事项的状态。

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 优化建议

  1. 性能优化

    • 使用 useCallback 优化事件处理函数,避免不必要的重新渲染
    • 使用 useMemo 优化过滤后的待办事项列表,避免每次渲染都重新计算
  2. 用户体验优化

    • 添加动画效果,使待办事项的添加、删除和完成状态变化更加平滑
    • 添加键盘快捷键支持,如按 Enter 添加新待办事项,按 Escape 取消编辑
    • 添加清空已完成待办事项的功能
  3. 代码质量优化

    • 使用 TypeScript 为组件和函数添加类型定义
    • 添加单元测试,确保功能的正确性
    • 使用 ESLint 和 Prettier 保持代码风格一致
  4. 功能扩展

    • 添加待办事项的优先级设置
    • 添加待办事项的截止日期
    • 添加待办事项的分类标签
    • 实现待办事项的拖拽排序

12.7 项目总结

通过本实战项目的学习,你已经掌握了以下核心概念:

  1. 状态管理:使用 useStateuseReducer 管理应用状态
  2. 组件通信:通过 props 和回调函数实现组件之间的通信
  3. 本地存储:使用 localStorage 实现数据持久化
  4. 表单处理:处理表单提交和输入验证
  5. 条件渲染:根据状态渲染不同的 UI 内容
  6. 过滤和排序:根据条件过滤待办事项

这个 TodoList 应用虽然简单,但它涵盖了 React 开发的核心概念,是一个非常好的新手练习项目。通过不断优化和扩展这个项目,你可以进一步巩固和提升你的 React 开发技能。

© 2026 编程马·菜鸟教程 版权所有