从零起步,用TypeScript写一个Todo App:踩坑与收获分享

从零起步,用TypeScript写一个Todo App:踩坑与收获分享

我们来以一个经典的 Todo 应用为例,从零到一完整拆解 TypeScript 在实际项目中的落地方式:包括基础类型约束、接口定义、泛型工具函数、自定义 Hook 的类型安全实现,以及组件间 Props 的严格契约。 通过这个看似简单的 Todo App,我们将看到 TypeScript 如何在编译时拦截类型不匹配、避免运行时隐蔽 bug,并让代码逻辑更清晰、更易重构。接下来,我们一步步走进这个实战案例,拆解核心模块的设计思路与关键技巧。

先说说为什么选Todo App练手

Todo App简单吧?加任务、切换状态、删掉它,还得持久化数据。但别小看它,里面藏着React Hooks、状态管理、类型约束的精髓。TS是JS的超级,把TS当JS来写,但加了强类型,就能杜绝大部分错误。比如JS里你加法函数add(a, b) = a + b,传个字符串'5'进去,它默默拼接成'105',运行时才炸锅。但TS呢?编译时就给你红线:类型"string"的参数不能赋给类型"number"的参数。爽不爽?

我一开始就试了这个。写个简单加法:

javascript 复制代码
// 这是JS版,弱类型,容易出错
function add(a, b) {
    return a + b; // 二义性,可能是加法也可能是拼接
}
const result = add(10, '5'); // 输出'105',运行时没报错

换TS:

typescript 复制代码
// 强类型可以杜绝90%的错误
function addTs(a: number, b: number): number {
    // :类型约束
    return a + b;
}
const result2 = addTs(10, 5);
// const result3 = addTs(10, '5'); // 类型"string"的参数不能赋给类型"number"的参数。
console.log(result2); // 稳稳的15

看见没?如果不规范写,就会一片红,TS在编辑器里就提醒你了。这不光省调试时间,还让代码更可靠。尤其是团队协作,重构别人的代码时,TS的类型提示像个贴心文档,告诉你这个函数要啥输入、吐啥输出。

基础类型和枚举:TS的入门砖

学TS,先从基本类型玩起。TS借鉴Java(微软出品的),有number、string、boolean、null、undefined。数组得指定类型,比如let arr: number[] = [1, 2, 3]; 要是塞个字符串'4'进去,立马报错:不能将类型"string"分配给类型"number"。

还有元组,像let user: [number, string] = [1, 'Tom']; 固定长度和类型顺序,超实用。

枚举类型(enum)也超赞,笔记里提:枚举类型。用来定义一组常量,比如任务状态:

typescript 复制代码
enum Status {
    Pending, // 0
    Success, // 1
    Failed   // 2
}

let s: Status = Status.Success;
s = Status.Pending; // 只能赋这些值,安全

为什么这样设计?因为JS里你可能用魔法数字0、1、2,几个月后自己都忘了啥意思。枚举自带语义,还能避免手滑赋值错。

any和unknown是救命稻草,ts初学时,any救命,unknown更安全。any是任意类型,啥都能赋,但等于放弃TS优势。比如let aa: any = 1; aa = '11'; aa = {}; 随意,但容易藏bug。unknown呢?接受任何类型,但用前得检查:

typescript 复制代码
let bb: unknown = 1;
bb = 'b'; //  ok
// bb.hello(); // 报错:对象未知类型,不能直接调用方法
if (typeof bb === 'string') {
    console.log(bb.toUpperCase()); // 安全了
}

实际场景:API返回数据不确定时,用unknown先兜底,再窄化类型。别乱用any,不然TS就白学了。

接口(interface)和类型别名(type)是对象约束的核心。接口约定对象具有那些属性和方法;type自定义类型。

比如用户对象:

typescript 复制代码
interface User {
    name: string;
    age: number;
    readonly id: number; // 只读
    hobby?: string; // 可选
}

const u: User = {
    name: '柯基',
    age: 20,
    id: 1,
    hobby: '打游戏'
};

u.name = 'keji'; // ok
// u.id = 2; // 报错:无法为"id"赋值,因为它是只读属性

type类似,但更灵活,能做联合类型:type ID = string | number; let num: ID = '111';

为什么分interface和type?interface更适合扩展类,type适合简单别名。实际用Todo时,我定义了Todo接口:数据状态是应用的核心,TS保护它

typescript 复制代码
export interface Todo {
    id: number;
    title: string;
    completed: boolean;
}

这就锁死了Todo对象的形状,后面组件传参时,不会乱来。

泛型:让代码复用起来

泛型(Generic)是TS的杀手锏。简单说,就是类型的传参,让函数或类适应多种类型。

看storage工具:

typescript 复制代码
// T 类型参数
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) {
    localStorage.setItem(key, JSON.stringify(value));
}

为什么这样?因为localStorage存啥类型不确定,用T让调用者决定。比如存Todo[]:getStorage<Todo[]>('todos', []); TS就知道返回Todo数组,不会混成string。

