Skip to content

Lesson 06:useReducer 重构 + 性能优化 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 1(基础篇)
  • 推荐时长:60~90 分钟(首次学习)
  • 先修要求:完成 Lesson 05,保留 storage.ts、编辑功能与组件接口
  • 学习产出:用纯 reducer 管理任务,运行测试并理解记忆化边界
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:用 useReducer 统一管理 Todo 状态逻辑,学习 React.memo / useMemo / useCallback 性能优化。

📦 本节产出:Phase 1 Todo App 完成版——逻辑清晰、性能优化、代码规范。

一、为什么需要 useReducer? ​

App.tsx 中的状态逻辑越来越分散:addTodo、toggleTodo、deleteTodo、editTodo、clearCompleted…… 每个都单独写一个函数操作同一个 todos state。

useReducer 把"做什么"和"怎么做"分离开:组件只负责 dispatch(做什么),reducer 负责具体逻辑(怎么做)。


二、useReducer 语法 ​

tsx
const [state, dispatch] = useReducer(reducer, initialState)
//     ↑        ↑                      ↑          ↑
//   当前状态  发送动作               处理函数     初始值

三、重构 Todo App ​

3.1 定义 Action 类型和 Reducer ​

tsx
// src/todoReducer.ts
import type { Todo } from './types'

// 所有可能的操作(用联合类型精确定义)
type TodoAction =
  | { type: 'ADD';    text: string }
  | { type: 'TOGGLE'; id: number }
  | { type: 'DELETE'; id: number }
  | { type: 'EDIT';   id: number; text: string }
  | { type: 'CLEAR_COMPLETED' }

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: Math.max(0, ...state.map(todo => todo.id)) + 1, text: action.text, completed: false }]

    case 'TOGGLE':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )

    case 'DELETE':
      return state.filter(todo => todo.id !== action.id)

    case 'EDIT':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, text: action.text } : todo
      )

    case 'CLEAR_COMPLETED':
      return state.filter(todo => !todo.completed)

    default:
      return state
  }
}

export { todoReducer }
export type { TodoAction }

3.2 重构 App.tsx ​

tsx
// src/App.tsx
import { useReducer, useState, useEffect } from 'react'
import type { Filter } from './types'
import { loadTodos } from './storage'
import { todoReducer } from './todoReducer'
import Header from './components/Header'
import TodoInput from './components/TodoInput'
import TodoFilter from './components/TodoFilter'
import TodoList from './components/TodoList'

function App() {
  // ✅ useReducer 替代 useState + 多个处理函数
  const [todos, dispatch] = useReducer(todoReducer, [], loadTodos)
  const [filter, setFilter] = useState<Filter>('all')
  const [storageError, setStorageError] = useState('')

  // 持久化:沿用 Lesson 05 对外部写入结果反馈的局部 lint 例外
  /* eslint-disable react-hooks/set-state-in-effect -- 反馈外部存储写入结果,允许一次额外渲染 */
  useEffect(() => {
    try {
      localStorage.setItem('todos', JSON.stringify(todos))
      setStorageError('')
    } catch {
      setStorageError('本次修改未能保存,刷新页面可能丢失。')
    }
  }, [todos])
  /* eslint-enable react-hooks/set-state-in-effect */

  // 派生数据
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed
    if (filter === 'completed') return todo.completed
    return true
  })
  const completedCount = todos.filter(t => t.completed).length
  const activeCount = todos.length - completedCount

  return (
    <div className="min-h-screen bg-linear-to-br from-indigo-50 via-white to-cyan-50">
      <div className="max-w-xl mx-auto px-4 py-12">
        <Header total={todos.length} completed={completedCount} />
        {storageError && <p role="alert">{storageError}</p>}

        {/* 注意:现在传 dispatch,不再传单独的函数 */}
        <TodoInput onAdd={(text) => dispatch({ type: 'ADD', text })} />

        <div className="flex items-center justify-between mb-4">
          <TodoFilter current={filter} onChange={setFilter} />
          {completedCount > 0 && (
            <button
              onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}
              className="text-sm text-gray-400 hover:text-red-500 transition-colors"
            >
              清除已完成 ({completedCount})
            </button>
          )}
        </div>

        <TodoList
          todos={filteredTodos}
          onToggle={(id) => dispatch({ type: 'TOGGLE', id })}
          onDelete={(id) => dispatch({ type: 'DELETE', id })}
          onEdit={(id, text) => dispatch({ type: 'EDIT', id, text })}
        />

        <p className="mt-6 text-center text-sm text-gray-400">
          {activeCount} 个任务未完成
        </p>
      </div>
    </div>
  )
}

