从零开始用 TypeScript + React 打造类型安全的 Todo 应用

从零开始用 TypeScript + React 打造类型安全的 Todo 应用

引言:为什么选择 TypeScript + React?

React 作为当下最流行的前端库,以其组件化和声明式开发著称。而 TypeScript 作为 JavaScript 的超集,带来了静态类型检查强大的 IDE 支持。两者结合,堪称黄金搭档。

在纯 JavaScript 的 React 项目中,我们常常遇到:

  • 组件 props 类型不确定,传错属性难以及时发现;
  • 状态更新时,不小心修改了不该改的数据;
  • 调用自定义 Hook 返回的方法时,参数类型模糊不清。

TypeScript 可以完美解决这些问题。它让你在编写代码时就能发现错误,并且提供精准的代码补全和文档提示。今天我们就通过一个经典的 Todo 应用,来体验 TypeScript 在 React 项目中的魅力。


一、项目初始化

首先创建一个 React + TypeScript 项目(使用 Create React App):

bash 复制代码
npx create-react-app todo-ts --template typescript
cd todo-ts

项目结构我们会按照功能模块组织:

css 复制代码
src/
├── components/
│   ├── TodoInput.tsx
│   ├── TodoItem.tsx
│   └── TodoList.tsx
├── hooks/
│   └── useTodos.ts
├── types/
│   └── todo.ts
├── utils/
│   └── storages.ts
└── App.tsx

二、定义核心类型:Todo 接口

数据是整个应用的核心,TypeScript 通过接口(interface) 来约束数据的形状。

src/types/todo.ts

typescript 复制代码
export interface Todo {
    id: number;          // 唯一标识
    title: string;       // 标题
    completed: boolean;  // 是否完成
}

这个接口将被多个组件和 Hook 使用,确保整个应用中 Todo 的数据结构始终一致。


三、封装 localStorage 工具函数(泛型实战)

为了方便存取数据,我们封装两个工具函数,并利用 TypeScript 的泛型让它们支持任意类型。

src/utils/storages.ts

typescript 复制代码
export function getStorage<T>(key: string, defaultValue: T): T {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : defaultValue;
}

export function setStorage<T>(key: string, value: T): void {
    localStorage.setItem(key, JSON.stringify(value));
}

泛型 <T> 的作用:

  • getStorage 的返回值类型与 defaultValue 类型一致,调用时可以明确知道返回的是什么类型。
  • setStoragevalue 参数类型为 T,确保存入的数据类型与取出时的预期相符。

例如,当我们存储 Todo 数组时,可以这样调用:

typescript 复制代码
const todos = getStorage<Todo[]>('todos', []);
setStorage<Todo[]>('todos', todos);

如果传入了错误类型,TypeScript 会立即报错。


四、自定义 Hook:useTodos(核心业务逻辑)

useTodos 负责管理待办事项的状态,并与 localStorage 同步。我们看看 TypeScript 如何让这个 Hook 变得健壮。

src/hooks/useTodos.ts

typescript 复制代码
import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo';
import { getStorage, setStorage } from '../utils/storages';

const STORAGE_KEY = 'todos';

export default function useTodos() {
    // 1. 初始化状态,使用泛型指定状态类型,并懒加载从 localStorage 读取数据
    const [todos, setTodos] = useState<Todo[]>(() => 
        getStorage<Todo[]>(STORAGE_KEY, [])
    );

    // 2. 自动同步到 localStorage
    useEffect(() => {
        setStorage<Todo[]>(STORAGE_KEY, todos);
    }, [todos]);

    // 3. 添加待办
    const addTodo = (title: string) => {
        const newTodo: Todo = {
            id: +new Date(),      // 使用时间戳作为简单 ID
            title,
            completed: false,
        };
        setTodos([...todos, newTodo]);
    };

    // 4. 切换完成状态
    const toggleTodo = (id: number) => {
        const newTodos = todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        );
        setTodos(newTodos);
    };

    // 5. 删除待办
    const removeTodo = (id: number) => {
        const newTodos = todos.filter(todo => todo.id !== id);
        setTodos(newTodos);
    };

    return {
        todos,
        addTodo,
        toggleTodo,
        removeTodo,
    };
}

关键点解析

  • useState<Todo[]> :明确状态类型,后续 setTodos 只能传入 Todo[] 类型数据。
  • 懒初始化函数() => getStorage<Todo[]>(...) 确保 localStorage 读取只在首次渲染时执行。
  • useEffect 依赖 [todos] :每当 todos 变化,自动调用 setStorage 同步到本地。
  • 操作方法参数类型addTodo 接收 stringtoggleTodoremoveTodo 接收 number,杜绝传错参数的可能。
  • 返回对象:TypeScript 会自动推断返回值的类型,在组件中使用时能得到完整的类型提示。

五、编写 React 组件

1. TodoInput:受控输入框

src/components/TodoInput.tsx

typescript 复制代码
import * as React from 'react';

interface Props {
    onAdd: (title: string) => void;   // 回调函数类型
}