实际应用:我用它持久化todos。想象场景,后端API返回泛型Response,T是User或Order,复用一个fetch函数,超省事。

小提醒:泛型别滥用,新手容易写成getStorage,那就废了。想想为什么设计成这样:让类型安全贯穿存储层,避免运行时JSON.parse炸锅。

React + TS:组件和Hooks的类型安全

Todo App用React,TS让它更稳。在React组件中定义Props接口,子组件参数接口,父子组件对接,props接口可以确保子组件的正确运行。

先看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>
    );
}

useTodos是自定义Hook,里面用泛型和类型:

typescript 复制代码
import { useState, useEffect } from "react";
import type { Todo } from "../types/todo"; // 不加type会报错
import { getStorage, setStorage } from "../utils/storages";

const STORAGE_KEY = 'todos';

export function useTodos() {
    const [todos, setTodos] = useState<Todo[]>(() => getStorage<Todo[]>(STORAGE_KEY, []));
    useEffect(() => {
        setStorage<Todo[]>(STORAGE_KEY, todos);
    }, [todos]); // 依赖todos,避免无限循环

    const addTodo = (title: string) => {
        const newTodo: Todo = {
            id: +new Date(),
            title,
            completed: false
        };
        const newTodos = [...todos, newTodo];
        setTodos(newTodos);
    };

    const toggleTodo = (id: number) => {
        const newTodos = todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        );
        setTodos(newTodos);
    };

    const removeTodo = (id: number) => {
        const newTodos = todos.filter(todo => todo.id !== id);
        setTodos(newTodos);
    };

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

这里useState指定<Todo[]>,初始化用箭头函数懒加载localStorage。useEffect存数据,依赖[todos]------别忘了这点,我一开始没加,页面刷一下就无限存取,浏览器卡爆。经验:useEffect依赖问题,TS不会直接报,但逻辑错就GG。想想为什么:Hooks强调纯函数,类型帮你约束输入,但副作用得手动管。

组件里,FC是FunctionComponent的缩写,Props接口定义入参。

TodoInput.tsx:

typescript 复制代码
import type { FC } from "react";

interface Props {
    onAdd: (title: string) => void;
}

import { useState } from "react";

const TodoInput: FC<Props> = ({ onAdd }) => {
    const [value, setValue] = useState<string>('');
    const handleAdd = () => {
        if (!value.trim()) return;
        onAdd(value);
        setValue('');
    };
    return (
        <div>
            <input
                value={value}
                onChange={e => setValue(e.target.value)}
            />
            <button onClick={handleAdd}>添加</button>
        </div>
    );
};

export default TodoInput;

useState,明确value是string。onAdd是函数类型,调用时TS检查title是string。

onAdd: (title: string) => void; 函数类型为什么这样写?

这个语法在React + TS项目里超级常见,尤其Props定义回调函数时。咱们拆开看:

TypeScript

typescript 复制代码
onAdd: (title: string) => void;
  • (title: string):这是函数的参数列表,括号里定义入参。title是形参名(可以随便起,但起有语义的名字好读),: string约束它必须是字符串。
  • => void:这是函数的返回类型。=>是箭头函数的类型写法,void表示"这个函数不返回任何有意义的值"(或者说返回undefined)。

为什么用void而不是不写返回类型?

TypeScript官方文档明确:函数类型必须显式指定返回类型,尤其是回调函数。如果不写,TS会推导为隐式any或出错。更重要的是,void有特殊语义------它告诉调用方:你不用关心这个函数的返回值,也别指望它返回东西。这在事件处理、回调里特别合适。

举个对比:

  • 如果写成onAdd: (title: string) => string → 表示必须返回string,比如返回新id。
  • 如果写成onAdd: (title: string) → 合法,但返回类型隐式any,丢失安全。
  • 如果写成onAdd: (title: string) => void → 最清晰:它只是"做点事"(添加todo),不返回值。

在TodoInput组件里:

TypeScript

scss 复制代码
const handleAdd = () => {
    if (!value.trim()) return;
    onAdd(value);  // 这里TS知道value是string,完美匹配
    setValue('');
};

调用时TS全程检查:传错类型(如number)直接红线。void也防止你误用const newId = onAdd(value)这种操作。

小Tips:React里常见回调都用这种写法,比如onClick: () => void、onChange: (e: ChangeEvent) => void。统一用箭头函数语法,更现代也更一致。

TodoList.tsx:

typescript 复制代码
import type { Todo } from "../types/todo";
import TodoItem from "./TodoItem";
import type { FC } from "react";

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

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

export default TodoList;

map里指定todo: Todo,虽然todos是Todo[],但显式写加可读性。

TodoItem.tsx类似,checkbox和span用completed控制样式。

为什么这样设计组件?分层:Input管输入,List渲染列表,Item单个项。Props接口像合同,确保父子通信不出岔子。

小案例:假如加编辑功能,定义onEdit: (id: number, newTitle: string) => void; 在Item加input,TS全程护航,不会传错类型。