export default App

3.3 useState vs useReducer 对比 ​

useStateuseReducer
适合简单、独立的状态复杂、关联的状态逻辑
更新方式setState(newValue)dispatch({ type, payload })
逻辑位置分散在各个事件处理中集中在 reducer 函数中
可测试性需要渲染组件测试reducer 是纯函数,直接测试
TypeScript类型推断足够联合类型提供精确 action 约束

四、性能优化 ​

4.1 什么时候需要优化? ​

IMPORTANT

先用 Profiler 或实际交互确定瓶颈,再决定是否记忆化。本课为了说明机制演示这些 API,并不表示这个小型 Todo 应用已经需要它们。

4.2 React.memo — 跳过不必要的重新渲染 ​

tsx
import { memo } from 'react'

// 没有 memo:App 重新渲染时,TodoItem 全部重新渲染(即使 props 没变)
// 有 memo:Props 不变时通常可以跳过父组件导致的渲染;自身 state 或订阅的 Context 更新仍会渲染

const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete, onEdit }: TodoItemProps) {
  console.log(`TodoItem ${todo.id} rendered`)  // 观察渲染次数
  // ...组件代码
})

4.3 useCallback — 稳定回调函数引用 ​

memo 有个陷阱:每次 App 渲染都会创建新的函数引用,导致 memo 失效!

tsx
// ❌ 每次 App 渲染,onToggle 都是新函数 → memo 对比 props 发现变了 → 白费
<TodoItem onToggle={(id) => dispatch({ type: 'TOGGLE', id })} />

// ✅ useCallback 缓存函数引用
import { useCallback } from 'react'

const handleToggle = useCallback((id: number) => {
  dispatch({ type: 'TOGGLE', id })
}, [dispatch])   // dispatch 是稳定的,不会变

const handleDelete = useCallback((id: number) => {
  dispatch({ type: 'DELETE', id })
}, [dispatch])

const handleEdit = useCallback((id: number, text: string) => {
  dispatch({ type: 'EDIT', id, text })
}, [dispatch])

// 替换 App 中的 TodoList;TodoList 再原样传给各个 TodoItem
<TodoList todos={filteredTodos} onToggle={handleToggle} onDelete={handleDelete} onEdit={handleEdit} />

4.4 useMemo — 缓存昂贵计算 ​

tsx
import { useMemo } from 'react'

// 如果 todos 很多(如 10000 条),每次渲染都过滤一遍很昂贵
const filteredTodos = useMemo(() => {
  return todos.filter(todo => {
    if (filter === 'active') return !todo.completed
    if (filter === 'completed') return todo.completed
    return true
  })
}, [todos, filter])   // 只在 todos 或 filter 变化时重新计算

4.5 三者关系 ​


五、🧪 初尝测试:Reducer 是最好的起点 ​

还记得 todoReducer 是一个纯函数吗?输入确定 → 输出确定,没有副作用。 这里可以直接传入 state 和 action,检查返回结果,不必启动浏览器。Phase 3 会系统介绍 Vitest,本节先为已有实现补上测试:

bash
npm install -D vitest@4
ts
// src/todoReducer.test.ts
import { describe, it, expect } from 'vitest'
import { todoReducer } from './todoReducer'

describe('todoReducer', () => {
  it('ADD 应该新增一个未完成任务', () => {
    const result = todoReducer([], { type: 'ADD', text: '学 React' })
    
    expect(result).toHaveLength(1)
    expect(result[0].text).toBe('学 React')
    expect(result[0].completed).toBe(false)
  })

  it('TOGGLE 应该切换指定任务的完成状态', () => {
    const initial = [{ id: 1, text: '测试', completed: false }]
    const result = todoReducer(initial, { type: 'TOGGLE', id: 1 })
    
    expect(result[0].completed).toBe(true)
  })

  it('DELETE 应该移除指定任务', () => {
    const initial = [
      { id: 1, text: 'A', completed: false },
      { id: 2, text: 'B', completed: false },
    ]
    const result = todoReducer(initial, { type: 'DELETE', id: 1 })
    
    expect(result).toHaveLength(1)
    expect(result[0].id).toBe(2)
  })

  it('CLEAR_COMPLETED 应该移除所有已完成任务', () => {
    const initial = [
      { id: 1, text: 'A', completed: true },
      { id: 2, text: 'B', completed: false },
    ]
    const result = todoReducer(initial, { type: 'CLEAR_COMPLETED' })
    
    expect(result).toHaveLength(1)
    expect(result[0].completed).toBe(false)
  })
})

