从零起步,用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代码中,泛型主要出现在几个典型场景,我来逐一总结,并结合实际编写代码中最常见的用法扩展一下。
-
存储工具的类型安全封装 在storages.ts中:
TypeScript
rexport function getStorage<T>(key: string, defaultValue: T): T { ... } export function setStorage<T>(key: string, value: T) { ... }这里T就是泛型参数。为什么需要它?因为localStorage存取的是字符串,但实际数据可能是Todo[]、User、number等各种类型。用了泛型后,调用方自己决定T是什么:
TypeScript
cssgetStorage<Todo[]>('todos', []); // 返回 Todo[]如果不用泛型,就得写一堆重载或用any,类型安全直接崩掉。这是最常见的"工具函数泛型化"场景,几乎所有项目都会有类似localStorage、cookie、缓存工具。
-
React状态管理中的useState初始化 在useTodos.ts里:
TypeScript
iniconst [todos, setTodos] = useState<Todo[]>(() => getStorage<Todo[]>(STORAGE_KEY, []));useState本身就支持泛型,显式指定<Todo[]>让TS知道初始值和后续set的值必须是Todo数组。这在自定义Hook里特别常见,避免类型推导出错(尤其懒初始化时)。
-
实际编写代码中最常见的泛型场景(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的类型约束,又让组件复用和状态管理更优雅。