欢迎光临
我们一直在努力

React 待办事项清单实战:从组件拆分到 TypeScript 改造

这是一个待办事项清单的小案例,包含了组件拆分、父子通信、祖孙通信以及 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

    特性prop-types (JS)TypeScript (TS)
    定义位置 组件函数外部赋值 组件外部定义 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源码

    赞(0)
    未经允许不得转载:171主机测评 » React 待办事项清单实战:从组件拆分到 TypeScript 改造
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址