第26章 项目一——Todo App增强版

Chapter-26 - 项目一——Todo App 增强版

26.1 项目需求分析与结构设计

26.1.1 功能列表:增删改查、筛选、分类、持久化

一个完整的 Todo App 增强版不只是"能记事情"这么简单——它要像一个小型的效率工具,让用户感受到"我在掌控我的任务"。

核心 CRUD 是根基:添加任务不用多说;删除任务要考虑"误删怎么办",可以加个确认;编辑任务最自然的交互是双击进入编辑状态;完成任务则是点击复选框,给用户一种"划掉"的满足感。

筛选和分类是让 Todo App 从"能用"到"好用"的关键。当任务多了之后,用户不想每次都看到所有任务——“只看没完成的"是最常见需求,所以至少要有全部/已完成/未完成三档筛选。如果再加上按标签分类,就更有组织性了。

本地持久化(localStorage) 让数据不丢失——页面刷新甚至关掉浏览器,下次打开任务还在。这不需要后端,一个浏览器内置 API 就够了,是纯前端项目练手的完美选择。

拖拽排序是加分项,让用户可以自由调整任务优先级,把最重要的拖到最上面。这需要用到 @dnd-kitreact-beautiful-dnd 等库。

26.1.2 目录结构设计

src/
├── features/
│   └── todos/
│       ├── components/
│       │   ├── TodoItem.jsx
│       │   ├── TodoList.jsx
│       │   ├── TodoForm.jsx
│       │   ├── TodoFilter.jsx
│       │   └── TodoStats.jsx
│       ├── hooks/
│       │   └── useTodos.js
│       ├── stores/
│       │   └── todoStore.js
│       └── types/
│           └── index.js
├── App.jsx
└── main.jsx

26.1.3 技术选型:纯 React vs + 状态管理

方案适用场景
纯 useState + useReducer简单 Todo
Zustand中等复杂度
Redux Toolkit大型应用

26.2 组件划分

26.2.1 组件拆分方案

TodoApp (根组件)
├── TodoHeader
├── TodoForm (添加表单)
├── TodoFilter (筛选器)
├── TodoList (列表容器)
│   └── TodoItem (单个任务项)
│       ├── TodoCheckbox
│       ├── TodoText
│       └── TodoActions
└── TodoStats (统计)

26.2.2 TodoItem / TodoList / TodoForm / TodoFilter 组件设计

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// TodoItem 组件
function TodoItem({ todo, onToggle, onDelete, onUpdate }) {
  const [isEditing, setIsEditing] = useState(false)
  const [editText, setEditText] = useState(todo.text)

  function handleDoubleClick() {
    setIsEditing(true)
  }

  function handleSave() {
    onUpdate(todo.id, { text: editText })
    setIsEditing(false)
  }

  return (
    <div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />

      {isEditing ? (
        <input
          value={editText}
          onChange={e => setEditText(e.target.value)}
          onBlur={handleSave}
          onKeyDown={e => { if (e.key === 'Enter') handleSave() }}
          autoFocus
        />
      ) : (
        <span onDoubleClick={handleDoubleClick}>{todo.text}</span>
      )}

      <button onClick={() => onDelete(todo.id)}>删除</button>
    </div>
  )
}

26.2.3 目录组织:feature-based 结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// features/todos/store/todoStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useTodoStore = create(
  persist(
    (set, get) => ({
      todos: [],

      addTodo: (text) => set(state => ({
        todos: [
          ...state.todos,
          {
            id: Date.now(),
            text,
            completed: false,
            createdAt: new Date().toISOString()
          }
        ]
      })),

      toggleTodo: (id) => set(state => ({
        todos: state.todos.map(t =>
          t.id === id ? { ...t, completed: !t.completed } : t
        )
      })),

      deleteTodo: (id) => set(state => ({
        todos: state.todos.filter(t => t.id !== id)
      })),

      updateTodo: (id, updates) => set(state => ({
        todos: state.todos.map(t =>
          t.id === id ? { ...t, ...updates } : t
        )
      })),

      clearCompleted: () => set(state => ({
        todos: state.todos.filter(t => !t.completed)
      }))
    }),
    { name: 'todo-storage' }
  )
)

26.3 状态管理

26.3.1 纯 useState 方案

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function useTodos() {
  const [todos, setTodos] = useState([])

  const addTodo = (text) => {
    setTodos(prev => [...prev, { id: Date.now(), text, completed: false }])
  }

  const toggleTodo = (id) => {
    setTodos(prev =>
      prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    )
  }

  return { todos, addTodo, toggleTodo }
}

