一个 Todo 的自我修养:TypeScript + 自定义 Hook 实战

0 阅读9分钟

用 TypeScript 写一个 Todo 应用:自定义 Hook 实战

上一篇我们讲了 useContext 和自定义 Hook 的封装思路。这次上点实战强度:用 TypeScript 从零写一个 Todo 应用,把状态、类型和业务逻辑组织在一起。

这篇文章不会把所有代码都堆在一个 App.tsx 里,而是把 Todo 的核心行为封装进 useTodos,让组件主要负责渲染和用户交互。

你会看到:

  • 如何用 interface 和联合类型描述 Todo 数据
  • 如何为自定义 Hook 设计清晰的输入和输出
  • 添加、切换、删除、清除已完成任务时,如何进行不可变更新
  • 为什么 Hook 能复用逻辑,但不会自动共享状态
  • 如何把一个 Hook 接入真正可运行的 Todo 页面

一、先设计类型:Todo 到底有哪些数据?

写功能之前,先把数据结构想清楚。一个最小 Todo 至少需要三个字段:

// types/todo.ts

export interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

export type FilterType = "all" | "active" | "completed";

Todo 是对象结构,所以使用 interface 很自然;FilterType 是一组有限的字符串选项,使用字符串联合类型更合适。

let filter: FilterType = "active"; // 正确
filter = "deleted";                 // TypeScript 报错

interface 和 type 怎么选?

不要把它们理解成绝对规则:两者都能描述对象,也都能参与类型组合。日常开发中可以这样记:

类型更常见的用途
interface描述对象形状、组件 props、可扩展的实体结构
type联合类型、交叉类型、元组、函数类型和类型别名

例如筛选按钮的文案可以借助 Record 获得完整约束:

const filterLabels: Record<FilterType, string> = {
  all: "全部",
  active: "未完成",
  completed: "已完成",
};

如果以后给 FilterType 新增一个值,filterLabels 没有同步补充时,TypeScript 会提醒你。这就是类型系统和业务规则之间的连接。

二、先确定 Hook 的职责边界

useTodos 不应该负责按钮长什么样,也不应该直接操作 DOM。按照当前这份源码,它主要负责三类事情:

  1. 保存 Todo 列表和筛选状态
  2. 提供添加、切换、删除和清除任务的方法
  3. 返回组件渲染所需的数据和操作函数

组件只需要拿到这些数据和方法:

const {
  todos,
  addTodo,
  toggleTodo,
  deleteTodo,
  clearCompleted,
} = useTodos();

这个返回对象就是 Hook 的公开接口。以后把数据从内存改成 localStorage 或服务端 API,组件依然可以保持相同的调用方式。

image.png

三、封装 useTodos:Todo 应用的核心逻辑

这一节直接对应你的 useTodos.ts 源码。我们按“状态 → 操作 → 返回值”分开阅读,所有代码片段都属于同一个 Hook,不需要一次面对一大段完整实现。

1. 先搭出状态骨架

// hooks/useTodos.ts
import { useState } from "react";
import type { FilterType, Todo } from "../types/todo";

export function useTodos() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<FilterType>("all");

  // 业务方法写在这里
}

这里有两份状态:todos 是任务列表,filter 是筛选条件。当前源码已经预留了 filter,但还没有实现筛选按钮和 setFilter,后面会把它作为扩展说明。

2. 添加任务

先实现添加任务。输入先 trim,再判断是否为空:

const addTodo = (text: string) => {
  const normalizedText = text.trim();

  if (!normalizedText) return false;

  const newTodo: Todo = {
    id: Date.now().toString(),
    text: normalizedText,
    completed: false,
  };

  setTodos((previousTodos) => [...previousTodos, newTodo]);
  return true;
};

空白内容直接返回,不创建无意义的 Todo。这里返回 falsetrue,组件可以据此决定是否清空输入框。

3. 切换完成状态

const toggleTodo = (id: string) => {
  setTodos((previousTodos) =>
    previousTodos.map((todo) =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
      )
  );
};

map 会返回新数组,只有匹配到的 Todo 通过展开运算符创建新对象,其他项继续复用原对象。

