这是一个待办事项清单的小案例,包含了组件拆分、父子通信、祖孙通信以及 TypeScript 类型限制,一圈走下来还是能学到不少东西。我把整个开发过程记录下来了,大家可以跟着一起练。
一、效果预览
我们要实现的待办清单大概长这样:头部是一个搜索框和添加按钮,中间是事项列表,底部显示已完成数量和全选按钮。
界面大概分三块:
- 顶部:输入框 + 添加按钮
- 中间:待办事项列表,鼠标悬停时显示删除按钮
- 底部:显示已完成数量 + 全选按钮
二、组件拆分
拿到一个页面,第一步就是拆组件。组件的拆分一定要有逻辑和意义,不是说随便切几刀就完事了。
拆组件的几个原则:
我们这个案例里面,拆成了四个组件: 
| Header | 专注搜索功能和添加待办事项 |
| List | 展示所有的待办项列表 |
| Item | 单个待办项的渲染,鼠标悬停效果和删除功能 |
| Footer | 数据统计(已完成/总数)和全选功能 |
拆完之后的效果就是这样的,折叠起来看很清晰:
三、静态页面搭建
拆好组件之后,先把静态页面写出来。静态页面是后面所有功能的基础,先把布局和样式调好,再往里填数据和逻辑。 
小提示: 写好的静态页面代码复制到 App.jsx 里面的时候,要注意一个问题——如果静态页面里面有 id="root" 的元素,一定要把它改成别的标识,不然它跟 React 的根节点 id="root" 会冲突。
四、替换成组件
写好静态页面之后,我们开始把页面拆成组件。先根据我们拆好的组件结构,把代码用注释或者占位符标注出来,哪些部分归 Header,哪些归 List,哪些归 Item,哪些归 Footer。
然后,把这些占位部分替换成真正的组件标签。被替换掉的代码,要挨个写到对应的组件文件里面去。 




样式也要跟着拆,不过这个过程比较繁琐,也没有太大的意义就不放图片了。主要就是把全局样式里对应组件的那部分抽离出来,放到组件的样式文件里。
五、数据初始化与传递(父传子)
组件结构搭好了,接下来就要让数据动起来。
5.1 初始化数据
在 App.jsx 里面先初始化待办事项列表,并且用 useState 来管理状态:
const [todos, setTodos] = useState([
{ id: '1', name: '学习 React', done: false },
{ id: '2', name: '写待办清单 Demo', done: true },
{ id: '3', name: '整理笔记', done: false }
]);
但是光初始化没用,这些数据并没有加载到页面上。所以我们需要将app.jsx的数据传给子组件,也就是list,让list加载真实的数据。