26.3.2 useReducer 方案

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const todoReducer = (state, action) => {
  switch (action.type) {
    case 'ADD':
      return [...state, action.payload]
    case 'TOGGLE':
      return state.map(t =>
        t.id === action.payload.id ? { ...t, completed: !t.completed } : t
      )
    case 'DELETE':
      return state.filter(t => t.id !== action.payload.id)
    default:
      return state
  }
}

function useTodosReducer() {
  const [todos, dispatch] = useReducer(todoReducer, [])

  return {
    todos,
    addTodo: (text) => dispatch({ type: 'ADD', payload: { id: Date.now(), text, completed: false } }),
    toggleTodo: (id) => dispatch({ type: 'TOGGLE', payload: { id } }),
    deleteTodo: (id) => dispatch({ type: 'DELETE', payload: { id } })
  }
}

26.3.3 本地持久化:useLocalStorage 自定义 Hook

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch {
      return initialValue
    }
  })

  const setValue = (value) => {
    try {
      setStoredValue(value)
      localStorage.setItem(key, JSON.stringify(value))
    } catch (error) {
      console.error('localStorage 写入失败:', error)
    }
  }

  return [storedValue, setValue]
}

26.4 样式实现

26.4.1 Tailwind CSS 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <div className={`flex items-center gap-3 p-3 rounded-lg ${todo.completed ? 'bg-gray-100' : 'bg-white'}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        className="w-5 h-5 rounded text-blue-500"
      />

      <span className={`flex-1 ${todo.completed ? 'line-through text-gray-400' : 'text-gray-800'}`}>
        {todo.text}
      </span>

      <button
        onClick={() => onDelete(todo.id)}
        className="px-3 py-1 text-sm text-red-500 hover:bg-red-50 rounded"
      >
        删除
      </button>
    </div>
  )
}

26.4.2 响应式设计

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function TodoApp() {
  return (
    <div className="max-w-md mx-auto p-4">
      <h1 className="text-2xl font-bold text-center mb-6">Todo App</h1>

      {/* 移动端和桌面端自适应 */}
      <div className="block sm:hidden">
        <p>移动端布局</p>
      </div>

      <div className="hidden sm:block">
        <p>桌面端布局</p>
      </div>
    </div>
  )
}

26.5 功能迭代

26.5.1 筛选功能:全部 / 已完成 / 未完成 / 分类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function TodoFilter({ filter, onFilterChange, categories }) {
  return (
    <div className="flex gap-2 mb-4">
      {['all', 'active', 'completed'].map(f => (
        <button
          key={f}
          onClick={() => onFilterChange(f)}
          className={`px-3 py-1 rounded ${filter === f ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
        >
          {f === 'all' ? '全部' : f === 'active' ? '未完成' : '已完成'}
        </button>
      ))}
    </div>
  )
}

function useFilteredTodos(todos, filter) {
  return useMemo(() => {
    switch (filter) {
      case 'active':
        return todos.filter(t => !t.completed)
      case 'completed':
        return todos.filter(t => t.completed)
      default:
        return todos
    }
  }, [todos, filter])
}

26.5.2 编辑功能:双击编辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function TodoItem({ todo, onUpdate, onDelete }) {
  const [isEditing, setIsEditing] = useState(false)
  const [editText, setEditText] = useState(todo.text)

  function handleSave() {
    if (editText.trim()) {
      onUpdate(todo.id, { text: editText.trim() })
    }
    setIsEditing(false)
  }

  return (
    <div onDoubleClick={() => !todo.completed && setIsEditing(true)}>
      {isEditing ? (
        <input
          value={editText}
          onChange={e => setEditText(e.target.value)}
          onBlur={handleSave}
          onKeyDown={e => { if (e.key === 'Enter') handleSave() }}
          className="border border-blue-500 px-2 py-1 rounded"
        />
      ) : (
        <span>{todo.text}</span>
      )}
    </div>
  )
}

26.5.3 批量操作:全选、批量删除

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function TodoList({ todos, onToggle, onDelete }) {
  const allCompleted = todos.length > 0 && todos.every(t => t.completed)

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={allCompleted}
          onChange={() => {
            if (!allCompleted) {
              todos.forEach(t => !t.completed && onToggle(t.id))
            } else {
              todos.forEach(t => t.completed && onToggle(t.id))
            }
          }}
        />
        全选
      </label>

      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={onToggle} onDelete={onDelete} />
      ))}
    </div>
  )
}