4. 删除和清除

const deleteTodo = (id: string) => {
  setTodos((previousTodos) =>
    previousTodos.filter((todo) => todo.id !== id)
  );
};

const clearCompleted = () => {
  setTodos((previousTodos) =>
    previousTodos.filter((todo) => !todo.completed)
  );
};

两个操作都使用 filter,区别只是保留条件不同:一个保留 ID 不匹配的项,一个保留未完成的项。

5. 暴露 Hook 接口

最后把状态和操作方法返回给组件:

return {
  todos,
  filter,
  addTodo,
  toggleTodo,
  deleteTodo,
  clearCompleted,
};

至此,前面的代码片段合在一起就是当前版本的 useTodos。代码短了,但每个片段的职责更明确。

还有两个细节值得记住:

  • 当新状态依赖旧状态时,优先使用 setTodos(previous => next),避免依赖过期的渲染快照。
  • 当前源码中的 filter 只是功能预留;没有 setFilter 和筛选后的列表时,组件无法真正切换筛选条件。

当前项目使用 Date.now().toString() 生成 ID,作为学习项目足够直观;如果未来存在短时间批量创建任务、离线同步或服务端写入等场景,再统一替换为 crypto.randomUUID() 或后端生成的 ID。

四、理解不可变更新:不要直接改 state

React state 应该被当作不可变数据处理。下面的写法不要使用:

// ❌ 直接修改原对象和原数组
todos[0].completed = true;
setTodos(todos);

问题不只是“页面可能不更新”。React 以及 memo、依赖比较等机制都依赖引用变化来判断数据是否改变。直接修改原对象会破坏这个约定,也可能让旧的渲染快照被悄悄改变。

正确写法是:只复制发生变化的那一层。

// ✅ 生成新数组,并为变化的 Todo 生成新对象
setTodos((previousTodos) =>
  previousTodos.map((todo) =>
    todo.id === id
      ? { ...todo, completed: !todo.completed }
      : todo
  )
);

四种常见操作可以这样记:

操作不可变模式示例
添加展开旧数组,再追加新元素[...previousTodos, newTodo]
更新map 创建新数组,展开目标对象todo.id === id ? { ...todo, completed: true } : todo
删除filter 创建新数组previousTodos.filter((todo) => todo.id !== id)
批量删除filter 保留需要留下的元素previousTodos.filter((todo) => !todo.completed)

“不可变”不等于深拷贝所有数据,而是:不修改当前 state 引用,返回新的状态引用。

五、把 Hook 接入 Todo 页面

页面组件只需要接收 Hook 返回的数据和方法,负责组织输入框、列表和按钮:

import { useTodos } from "./hooks/useTodos";

