从 0 到 1 玩转 TypeScript+React:手写强类型 TodoList,新手也能秒懂

29 阅读11分钟

作为前端开发者,你是不是也有过这样的经历:用纯 JavaScript 写 React 项目时,一不小心就把number类型的id传成了字符串,或者调用组件时漏传了关键参数,结果代码运行时才报错,排查半天浪费时间?

别慌!今天我们就用 TypeScript + React 手写一个功能完整的 TodoList,从类型定义到组件开发,再到数据持久化,全程强类型护航,让 bug 在编译期就无处遁形。更重要的是,这篇文章会逐行拆解代码,就算你是刚接触 TS 的新手,也能轻松拿捏!

一、先定个小目标:我们要做什么?

本次实战的 TodoList 包含三大核心功能:

  1. 添加 Todo:输入待办事项标题,点击按钮新增;
  2. 切换状态:勾选复选框,标记 Todo 完成 / 未完成;
  3. 删除 Todo:点击删除按钮,移除指定待办;
  4. 数据持久化:页面刷新后,Todo 列表不会丢失(基于localStorage实现)。

技术栈:React 18 + TypeScript 5.x,无需额外依赖(进阶版会引入 Zustand 优化状态管理)。

二、项目结构拆解:清晰分层,新手也不懵

一个好的项目结构能让代码更易维护,我们的 TodoList 采用分层设计,文件结构如下:

plaintext

src/
├── types/          # 类型定义目录
│   └── todo.ts     # Todo数据类型接口
├── utils/          # 工具函数目录
│   └── storages.ts # 本地存储封装函数
├── hooks/          # 自定义Hook目录
│   └── useTodos.ts # Todo状态管理核心Hook
├── components/     # UI组件目录
│   ├── TodoInput.tsx  # 新增Todo输入组件
│   ├── TodoItem.tsx   # 单个Todo项组件
│   └── TodoList.tsx   # Todo列表容器组件
└── App.tsx         # 根组件,组装所有功能

分层的好处很明显:职责单一,复用性高。比如storages.ts的存储函数,后续任何项目都能直接用;useTodos.ts封装了所有状态逻辑,组件只需要专注于 UI 渲染。

三、核心代码逐行精讲:从类型到组件,全程干货