26.5.4 拖拽排序

拖拽排序推荐使用 @dnd-kit,它比 react-beautiful-dnd 更轻量、兼容性更好,且支持更多场景。

1
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import { useState } from 'react'
import {
  DndContext,           // DnD 上下文,包裹整个列表
  closestCenter         // 拖拽对齐算法:找到最近的中心点
} from '@dnd-kit/core'
import {
  SortableContext,              // 包裹可排序列表
  verticalListSortingStrategy, // 列表纵向排序策略
  useSortable                  // 让一个元素变成可拖拽的
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

// --------------------------------------------------
// 单个可拖拽的任务项
// --------------------------------------------------
function SortableItem({ todo, onDelete }) {
  // useSortable:让这个 div 变为可拖拽元素
  // id 必须唯一,dnd-kit 用它来追踪是哪个元素被拖拽
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
    id: todo.id
  })

  // transform:拖拽过程中的位移(CSS transform 值),需要用 CSS.Transform.toString() 转换
  // transition:松手后的动画过渡
  const style = {
    transform: CSS.Transform.toString(transform),
    transition
  }

  return (
    // setNodeRef:注册 DOM 引用,dnd-kit 需要它来获取元素位置
    // {...attributes} 和 {...listeners}:包含拖拽所需的事件(onPointerDown 等),要展开到元素上
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      {todo.text}
      <button onClick={() => onDelete(todo.id)}>删除</button>
    </div>
  )
}

// --------------------------------------------------
// 拖拽列表容器(父组件)
// --------------------------------------------------
function TodoList({ todos, onDelete, onReorder }) {
  const [activeId, setActiveId] = useState(null)

  function handleDragEnd(event) {
    // event.active:被拖拽的元素
    // event.over:当前鼠标位置下的目标元素
    const { active, over } = event

    if (active.id !== over?.id) {
      // 找到了新位置,调用 onReorder 更新顺序
      const oldIndex = todos.findIndex(t => t.id === active.id)
      const newIndex = todos.findIndex(t => t.id === over.id)
      onReorder(oldIndex, newIndex)
    }
    setActiveId(null)
  }

  return (
    // DndContext:必须包裹整个可拖拽列表
    // onDragEnd:拖拽结束时触发,在此计算新顺序
    <DndContext
      collisionDetection={closestCenter}
      onDragStart={({ active }) => setActiveId(active.id)}
      onDragEnd={handleDragEnd}
    >
      {/* SortableContext:管理列表中所有可排序项
          items:所有项的 id 数组,dnd-kit 据此知道有多少项需要管理
          strategy:指定排序策略(纵向列表用 verticalListSortingStrategy) */}
      <SortableContext
        items={todos.map(t => t.id)}
        strategy={verticalListSortingStrategy}
      >
        {todos.map(todo => (
          <SortableItem key={todo.id} todo={todo} onDelete={onDelete} />
        ))}
      </SortableContext>
    </DndContext>
  )
}

26.6 部署上线

26.6.1 Vercel / Netlify 免费部署

Vercel 部署(推荐):

1
2
3
4
5
6
# 1. GitHub 上创建仓库
# 2. 登录 vercel.com
# 3. 点击 "New Project"
# 4. 选择 GitHub 仓库
# 5. 点击 "Deploy"
# 自动构建并部署!

Netlify 部署:

1
2
3
4
5
6
# 1. GitHub 上创建仓库
# 2. 登录 netlify.com
# 3. 点击 "Add new site" -> "Import from Git"
# 4. 选择 GitHub 仓库
# 5. 构建命令留空或填 npm run build
# 6. 发布目录填 dist

26.6.2 自动化部署配置

在 GitHub 仓库设置中配置:

  • Branch: main
  • Build Command: npm run build
  • Output Directory: dist

每次 push 到 main 分支,自动触发构建和部署!


本章小结

本章我们完整实现了一个 Todo App 增强版项目:

  • 功能完整:增删改查、筛选、分类、持久化、批量操作
  • 组件设计:清晰的组件拆分,从根组件到原子组件
  • 状态管理:useReducer 方案作为演示,Zustand + persist 是更优方案
  • 样式:Tailwind CSS 实现响应式设计
  • 部署:Vercel/Netlify 免费一键部署

这是一个很好的入门实战项目!下一章我们将实现一个更复杂的 社交类应用!💬