export default function TodoApp() {
  const { todos, addTodo, toggleTodo, deleteTodo, clearCompleted } = useTodos();

  return (
    <main>
      <TodoInput onAdd={addTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
      <button type="button" onClick={clearCompleted}>清除已完成</button>
    </main>
  );
}

这段代码体现了一个很重要的分工:

  • TodoInput 保存输入框文本,因为它只属于当前表单 UI
  • useTodos 保存 Todo 数据和业务操作
  • TodoInput 负责表单提交,TodoList 负责遍历任务
  • 点击事件通过 toggleTododeleteTodo 把用户操作交给 Hook

组件并不是完全没有逻辑,而是只保留展示和交互所需的逻辑;数据如何更新,则集中在 Hook 里。当前版本还没有接入筛选 UI,因此列表直接使用 todos

六、为什么自定义 Hook 能复用逻辑?

普通函数只能复用普通计算:

function formatTodoCount(count: number) {
  return `剩余 ${count} 项`;
}

自定义 Hook 可以复用完整的 React 响应式逻辑:

function useTodos() {
  const [todos, setTodos] = useState<Todo[]>([]);

  // state、事件处理、推导数据和 effect 都可以集中在这里
  return { todos, addTodo, toggleTodo };
}

如果另一个页面也调用 useTodos(),它会复用同一套业务代码,但默认拥有自己独立的 Todo 状态:

const firstList = useTodos();
const secondList = useTodos();

这两个调用不会自动共享数据。想让多个组件共享同一份 Todo 列表,需要把 Hook 放进共同的 Provider,或者把状态提升到共同父组件。

这也是自定义 Hook 和 Context 的区别:

能力自定义 HookContext
复用状态逻辑可以不负责逻辑复用本身
默认是否共享同一份状态不共享,每次调用独立Provider 子树共享 value
适合解决的问题抽取状态、effect 和事件逻辑跨层级传递共享数据

两者组合起来,就能得到“共享状态 + 可复用访问入口”。

七、可以继续怎样扩展?

这个版本只把数据保存在内存里,刷新页面后 Todo 会消失。后续可以逐步增加能力:

1. 增加编辑 Todo

function updateTodo(id: string, text: string) {
  const normalizedText = text.trim();

  if (!normalizedText) return false;

  setTodos((previousTodos) =>
    previousTodos.map((todo) =>
      todo.id === id ? { ...todo, text: normalizedText } : todo
    )
  );

  return true;
}

2. 持久化到 localStorage

这时可以在 Hook 里增加 useEffect,监听 todos 并保存;初始化 state 时从 localStorage 读取。要注意 JSON 解析失败、数据格式不符合 Todo 类型等异常情况,TypeScript 不会替你验证运行时 JSON。

3. 使用 useReducer 管理复杂操作

当新增、编辑、批量操作越来越多时,可以把多个 setter 改成一个 reducer:

type TodoAction =
  | { type: "add"; text: string }
  | { type: "toggle"; id: string }
  | { type: "delete"; id: string }
  | { type: "clearCompleted" };

这样每个业务动作都有清晰的 type 和 payload,适合状态转换复杂的场景。

八、常见误区

误区一:TypeScript 会在运行时校验接口

不会。interface Todo 只在编译阶段帮助编辑器和 TypeScript 检查代码,接口会在编译后消失。

从接口、localStorage 或用户输入拿到的数据,仍然需要运行时校验。类型声明不能替代数据验证。

误区二:修改数组后再 setTodos 就算不可变更新

下面这种写法仍然修改了旧数组:

todos.push(newTodo);
setTodos(todos);

即使调用了 setter,旧引用已经被污染。应该返回新数组:

setTodos((previousTodos) => [...previousTodos, newTodo]);

误区三:把筛选结果也保存成 state

后续实现筛选时,筛选结果可以由 todosfilter 推导出来,不必再用 effect 同步一份:

const visibleTodos = todos.filter(/* ... */);

重复保存派生数据,会增加状态同步的分支和出错机会。当前源码还没有实现这一步,所以只保留了 filter 状态作为扩展入口。

误区四:只要抽成 Hook,就会自动共享状态

不会。Hook 复用的是逻辑,不是某一次调用产生的状态。共享状态仍然需要 Context、状态提升或专门的状态管理方案。

九、面试怎么回答?

可以这样回答:

自定义 Hook 是一个以 use 开头的函数,用来复用包含 React Hooks 的逻辑。它可以封装 state、effect、事件处理和清理逻辑,让组件更专注于 UI。每次调用自定义 Hook 都会创建属于当前组件实例的独立状态;如果多个组件需要共享同一份状态,还需要结合状态提升、Context 或状态管理库。TypeScript 则可以在编译阶段约束 Todo 数据结构、筛选值和组件事件参数,减少类型错误。

十、总结

层次负责什么本项目中的内容
types/描述数据和业务选项TodoFilterTypeTodoAction
hooks/封装状态和业务逻辑useTodos:添加、切换、删除、筛选
components/接收 props 并渲染 UITodoItem
页面组件组织交互和页面结构TodoApp

这篇文章可以浓缩成三句话:

  • TypeScript 先描述数据结构,再让代码围绕类型协作
  • 自定义 Hook 把 state、事件和副作用组织成可复用逻辑
  • React state 按不可变方式更新,派生数据尽量在渲染阶段计算

真正好用的自定义 Hook,不是把代码从组件里挪到另一个文件,而是让 Hook 拥有清晰的职责、稳定的返回接口和可验证的业务行为。