(一)第一步:定义核心类型,打好地基(types/todo.ts

TypeScript 的核心是类型约束,而约束的第一步就是定义数据结构。我们的 TodoList 中,每个待办事项都包含idtitlecompleted三个属性,所以先写一个接口:

ts

// src/types/todo.ts
// 定义Todo数据的结构,这是整个项目的类型基石
export interface Todo {
  id: number;        // 唯一标识,数字类型
  title: string;     // 待办事项标题,字符串类型
  completed: boolean;// 完成状态,布尔类型
}

新手必看解读

  • interface是 TS 中定义对象结构的利器,它就像一份 “数据说明书”,告诉代码:所有 Todo 类型的变量,必须包含这三个属性,且类型不能错;
  • 如果后续你想给 Todo 加个createTime属性,只需要在接口里加一行createTime: string;,所有用到 Todo 的地方都会有 TS 的类型提示,重构超方便;
  • 对比纯 JS:如果用 JS 写,你可能会把id写成字符串,或者漏写completed,只有运行时才会发现问题。

(二)第二步:封装本地存储工具,泛型是关键(utils/storages.ts

为了实现数据持久化,我们需要操作localStorage。但直接写localStorage.getItem/setItem太麻烦,而且容易出现类型错误,所以封装两个通用函数:

ts

// src/utils/storages.ts
// 泛型<T>:让函数支持任意类型的数据存储,实现复用
export function getStorage<T>(key: string, defaultValue: T): T {
  // 从localStorage中获取数据
  const value = localStorage.getItem(key);
  // 如果有值,就解析成对应的类型;没值就返回默认值
  return value ? JSON.parse(value) : defaultValue;
}

export function setStorage<T>(key: string, value: T) {
  // 将数据序列化成字符串,存入localStorage
  localStorage.setItem(key, JSON.stringify(value));
}

逐行拆解,新手也能懂

  1. 泛型<T>是什么?泛型就像一个 “类型占位符”,它表示函数可以处理任意类型的数据。比如调用getStorage<Todo[]>时,T就变成了Todo[],函数返回值也必然是Todo[]类型。没有泛型的话,我们只能用any类型,这样就失去了 TS 的类型检查意义。

  2. getStorage函数逻辑

    • 入参key是存储的键名,defaultValue是默认值(比如空数组);
    • 先尝试从localStorage取值,如果取到了就用JSON.parse解析,否则返回默认值;
    • TS 会保证:返回值的类型和defaultValue的类型完全一致。
  3. 踩坑提醒localStorage只能存储字符串,所以我们需要用JSON.stringify把对象 / 数组转成字符串,读取时再用JSON.parse转回来。但要注意:JSON.parse无法解析函数、undefined等数据,所以存储的内容必须是可序列化的。

(三)第三步:核心状态管理,自定义 Hook 是灵魂(hooks/useTodos.ts

React 中,我们用自定义 Hook封装状态逻辑,让组件和状态解耦。useTodos.ts就是整个 TodoList 的 “大脑”,负责处理所有数据操作:

ts

// src/hooks/useTodos.ts
import { useState, useEffect } from 'react';
// 引入Todo类型,注意用type关键字表示这是类型导入
import type { Todo } from '../types/todo';
import { getStorage, setStorage } from '../utils/storages';

// 定义存储的键名,常量大写是规范,便于后续维护
const STORAGE_KEY = 'todos';

export function useTodos() {
  // 1. 初始化Todo列表状态,强类型约束为Todo数组
  const [todos, setTodos] = useState<Todo[]>(
    // 初始值从localStorage读取,没有就用空数组
    () => getStorage<Todo[]>(STORAGE_KEY, [])
  );

  // 2. 监听todos变化,同步到localStorage
  useEffect(() => {
    setStorage<Todo[]>(STORAGE_KEY, todos);
  }, [todos]); // 依赖项:只有todos变了才执行

  // 3. 新增Todo:入参title是字符串类型,约束明确
  const addTodo = (title: string) => {
    // 创建新Todo,严格遵循Todo接口的结构
    const newTodo: Todo = {
      id: +new Date(), // 时间戳转数字,作为唯一id
      title, // 简写,等价于 title: title
      completed: false // 默认未完成
    };
    // 不可变更新:用扩展运算符创建新数组,不修改原数组
    setTodos([...todos, newTodo]);
  };

  // 4. 切换Todo完成状态:入参id是数字类型
  const toggleTodo = (id: number) => {
    setTodos(
      todos.map(todo => 
        // 找到匹配id的Todo,反转completed状态
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  // 5. 删除Todo:入参id是数字类型
  const removeTodo = (id: number) => {
    // 过滤掉id匹配的Todo,返回新数组
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 6. 返回状态和方法,供组件使用
  return { todos, addTodo, toggleTodo, removeTodo };
}

新手重点关注的细节

  1. useState<Todo[]>的类型注解我们明确告诉 React:todos是一个Todo类型的数组,初始值通过getStorage从本地读取。这样 TS 会自动检查:任何给todos赋值的操作,都必须是Todo[]类型,否则直接报错。
  2. 不可变更新原则React 中状态是只读的,不能直接修改原数组。比如新增 Todo 时,我们用[...todos, newTodo]创建新数组;切换状态时用map,删除时用filter—— 这些方法都会返回新数组,不会修改原数据。
  3. +new Date()是什么操作?new Date()返回的是日期对象,前面加个+号会把它转成时间戳数字,用来做id简单又实用。不过要注意:快速连续点击添加按钮时,可能会生成相同的时间戳,进阶版可以用uuid库生成唯一 id。

(四)第四步:UI 组件开发,TypeScript 约束 Props 传参

组件是 React 应用的 “皮肤”,我们把 UI 拆分成三个小组件:TodoInput(输入框)、TodoItem(单个 Todo 项)、TodoList(列表容器)。核心原则:组件只负责渲染,不处理业务逻辑

1. TodoInput:新增待办的输入组件(components/TodoInput.tsx

tsx

// src/components/TodoInput.tsx
import * as React from 'react';

// 定义组件的Props接口:父组件必须传一个onAdd函数
interface Props {
  onAdd: (title: string) => void;
}

// React.FC<Props>:表示这是一个接收Props类型的函数组件
const TodoInput: React.FC<Props> = ({ onAdd }) => {
  // 输入框的值状态,约束为字符串类型
  const [value, setValue] = React.useState<string>('');

  // 点击添加按钮的处理函数
  const handleAdd = () => {
    // 去除首尾空格,空内容不添加
    if (!value.trim()) return;
    // 调用父组件传过来的onAdd方法,传入输入的标题
    onAdd(value);
    // 清空输入框
    setValue('');
  };

  return (
    <div style={{ margin: '10px 0' }}>
      <input
        type="text"
        value={value}
        // 输入变化时更新状态e.target.value是字符串符合类型约束
        onChange={e => setValue(e.target.value)}
        placeholder="请输入待办事项"
        style={{ padding: '4px' }}
      />
      <button onClick={handleAdd} style={{ marginLeft: '8px' }}>
        添加
      </button>
    </div>
  );
};

export default TodoInput;

Props 接口是组件的 “契约”

  • 我们用interface Props定义了组件接收的参数:一个名为onAdd的函数,这个函数必须接收一个字符串参数,没有返回值;
  • 如果父组件使用TodoInput时,漏传了onAdd,或者传了一个不接收参数的函数,TS 会直接在编译期报错,比运行时 debug 高效 10 倍;
  • React.FC<Props>是 TS 中定义函数组件的标准写法,它会自动推断组件的 Props 类型。

2. TodoItem:单个 Todo 项组件(components/TodoItem.tsx

tsx

// src/components/TodoItem.tsx
import type { Todo } from '../types/todo';
import * as React from 'react';

// 定义组件Props:必须传todo对象、切换函数、删除函数
interface Props {
  todo: Todo;
  onToggle: (id: number) => void;
  onRemove: (id: number) => void;
}

const TodoItem: React.FC<Props> = ({ todo, onToggle, onRemove }) => {
  return (
    <li style={{ margin: '6px 0' }}>
      {/* 复选框:checked状态是todo.completed(布尔类型) */}
      <input
        type="checkbox"
        checked={todo.completed}
        // 点击时调用切换函数传入当前Todo的id
        onChange={() => onToggle(todo.id)}
      />
      {/* 完成的Todo加删除线 */}
      <span 
        style={{ 
          margin: '0 8px',
          textDecoration: todo.completed ? 'line-through' : 'none' 
        }}
      >
        {todo.title}
      </span>
      {/* 删除按钮:点击时调用删除函数,传入当前Todo的id */}
      <button 
        onClick={() => onRemove(todo.id)}
        style={{ color: 'red', border: 'none', background: 'transparent' }}
      >
        删除
      </button>
    </li>
  );
};

export default TodoItem;

小细节,大作用

  • todo参数是Todo类型,所以我们可以放心地访问todo.idtodo.title等属性,TS 会提供完整的代码提示;
  • onToggleonRemove的参数都是number类型,和父组件的方法完全匹配,不会出现类型不兼容的问题。

3. TodoList:Todo 列表容器组件(components/TodoList.tsx

这个组件的作用很简单:接收 Todo 数组,遍历渲染每个TodoItem

tsx

// src/components/TodoList.tsx
import type { Todo } from '../types/todo';
import * as React from 'react';
import TodoItem from './TodoItem';

// 定义Props:接收Todo数组、切换函数、删除函数
interface Props {
  todos: Todo[];
  onToggle: (id: number) => void;
  onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {/* 遍历todos数组,每个项都是Todo类型 */}
      {todos.map((todo: Todo) => (
        <TodoItem
          key={todo.id} // 列表项必须加key提高渲染性能
          todo={todo}   // 传递Todo数据给子组件
          onToggle={onToggle} // 传递切换函数
          onRemove={onRemove} // 传递删除函数
        />
      ))}
    </ul>
  );
};

export default TodoList;

**新手必知:为什么要加key?**React 在渲染列表时,需要通过key来识别每个列表项的唯一性。如果没有key,React 无法判断哪些项是新增、删除或修改的,可能会导致渲染性能下降,甚至出现 UI 和数据不一致的问题。这里用todo.id作为key,因为它是唯一的。

(五)第五步:根组件组装,大功告成(App.tsx

最后,我们在根组件App.tsx中把所有功能组装起来,一个完整的 TodoList 就诞生了!

tsx

// src/App.tsx
import { useTodos } from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';

export default function App() {
  // 从自定义Hook中解构出状态和方法,TS会自动推导类型
  const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

  return (
    <div style={{ width: '400px', margin: '50px auto' }}>
      <h1 style={{ textAlign: 'center' }}>TypeScript TodoList</h1>
      {/* 传入addTodo方法,符合TodoInput的Props约束 */}
      <TodoInput onAdd={addTodo} />
      {/* 传入Todo数组和操作方法,符合TodoList的Props约束 */}
      <TodoList
        todos={todos}
        onToggle={toggleTodo}
        onRemove={removeTodo}
      />
    </div>
  );
}

点睛之笔:TS 的自动类型推导我们解构useTodos()的返回值时,没有写任何类型注解,但 TS 会自动推断出:

  • todosTodo[]类型;
  • addTodo(title: string) => void类型;
  • toggleTodoremoveTodo(id: number) => void类型。这就是 TypeScript 的魅力 ——少写冗余代码,多享类型安全

四、进阶优化:用 Zustand 简化状态管理

上面的代码已经很完美了,但如果 TodoList 的功能更复杂(比如添加筛选、编辑功能),useState的状态管理会变得繁琐。这时我们可以用之前聊过的Zustand来优化,代码会更简洁,而且依然保持强类型!

改造后的useTodos.ts(Zustand 版本)

ts

// src/hooks/useTodos.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Todo } from '../types/todo';

// 定义状态和方法的接口
interface TodoState {
  todos: Todo[];
  addTodo: (title: string) => void;
  toggleTodo: (id: number) => void;
  removeTodo: (id: number) => void;
}

// 创建Zustand store,自带持久化功能
export const useTodos = create<TodoState>()(
  persist(
    (set) => ({
      todos: [],
      // 新增Todo:基于当前状态创建新数组
      addTodo: (title) => set((state) => ({
        todos: [...state.todos, { id: Date.now() + Math.random() * 1000, title, completed: false }]
      })),
      // 切换状态:map遍历生成新数组
      toggleTodo: (id) => set((state) => ({
        todos: state.todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
      })),
      // 删除Todo:filter过滤生成新数组
      removeTodo: (id) => set((state) => ({
        todos: state.todos.filter(t => t.id !== id)
      }))
    }),
    { name: 'todos' } // 持久化的键名,自动存到localStorage
  )
);

优化点对比

  1. 不用手动写useEffect同步localStorage,Zustand 的persist中间件自动搞定;
  2. 状态更新逻辑更清晰,set函数直接接收当前状态,避免闭包陷阱;
  3. 类型约束依然严格,TodoState接口明确了所有状态和方法的类型。

五、总结:TypeScript+React 开发的核心心法

通过这个 TodoList 实战,我们不仅掌握了 TS+React 的基础开发流程,还明白了几个核心心法:

  1. 类型先行:写代码前先定义接口(比如TodoProps),让数据结构有章可循;
  2. 分层设计:类型、工具、Hook、组件各司其职,代码更易维护;
  3. 不可变更新:React 状态是只读的,用扩展运算符、mapfilter等方法创建新数据;
  4. 泛型复用:工具函数用泛型实现通用化,避免重复造轮子。

这个 TodoList 虽然简单,但它包含了前端开发的核心思想。你可以在此基础上扩展功能,比如添加编辑 Todo筛选已完成 / 未完成按时间排序等,相信你会对 TypeScript+React 有更深的理解!

六、动手试试吧!

看完文章,不如自己动手敲一遍代码。你可以:

  1. 创建一个新的 React+TS 项目:npx create-react-app todo-ts --template typescript
  2. 按照本文的目录结构创建文件,逐行写入代码;
  3. 运行项目:npm start,体验强类型开发的快乐!