作为前端开发者,你是不是也有过这样的经历:用纯 JavaScript 写 React 项目时,一不小心就把number类型的id传成了字符串,或者调用组件时漏传了关键参数,结果代码运行时才报错,排查半天浪费时间?
别慌!今天我们就用 TypeScript + React 手写一个功能完整的 TodoList,从类型定义到组件开发,再到数据持久化,全程强类型护航,让 bug 在编译期就无处遁形。更重要的是,这篇文章会逐行拆解代码,就算你是刚接触 TS 的新手,也能轻松拿捏!
一、先定个小目标:我们要做什么?
本次实战的 TodoList 包含三大核心功能:
- 添加 Todo:输入待办事项标题,点击按钮新增;
- 切换状态:勾选复选框,标记 Todo 完成 / 未完成;
- 删除 Todo:点击删除按钮,移除指定待办;
- 数据持久化:页面刷新后,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 中,每个待办事项都包含id、title、completed三个属性,所以先写一个接口:
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));
}
逐行拆解,新手也能懂:
-
泛型
<T>是什么?泛型就像一个 “类型占位符”,它表示函数可以处理任意类型的数据。比如调用getStorage<Todo[]>时,T就变成了Todo[],函数返回值也必然是Todo[]类型。没有泛型的话,我们只能用any类型,这样就失去了 TS 的类型检查意义。 -
getStorage函数逻辑- 入参
key是存储的键名,defaultValue是默认值(比如空数组); - 先尝试从
localStorage取值,如果取到了就用JSON.parse解析,否则返回默认值; - TS 会保证:返回值的类型和
defaultValue的类型完全一致。
- 入参
-
踩坑提醒
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 };
}
新手重点关注的细节:
useState<Todo[]>的类型注解我们明确告诉 React:todos是一个Todo类型的数组,初始值通过getStorage从本地读取。这样 TS 会自动检查:任何给todos赋值的操作,都必须是Todo[]类型,否则直接报错。- 不可变更新原则React 中状态是只读的,不能直接修改原数组。比如新增 Todo 时,我们用
[...todos, newTodo]创建新数组;切换状态时用map,删除时用filter—— 这些方法都会返回新数组,不会修改原数据。 +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.id、todo.title等属性,TS 会提供完整的代码提示;onToggle和onRemove的参数都是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 会自动推断出:
todos是Todo[]类型;addTodo是(title: string) => void类型;toggleTodo和removeTodo是(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
)
);
优化点对比:
- 不用手动写
useEffect同步localStorage,Zustand 的persist中间件自动搞定; - 状态更新逻辑更清晰,
set函数直接接收当前状态,避免闭包陷阱; - 类型约束依然严格,
TodoState接口明确了所有状态和方法的类型。
五、总结:TypeScript+React 开发的核心心法
通过这个 TodoList 实战,我们不仅掌握了 TS+React 的基础开发流程,还明白了几个核心心法:
- 类型先行:写代码前先定义接口(比如
Todo、Props),让数据结构有章可循; - 分层设计:类型、工具、Hook、组件各司其职,代码更易维护;
- 不可变更新:React 状态是只读的,用扩展运算符、
map、filter等方法创建新数据; - 泛型复用:工具函数用泛型实现通用化,避免重复造轮子。
这个 TodoList 虽然简单,但它包含了前端开发的核心思想。你可以在此基础上扩展功能,比如添加编辑 Todo、筛选已完成 / 未完成、按时间排序等,相信你会对 TypeScript+React 有更深的理解!
六、动手试试吧!
看完文章,不如自己动手敲一遍代码。你可以:
- 创建一个新的 React+TS 项目:
npx create-react-app todo-ts --template typescript; - 按照本文的目录结构创建文件,逐行写入代码;
- 运行项目:
npm start,体验强类型开发的快乐!