在 package.json 现有的 scripts 对象中加入 "test": "vitest",保留 dev、build 和 lint。也可以运行:

bash
npm pkg set scripts.test="vitest"
bash
npm test

这 4 个测试应当通过。Reducer 集中逻辑后便于独立测试;useState 的复杂计算同样可以抽成纯函数,不能据此断言 useReducer 总是更好。一次性验证可运行 npm test -- --run。

TIP

Phase 3 的 Lesson 25 会系统教 Vitest + Testing Library。但在这里先让你体验一下:测试不是"做完之后的流水作业",而是开发过程中的质量保障。 很多优秀的团队采用 TDD(测试驱动开发),先写测试再写实现。


六、🧠 深度专题:React Compiler ​

6.1 手动优化的痛点 ​

tsx
// 开发者需要手动决定:哪里加 memo?哪里加 useCallback?依赖数组写对了吗?
// 这是心智负担,也容易出错。

6.2 React Compiler(原名 React Forget) ​

React Compiler 1.0 已于 2025 年 10 月稳定发布。它在构建时分析组件和 Hook,为可安全优化的值与 UI 添加记忆化,减少手动使用这些 API 的需求。参见 React Compiler 1.0 公告。

  • 安装 React 19 不会自动启用 Compiler,还需要构建工具集成。
  • Compiler 不会消除网络延迟、长列表 DOM 或所有昂贵计算;仍需测量实际效果。
  • 本课程的 Vite 模板不启用 Compiler,便于观察手动记忆化;Lesson 30 再演示配置。

理解 memo、useCallback 和 useMemo 的引用与依赖关系,仍然有助于阅读已有代码和排查性能问题。


七、React 19 新增:useActionState ​

React 19 引入了 useActionState,适用于表单操作:

tsx
import { useActionState } from 'react'

function TodoForm({ onAdd }: { onAdd: (text: string) => void }) {
  const [error, submitAction, isPending] = useActionState(
    async (_prevState: string | null, formData: FormData) => {
      const text = String(formData.get('todo') ?? '').trim()
      if (text.length < 2) return '任务至少 2 个字符'
      onAdd(text)  // 本地添加;服务端操作在 Phase 3 中实现
      return null  // 无错误
    },
    null  // 初始错误状态
  )

  return (
    <form action={submitAction}>
      <input name="todo" aria-label="新任务" />
      <button disabled={isPending}>
        {isPending ? '提交中...' : '添加'}
      </button>
      {error && <p className="text-red-500">{error}</p>}
    </form>
  )
}

这是替换 TodoInput 的可选示例,需要传入 onAdd。isPending 在实际异步提交时更有用;Phase 3 再结合 Server Actions 使用。


八、Phase 1 完成!🎉 ​

Phase 1 全面回顾 ​

你掌握了关键概念
项目搭建Vite + React 19 + TypeScript + Tailwind v4
组件设计函数组件、Props、children、组合模式
状态管理useState、useReducer、派生数据
副作用useEffect、依赖数组、清理函数
DOM 操作useRef
性能优化memo、useMemo、useCallback
核心原理Virtual DOM、Reconciliation、Fiber、闭包陷阱
React 18/19React 18 引入的自动批处理,以及 React 19 的 use()、useActionState

九、练习 ​

  1. 测试 reducer:单独测试 todoReducer,验证每个 action 的行为
  2. 添加 undo:保存操作历史,实现撤销功能(提示:用 state 记录之前的 todos 快照)
  3. DevTools Profiler:安装 React DevTools,用 Profiler 观察 memo 前后的渲染差异

十、📌 本节小结 ​

你做了什么你学到了什么
用 useReducer 重构 Todo 状态逻辑把复杂状态更新集中到 reducer,降低维护成本
为组件添加 React.memo / useMemo / useCallback先定位瓶颈,再做有证据的性能优化
了解 React 19 的 useActionState表单场景下把提交状态和错误状态集中管理
完成 Phase 1 收官具备进入多页面工程(Phase 2)的能力

进入下一课前检查清单:

  • [ ] 能解释 useState 与 useReducer 的取舍边界
  • [ ] 能写出纯函数 reducer 并为 action 做类型约束
  • [ ] 知道何时应该/不应该使用 memo 与 useCallback

项目驱动 · 边写边学 · React 19