关于泛型的使用场景总结

泛型(Generics)是TypeScript中最强大也最常用的特性之一,它本质上是"类型的参数化",让代码既复用又保持类型安全。在你提供的文档和Todo App代码中,泛型主要出现在几个典型场景,我来逐一总结,并结合实际编写代码中最常见的用法扩展一下。

  1. 存储工具的类型安全封装 在storages.ts中:

    TypeScript

    r 复制代码
    export function getStorage<T>(key: string, defaultValue: T): T { ... }
    export function setStorage<T>(key: string, value: T) { ... }

    这里T就是泛型参数。为什么需要它?因为localStorage存取的是字符串,但实际数据可能是Todo[]、User、number等各种类型。用了泛型后,调用方自己决定T是什么:

    TypeScript

    css 复制代码
    getStorage<Todo[]>('todos', []);  // 返回 Todo[]

    如果不用泛型,就得写一堆重载或用any,类型安全直接崩掉。这是最常见的"工具函数泛型化"场景,几乎所有项目都会有类似localStorage、cookie、缓存工具。

  2. React状态管理中的useState初始化 在useTodos.ts里:

    TypeScript

    ini 复制代码
    const [todos, setTodos] = useState<Todo[]>(() => getStorage<Todo[]>(STORAGE_KEY, []));

    useState本身就支持泛型,显式指定<Todo[]>让TS知道初始值和后续set的值必须是Todo数组。这在自定义Hook里特别常见,避免类型推导出错(尤其懒初始化时)。

  3. 实际编写代码中最常见的泛型场景(React项目里Top 5)

    • 自定义Hook复用:像useTodos这种,状态是Todo[],但如果写成useList,就能复用成任何列表Hook(用户列表、商品列表等)。
    • 泛型组件:比如一个通用的<List items: T[] renderItem={(item: T) => JSX}>,T可以是User、Product、Todo,点击事件、排序逻辑都能类型安全。
    • API响应处理:fetchData() 返回Promise,T可能是{ data: User[] }或{ items: Order[] },前端统一处理错误/加载状态。
    • 工具函数:如mapByKey<T, K extends keyof T>(arr: T[], key: K),提取对象数组某字段成新数组,超级常见于数据转换。
    • 表单/表格组件:泛型让<Table columns: Column[] data: T[]>支持任意数据形状,同时列定义能自动提示字段名。

一句话总结:只要你发现一段代码逻辑相同,但处理的"东西"类型不同,就该考虑用泛型。它让代码从"重复写N份"变成"写一份,传不同T"。新手最容易踩的坑是滥用any代替泛型------短期爽,长期维护地狱。

潜在坑和优化:从前后端视角看

前后端结合,TS还能桥接API。假设后端用Node+TS,定义同个Todo接口,fetch时用Promise<Todo[]>,类型一致,少了很多转换bug。

易错点:类型不匹配,比如useState('0')------报错:类型"string"的参数不能赋给类型"number | (() => number)"的参数。随手一提:新手爱忽略初始值类型,TS帮你捉虫。

localStorage用JSON.stringify,对象里有函数会丢,优化用库如localforage支持更多类型。

性能:todos.map每次toggle都全遍历,规模大时用immer优化不可变更新。

从后端视角:TS在服务端更猛,类型orm如TypeORM,数据库 schema 和代码类型同步,少写 boilerplate。

结尾:TS让我爱上代码的干净

写完这个Todo App,我对TypeScript的理解从"听说很强"变成了"真香"。它不只是类型检查工具,更是大型项目里提升代码质量、加速团队协作的利器。强类型带来的编译时反馈,让重构和维护变得可控;泛型、接口、Hooks的类型约束,又让组件复用和状态管理更优雅。

相关推荐
ChinaLzw2 小时前
解决uniapp web-view 跳转到mui开发的h5项目 返回被拦截报错的问题
前端·javascript·uni-app
悟能不能悟2 小时前
Postman Pre-request Script 详细讲解与高级技巧
java·开发语言·前端
henujolly2 小时前
useeffect和uselayouteffect
前端·javascript·react.js
Ulyanov2 小时前
Python射击游戏开发实战:从系统架构到高级编程技巧
开发语言·前端·python·系统架构·tkinter·gui开发
华仔啊2 小时前
这 10 个 Vue3 性能优化技巧很实用,但很多项目都没用上
前端·vue.js
手握风云-2 小时前
JavaEE 进阶第九期:Spring MVC - Web开发的“交通枢纽”(三)
前端·spring·java-ee
怕浪猫2 小时前
React从入门到出门第九章 资源加载新特性Suspense 原生协调原理与实战
javascript·react.js·前端框架
天问一2 小时前
Cesium 处理屏幕空间事件(鼠标点击、移动、滚轮)的示例
前端·javascript
@PHARAOH2 小时前
WHAT - Vercel react-best-practices 系列(五)
前端·react.js·前端框架