5.2 父传子——Props
通过 props 把 App 的数据传给 List 组件:
<List todos={todos} />
在 List 组件里面接收父组件传递的数据:
function List(props) {
const { todos } = props;
// …
}
5.3 遍历渲染
到这一步,页面上渲染的条数已经跟着数据走了,但里面的内容还是空的或者占位符,这是因为我们还要将里面的每一条数据传给item组件,让item组件渲染真实的信息。
{todos.map(todo => (
<Item key={todo.id} todo={todo} />
))}
在 Item 组件里接收 todo 数据,用插值语法渲染到页面上:
function Item(props) {
const { todo } = props;
return (
<li>
<input type="checkbox" defaultChecked={todo.done} />
<span>{todo.name}</span>
</li>
);
}
5.4 小知识点:defaultChecked 和 checked 的区别
小知识点:defaultChecked 和 checked 的区别
- checked 是受控属性。它表示复选框的选中状态完全由 React 状态控制,必须配合onChange事件一起使用才能更新。如果只写checked={true}而不提供 onChange,复选框就会变成只读状态,无法通过点击取消。
- defaultChecked 是非受控属性。它只设置初始选中状态,后续用户的点击交互可以自由改变勾选状态,不受 React 状态控制。
在这个场景中,我们希望在页面刚加载时根据 todo.done 展示勾选状态,但用户仍然可以自由勾选或取消,所以我们先用defaultChecked:(初始阶段)
<input type="checkbox" defaultChecked={todo.done} />
defaultChecked 只会设置初始状态,后续用户的交互可以自由更改,不会受 done 值的限制。
六、添加待办事项(子传父)
添加功能涉及子传父,也就是 Header 组件要把用户输入的数据传给 App 组件。
6.1 父传子:传递函数
子传父的核心思路是:父组件把函数通过 props 传给子组件,子组件调用这个函数的时候把数据传回去。
首先,在 App 组件里面创建一个添加事项的函数:
const addTodo = (todo) => {
setTodos([…todos, todo]);
};
注意这个函数接收的参数 todo 是 Header 组件传过来的,我是在 Header 里面把整个 todo 对象组装好再传回来,而不是在 App 里补全。
然后把 addTodo 通过 props 传给 Header 组件:
<Header addTodo={addTodo} />
一个重要的坑: 千万不要直接把 setTodos 作为函数传给子组件,因为 setTodos 是替换式更新而不是合并式更新。如果子组件直接调用 setTodos(newItem),那么页面上只有新添加的那个值,以前的值全部丢失。虽然可以通过传递闭包函数解决,但这样会增加子组件的逻辑复杂度,不推荐。
6.2 子组件调用父组件传递的函数
在 Header 组件里面,给 input 添加键盘事件,这里可以用keydown也可以用keyUp还可以用keypress,区别仅在于触发函数的时间而已。
- keydown:用户按下键盘上的键
- keyup:用户释放按键
- keypress:用户按下并且释放了按键(已废弃,不推荐)
const handleKeyUp = (e) => {
if (e.key !== 'Enter') return;
const value = e.target.value.trim();
if (!value) return;
const newTodo = {
id: crypto.randomUUID(),
name: value,
done: false
};
props.addTodo(newTodo);
e.target.value = '';
};
小知识点:crypto.randomUUID()
这是现代浏览器和 Node.js 环境内置的 Web Crypto API 方法,用于生成符合 RFC 4122 标准的 UUID v4。它的唯一性极高,基于随机数生成,理论上需要生成数十亿个 UUID 才可能出现重复,非常适合用作列表的 key 或数据库主键。最关键的是,它不需要引入任何第三方库,直接就能用。
需要注意的是: 它只能在 https:// 或 localhost 等安全上下文中使用,普通的 http:// 环境下调用会报错。 在 Node.js 中,如果版本低于 v14.17.0,需要用 const { randomUUID } = require(‘crypto’) 显式引入。
如果你的项目可能运行在非安全环境,建议封装一个降级函数:
function generateId() {
if (crypto.randomUUID) return crypto.randomUUID();
return Date.now() + '-' + Math.random().toString(36).substring(2, 9);
}
七、鼠标悬停显示删除按钮
这里我用的纯 CSS 实现,比较简单。有个小点想跟大家分享一下:CSS 类名的设计。 
.btn {
/* 所有按钮的骨架:大小、圆角、字体、指针样式 */
padding: 4px 12px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
}
.btn-danger {
/* 红色删除按钮的个性特征 */
background: #ff4d4f;
color: #fff;
}
这样写的好处是:把“长得像的按钮”的通用特征(.btn)和“它是红色删除按钮”的个性特征(.btn-danger)拆分开来。
比如你还有“保存按钮”、“取消按钮”、“编辑按钮”,它们的大小、圆角、字体、鼠标指针样式都是一样的,只有颜色不同。如果不拆分,就要为每个按钮写一遍 padding、font-size、border-radius,代码非常冗余。拆分之后就很方便了:
<button className="btn btn-primary">保存</button>
<button className="btn btn-success">确认</button>
<button className="btn btn-danger">删除</button>
八、更新待办事项状态(checked 勾选)
点击复选框的时候,要更新对应事项的 done 状态。这里面涉及到祖孙通信——从 Item → List → App。
图源网络
8.1 理解闭包与事件绑定
先讲一个小知识点,是自己摸索出来的:每个 DOM 节点在挂载完成之后,函数里面的所有变量都会变成“固定”的。换句话说,Item 组件里渲染的每一个 li,其实都对应着一个具体的 todo 对象。一旦挂载到浏览器上,li 所对应的对象就不会变了(除非重新挂载)。
所以 li 里面其实就已经有了 id、name、done 这些值。
在item组件里面,list绑定了多个事件,里面的done和id,其实在现在看来是变量,但是我们实际上可以将它看做为常量。 起初我不理解,为什么我点击checked框,handleCheck就能够获取到id呢?又没有使用document去获取结点,但是理解了上面的话之后,其实就很简单了,因为结点里面一直都有id,name,done这些值以及handleCheck函数,我们点击只不过是触发handleCheck而已,而函数也只不过是使用id的值而已。
OK,理解了这块,相信大家对于闭包会更加的透彻。
<input
type="checkbox"
checked={todo.done}
onChange={() => handleCheck(todo.id)}
/>
这就是闭包的作用——函数“记住”了它被创建时的环境变量。
8.2 祖孙通信
数据从 Item 传到 App,中间隔了一个 List。其实也很简单:你传给我,我传给他。
App 把更新函数传给 List,List 不用,直接原样传给 Item,然后 Item 就能调用了。
// App 传给 List
<List updateTodo={updateTodo} />
// List 原样传给 Item
<Item updateTodo={props.updateTodo} />
这里我还有一个小的理解,我们的组件最终是要全部放在app里面,那么item里面的变量对于list和app来说就是局部变量,那么app肯定不能够直接访问item的局部变量,同样,list也不能,对于外部要使用内部的局部变量,这种情况就可以使用闭包,通过函数一层层获取里面的局部变量,所以我们看到在item里面最终的函数其实也就是一个闭包函数。
8.3 更新数据
在 App 里面实现更新逻辑:
const updateTodo = (id, done) => {
const newTodos = todos.map(todo => {
if (todo.id === id) {
return { …todo, done };
}
return todo;
});
setTodos(newTodos);
};
注意这里用了扩展运算符 …todo,把原对象的属性展开,再覆盖 done 的值。这样可以保证不直接修改原对象,符合 React 的不可变数据原则。
九、删除功能
删除功能同样用到了祖孙通信,跟更新逻辑类似。
9.1 在 App 创建删除函数
在 App 里面创建一个 deleteTodo 函数:
const deleteTodo = (id) => {
const newTodos = todos.filter(todo => todo.id !== id);
setTodos(newTodos);
};
逻辑很简单:用 filter 过滤掉 id 匹配的那个 todo,剩下的就是新的列表。 然后把这个函数通过 List 传到 Item。
9.2 在 Item 中绑定删除事件
在 Item 组件的删除按钮上绑定点击事件: 当删除按钮被点击,我们就弹出弹窗询问用户是否确定删除,如果是,就执行app传递的deleteTodo函数,如果取消那就什么也不发生
const handleDelete = (id) => {
if (window.confirm('确定要删除这条事项吗?')) {
props.deleteTodo(id);
}
};
// 在 JSX 中
<button onClick={() => handleDelete(todo.id)}>删除</button>
这里用了 window.confirm,不然浏览器不知道这个 confirm 是从哪来的。我这里没有用高阶函数柯里化,直接用箭头函数来调用 handleDelete,这也是通信函数需要传参时的另一种解法。
十、Footer 全选与统计
10.1 在 App 创建全选函数
全选/全不选的逻辑:用 map 对数组里的每一项进行加工。
const checkAllTodo = (checkDone) => {
const newTodos = todos.map(todo => {
return { …todo, done: checkDone };
});
setTodos(newTodos);
};
如果 checkDone 为 true,所有事项的 done 都变成 true;为 false 则全部变成 false。
然后把 todos 和 checkAllTodo 都传给 Footer:
<Footer todos={todos} checkAllTodo={checkAllTodo} />
在这里我们还要把todos传递给footer,因为在footer中我们还要计算已完成的事项数量以及总的数量,所以要将todos传递给footer进行计算。
10.2 Footer 中的逻辑
在 Footer 组件里接收参数:
function Footer(props) {
const { todos, checkAllTodo } = props;
const total = todos.length;
const doneCount = todos.filter(todo => todo.done).length;
const isAllChecked = total > 0 && doneCount === total;
return (
<div>
<span>{doneCount} / {total}</span>
<input
type="checkbox"
checked={isAllChecked}
onChange={() => checkAllTodo(!isAllChecked)}
/>
</div>
);
}
注意:checked 和 onChange 通常要成对使用。checked 的状态根据 doneCount === total 来决定,并且总事项数要大于 0。
10.3 修改 Item 中的 checked
最后,要把 Item 组件里的 defaultChecked 改成 checked,这样当 App 里的数据变化时,所有复选框的勾选状态才能跟着同步更新。
<input type="checkbox" checked={todo.done} onChange={() => handleCheck(todo.id)} />
十一、给 Props 添加类型限制(TypeScript)
目前我们的代码,所有变量和方法都没有加任何限制。不管是子传父还是父传子,默认都是任何情况都接受,但这样往往会在实际运行中出现问题。比如传递的参数是 null 的情况下依旧执行 null.方法(),或者参数传少了导致 undefined,页面渲染就会有问题,甚至白屏。
所以我们需要给代码添加上类型限制。这里我用了 TypeScript,也可以用 prop-types。
11.1 prop-types vs TypeScript
| 定义位置 | 组件函数外部赋值 | 组件外部定义 interface |
| 必填/选填 | .isRequired 后缀 | 属性名后加 ? 表示选填 |
| 字符串 | PropTypes.string | string(小写关键字) |
| 数字 | PropTypes.number | number |
| 布尔值 | PropTypes.bool | boolean |
| 数组 | PropTypes.arrayOf(PropTypes.string) | string[] 或 Array<string> |
| 对象 | PropTypes.shape({ … }) | { key: type } |
| 联合类型 | PropTypes.oneOfType([…]) | type | otherType |
如果你想用 prop-types,需要在控制台执行命令安装,然后 import PropTypes from 'prop-types'。详细的语法我就不介绍了,感兴趣的朋友可以去官网搜索一下道具类型 – NPM。 不过近几年 prop-types 已经很少被使用了,大家更倾向于直接用TypeScript,而且在 React 19 中,prop-types 已被官方标记为弃用(deprecated)。
11.2 TypeScript 改造
首先,要把文件后缀从 .jsx 改成 .tsx。
建议转换的文件:
- API 请求封装(api/user.js → api/user.ts)
- 常量定义(constants/index.js → constants/index.ts)
- 工具函数(utils/helper.js → utils/helper.ts)
- Store/Context 定义(store/index.js → store/index.ts)
可以不用转的文件:
- 构建工具配置(vite.config.js、next.config.js)
- 测试配置(jest.config.js)
- 简单的脚本文件
11.3 定义 Todo 类型
先把 Todo 类型定义好:
interface Todo {
id: string;
name: string;
done: boolean;
}
在 App 组件里给 todos 添加类型限制:
const [todos, setTodos] = useState<Todo[]>([
{ id: '1', name: '学习 React', done: false },
// …
]);
函数的参数也要加类型:
const addTodo = (todo: Todo) => {
setTodos([…todos, todo]);
};
const updateTodo = (id: string, done: boolean) => {
// …
};
11.4 在子组件中定义 Props
以 Item 组件为例:
interface ItemProps {
todo: Todo;
updateTodo: (id: string, done: boolean) => void;
deleteTodo: (id: string) => void;
}
注意:基本类型用小写的 string、boolean、number,不要用大写的 String、Boolean、Number。
然后组件要 加上: ItemProps:
interface ItemProps {
todo: Todo;
updateTodo: (id: string, done: boolean) => void;
deleteTodo: (id: string) => void;
}
function Item({ todo, updateTodo, deleteTodo }: ItemProps) {
// …
}
解释一下:
- interface ItemProps 定义了 Item 组件需要接收哪些参数,以及每个参数的类型
- 在函数参数的位置直接写上 : ItemProps,就相当于给 props 加上了类型限制
- 这里用了解构赋值,直接把 todo、updateTodo、deleteTodo 从 props 里取出来用
好处是什么? 当你在组件里输入 todo. 的时候,VS Code 会自动提示 id、name、done 这些属性;输入 updateTodo( 的时候,也会提示你需要传哪几个参数。如果你写错了类型,或者访问了不存在的属性,TS 会直接报错,不用等到运行才发现问题。
其他组件以此类推,按照 TypeScript 的语法规则来就行了。
十二、总结
这个案例虽然很小,但是一路手搓下来,覆盖了不少 React 的核心知识点:
| 组件拆分 | 按职责拆分成 Header、List、Item、Footer |
| 父传子 | 通过 props 传递数据 |
| 子传父 | 父组件传递函数,子组件调用并传回数据 |
| 祖孙通信 | 通过中间组件层层传递函数 |
| 列表渲染 | map 遍历数组渲染组件列表 |
| 事件处理 | onChange、onClick、onKeyUp |
| 状态更新 | 不可变数据原则,用扩展运算符创建新对象 |
| 条件渲染 | 根据数据状态控制 UI 展示 |
| 类型限制 | TypeScript 接口定义 + 泛型 |
| CSS 设计 | 按钮类名的拆分与复用 |
代码我已经传到 Git 仓库了,大家可以去拉下来跟着练一练。写一遍下来,很多东西就通透了。TodoList源码