const TodoInput: React.FC<Props> = ({ onAdd }) => {
    const [value, setValue] = React.useState<string>('');

    const handleAdd = () => {
        if (!value.trim()) return;      // 忽略空输入
        onAdd(value.trim());
        setValue('');                    // 清空输入框
    };

    return (
        <div>
            <input
                value={value}
                onChange={e => setValue(e.target.value)}
            />
            <button onClick={handleAdd}>添加</button>
        </div>
    );
};

export default TodoInput;

TypeScript 亮点

  • React.FC<Props> 定义了函数组件的 props 类型。
  • onAdd 的类型是 (title: string) => void,确保调用时传入正确的参数。
  • useState<string> 显式声明状态类型(虽然可以推导,但写上更清晰)。

2. TodoItem:单个待办项

src/components/TodoItem.tsx

typescript 复制代码
import type { Todo } from '../types/todo';
import * as React from 'react';

interface Props {
    todo: Todo;
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoItem: React.FC<Props> = ({ todo, onToggle, onRemove }) => {
    return (
        <li>
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                {todo.title}
            </span>
            <button onClick={() => onRemove(todo.id)}>删除</button>
        </li>
    );
};

export default TodoItem;

TypeScript 亮点

  • todo: Todo 明确传入的对象符合 Todo 接口。
  • onToggleonRemove 的参数类型明确为 number,使用 todo.id 时类型匹配。

3. TodoList:渲染列表

src/components/TodoList.tsx

typescript 复制代码
import type { Todo } from '../types/todo';
import TodoItem from './TodoItem';
import * as React from 'react';

interface Props {
    todos: Todo[];
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
    return (
        <ul>
            {todos.map(todo => (
                <TodoItem
                    key={todo.id}
                    todo={todo}
                    onToggle={onToggle}
                    onRemove={onRemove}
                />
            ))}
        </ul>
    );
};

export default TodoList;

TypeScript 亮点

  • todos: Todo[] 明确数组元素类型。
  • map 循环中,todo 自动推导为 Todo 类型。

六、组合应用:App 组件

src/App.tsx

typescript 复制代码
import useTodos from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';

export default function App() {
    const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

    return (
        <div>
            <h1>TodoList</h1>
            <TodoInput onAdd={addTodo} />
            <TodoList
                todos={todos}
                onToggle={toggleTodo}
                onRemove={removeTodo}
            />
        </div>
    );
}

在 App 中,我们从 useTodos 获取状态和方法,然后直接传递给子组件。由于所有类型都已定义,这里传参时完全类型安全,如果 addTodo 需要传入 number 类型,TypeScript 会立刻报错。


七、TypeScript 带来的好处总结

通过这个简单的 Todo 应用,我们可以看到 TypeScript 在 React 项目中的实际价值:

  1. 接口即文档TodoProps 等接口清晰地描述了数据结构,新成员加入项目能快速理解。
  2. 类型安全的状态管理useState<Todo[]> 确保状态始终符合预期,不会意外混入错误数据。
  3. 精确的事件处理onToggle={(id: number) => ...} 让调用方明确知道需要传递什么参数。
  4. 自动补全与重构 :在 VS Code 中,输入 todo. 会立刻弹出 idtitlecompleted 提示;修改接口字段后,所有用到的地方都会报错,重构零风险。
  5. 减少运行时错误:很多低级错误(如传错参数类型、访问不存在的属性)在编译阶段就被捕获。

八、扩展思考

这个 Todo 应用虽然简单,但已经涵盖了 TypeScript + React 的核心实践。在此基础上,你可以继续探索:

  • 更高级的泛型:比如封装通用的请求函数,使用泛型约束返回数据类型。
  • 类型工具PartialPickOmit 等工具类型,用于灵活地处理类型变换。
  • Redux Toolkit + TypeScript:大型状态管理中的类型安全。
  • 类型定义文件(.d.ts):为第三方无类型库编写声明。

结语

TypeScript 并不是一个陌生的新语言,它只是为 JavaScript 添加了一层"安全网"。在 React 项目中使用 TypeScript,初期可能会觉得有些繁琐,但一旦你习惯了类型带来的自信和效率,就很难再回到纯 JavaScript 的开发方式。

希望这篇文章能帮助你迈出 TypeScript + React 的第一步。如果你有任何问题或想法,欢迎在评论区留言交流!


效果图

参考资料TypeScript 官方文档 | React 官方类型定义

相关推荐
gyx_这个杀手不太冷静2 小时前
让 AI 替你写代码:OpenCode 完全配置与高效使用手册
前端·ai编程
龙猫不热2 小时前
从 0 手写 Promise:拆解 Promise 链式调用的实现原理
前端·javascript·面试
Arthur14726122865473 小时前
跨域方案汇总
前端
风象南3 小时前
纯文本模型竟然也能直接“画图”,而且还很好用
前端·人工智能·后端
IT_陈寒3 小时前
Vite vs Webpack:5个让你的开发效率翻倍的实战对比
前端·人工智能·后端
wuhen_n4 小时前
TypeScript 强力护航:PropType 与组件事件类型的声明
前端·javascript·vue.js
wuhen_n5 小时前
组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件
前端·javascript·vue.js
Moment16 小时前
Vibe Coding 时代,到底该选什么样的工具来提升效率❓❓❓
前端·后端·github
IT_陈寒17 小时前
SpringBoot性能飙升200%?这5个隐藏配置你必须知道!
前端·人工智能·后端