TODO List

案例需求

一个输入框, 下面显示任务列表, 最下面显示总共任务条数, 全选按钮, 以及清除已完成任务的按钮. 输入后回车添加任务到最上面, 鼠标移动到任务上面, 任务显示删除按钮, 确认后可以删除.

实现静态组件

首先, 最大的整个就是 App 组件, 但是我们不能就到这里了.

  1. 上面的输入框, 可以添加任务, 这就是一个单独的组件了, Header
  2. 下面的任务列表, 展示所有的任务, 是第二个组件, List
  3. 任务列表中的每一个任务, 都是一个组件, Item
  4. 最下面的部分是最后一个组件, Footer

image

下面, 就可以创建对应的四个组件了. (App 组件不算)

1
2
3
4
5
6
7
8
9
10
import {Component} from "react";

export default class <组件名称> extends Component {
render() {
return (
<>
</>
);
}
}

image

创建对应目录, 写入以上内容即可.

这里使用的是企业中常用的 index ​ 写法, 而不是叫做 xxx. jsx 了

根据拆分的思想, 首先实现 Header, 其实就是一个输入框, 直接拿过来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Header -> index.jsx */
import {Component} from "react";

// 引入样式表
import './index.css'

export default class Header extends Component {
render() {
return (
<div className="todo-header">
<input type="text" placeholder="请输入你的任务名称, 按下回车创建"/>
</div>
);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Header -> index.css */
.todo-header input {
width: 560px;
height: 28px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 7px;
}

.todo-header input:focus {
outline: none;
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}

List 部分有些复杂, 我们先实现最下面的 Footer 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {Component} from "react";
import './index.css'

export default class Footer extends Component {
render() {
return (
<div className="todo-footer">
<label>
<input type="checkbox"/>
</label>
<span>
<span>已完成0</span> / 全部2
</span>
<button className="btn btn-danger">清除已完成任务</button>
</div>
);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* Footer -> index.css */
.todo-footer {
height: 40px;
line-height: 40px;
padding-left: 6px;
margin-top: 5px;
}

.todo-footer label {
display: inline-block;
margin-right: 20px;
cursor: pointer;
}

.todo-footer label input {
position: relative;
top: -1px;
vertical-align: middle;
margin-right: 5px;
}

.todo-footer button {
float: right;
margin-top: 5px;
}

回到 List 组件, 里面是有很多个 Item 组件的, 所以可以让 List 组件布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {Component} from "react";

// 引入组件
import Item from "../Item/index.jsx";

// 引入样式
import './index.css'

export default class List extends Component {
render() {
return (
<ul className="todo-main">
<Item/>
</ul>
);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*main*/
.todo-main {
margin-left: 0px;
border: 1px solid #ddd;
border-radius: 2px;
padding: 0px;
}

.todo-empty {
height: 40px;
line-height: 40px;
border: 1px solid #ddd;
border-radius: 2px;
padding-left: 5px;
margin-top: 10px;
}

Item 就是列表的元素, 可以写出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {Component} from "react";
import './index.css';

export default class Item extends Component {
render() {
return (
<li>
<label>
<input type="checkbox"/>
<span>xxxxx</span>
</label>
<button className="btn btn-danger" style={{display: "none"}}>删除</button>
</li>
);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*item*/
li {
list-style: none;
height: 36px;
line-height: 36px;
padding: 0 5px;
border-bottom: 1px solid #ddd;
}

li label {
float: left;
cursor: pointer;
}

li label li input {
vertical-align: middle;
margin-right: 6px;
position: relative;
top: -1px;
}

li button {
float: right;
display: none;
margin-top: 3px;
}

li:before {
content: initial;
}

li:last-child {
border-bottom: none;
}

至此, 回到 App 组件, 调用即可看到效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {Component} from "react";

// 引入自定义组件
import Header from "./components/Header";
import Footer from "./components/Footer";
import List from "./components/List";

// 引入全局样式
import './App.css'

export default class App extends Component {
render() {
return (
<div className="todo-container">
<div className="todo-wrap">
<Header/>
<List/>
<Footer/>
</div>
</div>
)
}
}

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*base*/
body {
background: #fff;
}

.btn {
display: inline-block;
padding: 4px 12px;
margin-bottom: 0;
font-size: 14px;
line-height: 20px;
text-align: center;
vertical-align: middle;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
border-radius: 4px;
}

.btn-danger {
color: #fff;
background-color: #da4f49;
border: 1px solid #bd362f;
}

.btn-danger:hover {
color: #fff;
background-color: #bd362f;
}

.btn:focus {
outline: none;
}

.todo-container {
width: 600px;
margin: 0 auto;
}
.todo-container .todo-wrap {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
}

动态初始化列表

刚才的 List 中的内容都是写死的, 这显然不是我们想要的结果. 状态中的数据驱动了页面的展示, 这个状态储存在哪里呢?

考虑到, Header 也要输入状态, 目前的知识无法实现兄弟组件 (非父子关系) 之间的传参, 这就很不好了. 所以我们可以把状态存储在 App 中, 父子之间使用 props 进行传递, 反过来可以用函数的方式.

回到 App, 定义状态.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export default class App extends Component {
// 初始化定义状态
state = {
todos: [
// 考虑到任务是有是否完成的属性的, 我们不能直接使用一个字符串表示了, 而是要用对象实现.
{id: '001', name: '吃饭', done: false},
{id: '002', name: '睡觉', done: true},
{id: '003', name: '敲代码', done: false}
]
}

render() {
const {todos} = this.state;
return (
<div className="todo-container">
<div className="todo-wrap">
<Header/>
{/*List需要渲染 所以传递给List组件 另外这里不能简写了*/}
<List todos={todos}/>
<Footer/>
</div>
</div>
)
}
}

随后, 在 List 组件中接收参数 props.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default class List extends Component {
render() {
// 直接读取props的内容
const {todos} = this.props;
return (
<ul className="todo-main">
{/*多少个item 取决于todos的长度*/}
{
todos.map((todo) => {
return <Item key={todo.id}/>
})
}
</ul>
);
}
}

image

数量正确了, 但是我们需要的是里面的文字根据 todo 来进行渲染, 则可以继续传参.

1
2
3
todos.map((todo) => {
return <Item key={todo.id} todo={todo}/>
})

这样, 在 Item 中, 则可以获取到 todo 了, 根据 todo 进行渲染即可.

这里需要注意, 最好不要用 checked 属性, 这会导致原来的按钮变得无法点击, 也就是只读.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Item index.jsx
export default class Item extends Component {
render() {
const {todo} = this.props
return (
<li>
<label>
<input type="checkbox" defaultChecked={todo.done}/>
<span>{todo.name}</span>
</label>
<button className="btn btn-danger" style={{display: "none"}}>删除</button>
</li>
);
}
}

至此, 任务渲染完毕, 一切顺利!

image

添加 TODO

回车添加 todo, 所以这是一个键盘事件的监听, 找到 Header, 添加一个键盘事件并绑定函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Header index.jsx
export default class Header extends Component {
handleKeyUp = (event) => {
// 键盘抬起
console.log(event.target.value)
}

render() {
return (
<div className="todo-header">
<input onKeyUp={this.handleKeyUp} type="text" placeholder="请输入你的任务名称, 按下回车创建"/>
</div>
);
}
}

GIF 2025-5-13 8-22-30

然而, 这不是我们想要的效果, 我们希望的是按下回车才会触发事件. 我们根据 event.keyCode ​ 来判断按下的是什么按键. 只有 keyCode 为 13 才是按下回车. 另外也可以使用解构赋值进行代码简化:

1
2
3
4
5
handleKeyUp = (event) => {
// 判断是否按下回车 不是回车的话停止逻辑
if (event.keyCode !== 13) return;
console.log(event.target.value)
}

接下来, 就是想办法将用户的输入放入组件中. 我们的数据是 App 组件中的状态, 那么我们需要修改的就是 App 中的状态了, 溯源修改对应的数据. 父给子传东西可以直接使用 props, 反过来呢? 其实就是逆转思维了.

我们还是使用 props 进行传参, 不过需要传入的就是一个函数了, 让子组件调用函数, 即可实现反向的传参了.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 定义添加todo的方法 稍后传递给子组件进行调用
// 我希望穿过来的就是一个打包好的对象, 不是name
addTodo = (todoObj) => {
console.log("Add Todo", todoObj)
// 获取原来的todos
const {todos} = this.state;
// 追加todo
const newTodos = [todoObj, ...todos];
// 更新状态
this.setState({
todos: newTodos
})
}
render() {
const {todos} = this.state;
return (
<div className="todo-container">
<div className="todo-wrap">
{/*传入一个方法即可*/}
<Header addTodo={this.addTodo}/>
{/*List需要渲染 所以传递给List组件 另外这里不能简写了*/}
<List todos={todos}/>
<Footer/>
</div>
</div>
)
}

回到 Header 组件中, 我们可以写出如下代码:

1
2
3
4
5
6
7
8
9
handleKeyUp = (event) => {
const {keyCode, target} = event;
// 判断是否按下回车 不是回车的话停止逻辑
if (keyCode !== 13) return;
// 构建todo对象 方便进行传递
// id 肯定是随机的, 这里我们可以使用一个专门生成唯一ID库的东西: UUID
const todoObj = {id: "", name: target.value, done: false};
this.props.addTodo(todoObj);
}

这里的 ID 肯定不能是一样的, 那么我们可以怎么做呢? 这里推荐使用一个库: nanoid, 比较小的一个可以用来生成唯一 ID 的库. 在项目目录下运行命令即可安装: npm install nanoid

随后, 代码中引入这个函数即可随机创建一个 ID 出来:

1
2
3
4
5
6
// 引入随机id库
import {nanoid} from "nanoid";

...

const todoObj = {id: nanoid(), name: target.value, done: false};

至此, 已经可以添加内容了!

GIF 2025-5-13 14-13-03


然而, 现在发现直接空格也是可以添加 todo 的, 这是我们不想要的, 所以再加一些判断即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
handleKeyUp = (event) => {
// 解构赋值
const {keyCode, target} = event;
// 判断是否按下回车 不是回车的话停止逻辑
if (keyCode !== 13) return;
// 判断输入是否成立
if (target.value.trim() === '') {
alert("输入不能为空!")
return;
}
// 构建todo对象 方便进行传递
// id 肯定是随机的, 这里我们可以使用一个专门生成唯一ID库的东西: UUID
const todoObj = {id: nanoid(), name: target.value, done: false};
this.props.addTodo(todoObj);
}

鼠标移入效果

我希望, 鼠标悬浮上去后, 背景色能够自动修改, 并且后面出现一个删除按钮.

可以直接给单独的 Item 添加绑定事件, 监听移入和移出; 同时, 将状态保存到自身的 state 里面, 让样式, 以及后面的删除按钮根据 state 判断就好.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export default class Item extends Component {
state = {
mouseIsIn: false
}

handleMouse = (isMouseIn) => {
// 使用函数柯里化
return () => {
this.setState({
mouseIsIn: isMouseIn
})
}
}

render() {
const {todo} = this.props
const {mouseIsIn} = this.state
return (
<li style={{backgroundColor: mouseIsIn ? "#ddd" : "#fff"}} onMouseEnter={this.handleMouse(true)}
onMouseLeave={this.handleMouse(false)}>
<label>
<input type="checkbox" defaultChecked={todo.done}/>
<span>{todo.name}</span>
</label>
<button className="btn btn-danger" style={{display: mouseIsIn ? "block" : "none"}}>删除</button>
</li>
);
}
}

GIF 2025-5-13 14-25-35

至此, 鼠标的移入效果, 悬浮效果已经实现!

勾选或取消勾选 TODO

勾选和取消勾选, 是一种变化, 也就是 onChange 监听了, 我们对前面的勾选框添加一个事件监听, 并调用回调函数.

另外, 考虑到我们勾选是需要知道 ID 的, 所以我们应当传入 ID, 这个函数同样需要进行柯里化.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
handleCheck = (id) => {
// 复选框点击
return (event) => {
console.log("id =", id)
console.log("是否勾选", event.target.checked)
}
}

...

return (
<input type="checkbox" defaultChecked={todo.done} onChange={this.handleCheck(todo.id)}/>
...
)

现在只是获取, 我还需要通知 App 来更改了. 这里需要注意, 层级关系为: App -> List -> Item, 存在一个多层嵌套的关系, 所以需要逐层进行传递.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 用来勾选或者取消Item的函数
updateTodo = (id, done) => {
console.log("调用了")
// 首先 获取todos
const {todos} = this.state;
// 寻找 遍历找到指定项
// 这里一定要接收新的数组 否则无效更改
const newTodos = todos.map((todoObj) => {
if (todoObj.id === id) {
// 找到了 返回更改后的值
return {...todoObj, done};
} else {
// 否则不做处理
return todoObj;
}
})
// 最后 设置状态
this.setState({
todos: newTodos
})
}

render() {
const {todos} = this.state;
return (
<div className="todo-container">
<div className="todo-wrap">
<Header addTodo={this.addTodo}/>
{/*传入函数 用来切换状态*/}
<List todos={todos} updateTodo={this.updateTodo}/>
<Footer/>
</div>
</div>
)
}

回到 List 组件, 不需要调用, 继续传递即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default class List extends Component {
render() {
// 直接读取props的内容
const {todos, updateTodo} = this.props;
return (
<ul className="todo-main">
{/*多少个item 取决于todos的长度*/}
{
todos.map((todo) => {
return <Item key={todo.id} todo={todo} updateTodo={updateTodo}/>
})
}
</ul>
);
}
}

最后在 Item 组件中, 进行调用即可:

1
2
3
4
5
6
7
8
9
handleCheck = (id) => {
// 复选框点击
return (event) => {
// console.log("id =", id)
// console.log("是否勾选", event.target.checked)
// 调用传入的函数即可
this.props.updateTodo(id, event.target.checked);
}
}

这样状态就可以正常更新了!

GIF 2025-5-13 14-47-41

删除 TODO

给删除按钮添加一个删除事件, 我们肯定需要拿到 ID, 所以直接传入; 这里可以不使用高阶函数.

1
2
3
4
5
6
7
8
9
10
11
handleDelete = (id) => {
// 删除
}

...

return (
<button onClick={() => this.handleDelete(todo.id)} className="btn btn-danger"
style={{display: mouseIsIn ? "block" : "none"}}>删除
</button>
)

在 App 中实现删除 todo 的方法, 然后逐层传递给 Item 进行调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
deleteTodo = (id) => {
// 根据ID 删除TODO
// 获取原来的todos
const {todos} = this.state;

// 删除指定的id的对象 使用数组的过滤即可
const newTodos = todos.filter((todoObj) => {
return todoObj.id !== id;
})
// 更新状态
this.setState({
todos: newTodos
})
}

正常调用即可得到正确的运行结果!

GIF 2025-5-13 15-08-44


另外, 也可以加一个确认弹窗, 我们可以使用 confirm ​ 来实现. 对于这个函数, 调用的时候需要明确是 window ​ 身上的; 其次, 如果用户点击了是, 则会返回 true, 否则返回 false.

1
2
3
4
5
handleDelete = (id) => {
if (window.confirm("确认要删除该任务吗?")){
this.props.deleteTodo(id);
}
}

实现底部功能

首先, 实现已完成和全部多少的 文字部分. 全选暂时放在一边, 先实现简单一些的.

简单说, 只要把 todos 交给 Footer, 就可以实现统计了, 所以直接在 App 中传递 todos:

1
<Footer todos={todos}/>

随后, 使用 reduce 函数以及数组本身的 length, 即可统计当前总共有多少, 以及完成了多少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
render() {
const {todos} = this.props;
// 获取总数
const total = todos.length;
// 计算已完成的数目
// 其实就是对数组进行条件统计 可以使用reduce函数.
const doneTotal = todos.reduce((pre, todo) => {
return pre + (todo.done ? 1 : 0);
}, 0)
return (
<div className="todo-footer">
<label>
<input type="checkbox"/>
</label>
<span>
<span>已完成 {doneTotal}</span> / 全部 {total}
</span>
<button className="btn btn-danger">清除已完成任务</button>
</div>
);
}

另外, 全选按钮也需要实现. 逻辑其实就是, 当选择的个数等于全部个数的时候勾选, 否则没有勾选.

1
<input type="checkbox" checked={doneTotal === total}/>

另外, 我们也需要能够修改, 这里是不能设置 defaultChecked 的, 因为它的勾选与否是有功能作用的!

我们直接给它的勾选与否添加一个方法: onChange 即可.

对于之前的 Item 也需要设置为 checked, 不然无法正常监听更改!


对于后面的清除已完成, 只需要配合 filter 即可实现功能.

1
2
3
4
5
6
7
8
9
10
11
12
13
clearDoneTasks = () => {
// 清除已完成的任务
// 获取当前任务
const {todos} = this.state;
// 筛选
const newTodos = todos.filter((todoObj) => {
return !todoObj.done
})
// 更新
this.setState({
todos: newTodos
})
}

知识点总结

  1. 拆分组件, 实现静态组件.
    1. 其中需要注意 className, style={{}} ​ 的写法.
  2. 动态初始化列表
    1. 如何确认数据放在哪个组件中呢?
    2. 某个组件使用: 放在自身的 state 中即可
    3. 某些组件使用: 放在他们共同的父组件中即可 (这叫做状态提升)
  3. 子组件之间通信
    1. 父组件给子组件传递, 通过 props 传递
    2. 子组件给父组件传递, 通过 props 传递, 不过父组件需要给子组件传递函数
    3. 注意 defaultChecked (第一次起作用) 和 checked (一直有作用) 的区别, 类似的 defaultValue 和 value
    4. 状态在哪里, 操作状态的方法就在哪里