主题
删除todo与切换完成状态(Day6 2/3)
本页对应 React + TypeScript + 医通项目,共 5 段视频,约 35 分钟。
学习目标
- 能使用
useRef创建标识 ref,并通过判断ref.current在useEffect中仅执行更新逻辑,说明普通变量和 state 不能替代该写法的原因。 - 能分别实现非受控组件与受控组件表单,正确使用
defaultValue + ref获取输入值,以及value + onChange实现受控更新。 - 能使用参数方式封装通用
change处理函数,并在多个输入框中正确传入对应 setter 完成状态更新。 - 能使用高阶函数(返回函数)封装
change处理函数,说明其分阶段接收参数的过程并应用到受控表单。 - 能完成 RGB 调色板练习:使用受控滑块、状态驱动背景色,并通过高阶函数统一处理颜色值变化。
- 能实现 Todo 的删除、完成状态切换,并使用
localStorage完成数据持久化与初始化恢复。
知识点
本节目标与数据流
本节要完成的功能是:点击列表中的删除按钮,删除当前这一行。笔记把步骤拆成两步:先点击删除按钮,再删除该行;技术点对应为 onClick 传递 id,以及“子传父”。其中最关键的判断是:数据在谁身上,方法就要定义在谁身上。
删除 todo 时,列表数据通常由父组件持有,子组件只负责渲染某一行。因此子组件不能直接改父组件的数据,而要把“要删除哪一行”的 id 通过回调传给父组件,由父组件更新列表。这个数据流先想清楚,后面的代码才不会写成子组件直接改数组。
知识点一:点击删除按钮,onClick 传递 id
为什么需要:删除按钮在子组件里,但子组件只知道当前行,不知道完整列表。它必须告诉父组件“删哪一个”,所以点击事件要携带当前行的唯一标识 id。
怎么想:按钮的 onClick 不是直接执行删除,而是调用父组件传下来的删除回调,并把当前 todo.id 作为参数传出去。也就是说,子组件负责“上报 id”,父组件负责“真正删除”。
怎么做:父组件把 onDelete 传给子组件,子组件在按钮上写箭头函数调用。结构示意如下(本节笔记未附原代码,以下仅表示调用关系):
jsx
function TodoItem({ todo, onDelete }) {
return (
<button type="button" onClick={() => onDelete(todo.id)}>
删除
</button>
);
}易错点:不要写成 onClick={onDelete(todo.id)},这样渲染时就会立即执行,而不是点击时执行;要写成 onClick={() => onDelete(todo.id)}。另外,如果按钮在表单内,建议加 type="button",避免触发默认提交行为。
知识点二:删除该行,子传父更新列表
为什么需要:列表数据在父组件身上,删除操作必须修改父组件状态。子组件只拿到当前行数据,不能越权直接修改父数据,否则数据流会混乱。
怎么想:按“数据在谁身上,方法就要定义在谁身上”,删除方法应定义在父组件。子组件通过 onDelete(id) 把 id 传上去,父组件根据 id 过滤掉对应项。
怎么做:父组件定义删除方法,并用 filter 生成新数组:
jsx
function TodoList() {
const [todos, setTodos] = useState([]);
const handleDelete = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
));
}易错点:不要用 splice 直接改原数组;应使用 filter 返回新数组再 setTodos。比较 id 时注意类型一致,避免 1 和 "1" 不相等导致删不掉。列表 key 不要用索引,删除后容易错位;应使用稳定 id。删除方法不要定义在子组件里直接改父数据。
调试顺序与常见错误
转写稿本节没有记录删除 todo 的逐步调试日志,因此不补造具体输出。可以借用转写稿中“状态更新会触发函数组件重新执行”的思路,按链路检查:第一,点击按钮是否触发 onClick;第二,todo.id 是否作为参数传到父组件;第三,父组件 filter 条件是否写对,是否用 !== 排除当前 id;第四,setTodos 后列表是否按新状态重新渲染。若点击无反应,优先检查 onClick 是否被立即调用;若删错行,检查 id 来源和 key;若列表不更新,检查是否直接修改了原数组而没有调用 setTodos。
小结
删除 todo 的核心不是“在按钮里写删除”,而是把删除拆成“子组件上报 id”和“父组件更新状态”两段。记住两个技术点:onClick 用箭头函数传递 id;删除方法定义在持有数据的父组件中,通过子传父回调完成删除。
先明确目标:复选框只负责“报告变化”
为什么需要:待办事项的“完成状态”通常保存在父级列表数据里,单条子组件只负责展示。点击复选框时,子组件不能直接改父级数据,否则数据流会乱;正确做法是子组件把“哪一条、变成什么状态”报告给父组件。
怎么想:把复选框看成一个“输入控件”。它变化时触发事件,事件里能拿到最新的勾选值;子组件再调用父组件传下来的函数,把这条数据的 id 和新状态传出去。
怎么做:父组件持有 todos,并把 onToggle 传给子组件;子组件在复选框上绑定 onChange。
易错点:不要直接写 item.done = checked 改 props;不要用 onClick 代替 onChange 去猜状态;不要只传状态不传 id。
事件入口:用 onChange 拿到最新勾选值
为什么需要:笔记写的是“点击复选框,复选框发生变化,触发一个事件”。在 React 里,复选框值变化最直接的入口是 onChange,它会在勾选状态改变时触发。
怎么想:事件回调会收到事件对象。转写稿里口述的“实验对象”应纠正为“事件对象”,即 e。复选框的最新状态在 e.target.checked,不是 e.target.value。
怎么做:先只做一件事——打印,确认事件真的触发、值真的变化:
jsx
function TodoItem({ item, onToggle }) {
function handleChange(e) {
const checked = e.target.checked;
console.log('change:', item.id, checked); // 调试:先看事件和值
onToggle(item.id, checked); // 子传父
}
return (
<li>
<input
type="checkbox"
checked={item.done}
onChange={handleChange}
/>
<span>{item.text}</span>
</li>
);
}易错点:受控复选框要写 checked={item.done};如果写成 value,拿到的不是勾选状态;调试时先刷新看初始值,再点击看控制台是否输出,别一上来就怀疑父组件。
数据流:子传父,把“哪一条”和“新状态”交给父组件
为什么需要:完成状态属于列表数据,父组件统一更新才能保证列表、筛选、统计都不打架。这就是笔记里的“子传父”。
怎么想:子组件只调用父组件给的函数;父组件收到 id 和 checked 后,用不可变方式生成新数组。转写稿里反复强调“回调函数收到参数”“设置状态”,这里对应为:子组件传参,父组件 setTodos。
怎么做:
jsx
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '学习 React', done: false },
]);
function handleToggle(id, checked) {
console.log('parent receive:', id, checked); // 调试:确认子传父成功
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: checked } : todo
)
);
}
return (
<ul>
{todos.map(item => (
<TodoItem key={item.id} item={item} onToggle={handleToggle} />
))}
</ul>
);
}易错点:map 里要返回新对象,不能原地改;id 类型要一致;父组件函数名和子组件接收的 props 名要对应;不要子组件再存一份 done,否则父子状态不同步。
调试顺序:事件 → 回调 → 状态
为什么需要:这类问题往往不是“状态不会改”,而是事件没绑、值取错、id 传错。按顺序排查最快。
怎么想:转写稿的调试套路是“刷新—点击—打印—看效果”。这里同样:先在 handleChange 打印 item.id 和 e.target.checked;再在父组件 handleToggle 打印收到的参数;最后看页面复选框是否随 item.done 变化。
怎么做:如果第一处不打印,检查 onChange 是否绑定;如果第一处有、第二处没有,检查 onToggle 是否从父组件传入;如果第二处有但页面不变,检查 setTodos 是否返回了新数组、复选框是否绑定 checked={item.done}。
易错点:setTodos 后立刻打印 todos 可能还是旧值,这是状态更新的正常表现;要看最终渲染结果,或使用函数式更新并在更新函数内调试。
收尾检查清单
- 复选框用
onChange,取e.target.checked。 - 子组件调用父组件回调:
onToggle(item.id, checked)。 - 父组件用
map生成新数组更新对应条目。 - 受控复选框写
checked={item.done}。 - 调试按“事件是否触发 → 父回调是否收到 → 状态是否更新 → 页面是否变化”排查。
说明:本节转写稿主要记录 useRef、受控/非受控组件等内容,与 localStorage 没有直接对应关系;下面只依据本节笔记展开,不把无关内容硬塞进来。
本地存储解决什么问题
为什么需要:页面刷新后,普通变量、组件状态都会重新初始化。如果有些数据希望下次打开页面还在,就需要浏览器提供的本地存储。localStorage 就是其中一种:按“键值对”保存数据,刷新页面后仍可读取。
怎么想:先把它想成一个挂在浏览器上的小仓库。写入时给一个 key 和一个 value;读取时用同一个 key 取;删除时删指定 key;清空时把当前站点下的本地存储全部删掉。
怎么做:本节笔记给出四个核心 API:
js
// 1. 存储
localStorage.setItem(key, value);
// 2. 读数据
localStorage.getItem(key);
// 3. 删除
localStorage.removeItem(key);
// 4. 全部删除
localStorage.clear();易错点:key 写错时,getItem 取不到;clear() 不是删一个,而是清空当前源下 localStorage 的全部数据,使用前要确认。
写入与读取:setItem / getItem
为什么需要:保存数据是第一步,读取数据是第二步。只写不读,数据没有使用价值;只读不写,读不到内容。
怎么想:写入时先确定一个稳定的 key,例如 username;读取时必须使用同一个 key。读取结果要用变量接住,再打印或渲染。
怎么做:
js
localStorage.setItem('username', '张三');
const username = localStorage.getItem('username');
console.log(username); // '张三'易错点:localStorage 保存的值会以字符串形式处理。即使写入数字,读出来也通常是字符串;如果业务需要数字,要自己转换。getItem 读不到时返回 null,不要直接当成正常字符串使用。
删除与清空:removeItem / clear
为什么需要:本地数据不会自动消失,过期或无用的数据要主动清理,否则可能影响后续逻辑。
怎么想:只删一个键,用 removeItem(key);要清空当前站点全部本地存储,用 clear()。
怎么做:
js
localStorage.removeItem('username');
localStorage.clear();易错点:删除时 key 必须和写入时一致;clear() 影响范围大,不能把它当成“删除某个字段”。
对象和数组为什么要序列化
为什么需要:本节笔记特别强调:本地存储只能存基本数据类型,存不了对象和数组。直接把对象或数组交给 localStorage,读出来不能保持原来的结构。
怎么想:既然 localStorage 更适合保存字符串,那就先把对象或数组“变成字符串”再存;读取时再把字符串“还原”成对象或数组。
怎么做:使用 JSON.stringify 转成 JSON 格式字符串:
js
const user = { name: '张三', age: 18 };
localStorage.setItem('user', JSON.stringify(user));
const userStr = localStorage.getItem('user');
console.log(userStr); // '{"name":"张三","age":18}'数组同理:
js
const list = [1, 2, 3];
localStorage.setItem('list', JSON.stringify(list));易错点:不要直接 setItem('user', user),否则读出来不是可用对象;JSON.stringify 后存进去的是字符串,不是对象本身。
读取还原:JSON.parse
为什么需要:JSON.stringify 存进去的是 JSON 格式字符串,页面使用时通常需要对象或数组,所以要还原。
怎么想:读取分两步:先 getItem 拿到字符串,再 JSON.parse 还原。不要对普通字符串乱用 JSON.parse。
怎么做:
js
const userStr = localStorage.getItem('user');
const userObj = JSON.parse(userStr);
console.log(userObj.name); // '张三'数组:
js
const listStr = localStorage.getItem('list');
const listArr = JSON.parse(listStr);
console.log(listArr); // [1, 2, 3]易错点:如果 getItem 返回 null,或内容不是合法 JSON,直接 JSON.parse 可能出问题。实际写法应先判断,必要时用 try...catch 兜底。
调试与验证
为什么需要:localStorage 是浏览器层面的数据,光看代码容易漏掉“到底存进去没有”。
怎么想:用浏览器开发者工具验证:打开 Application/应用面板,找到 Local Storage,查看当前站点下的键值;也可以直接在控制台执行 setItem、getItem,刷新页面后再读一次。
怎么做:写入后刷新页面,再执行读取;观察 getItem 返回值;存对象时先看存进去的字符串,再 JSON.parse 看还原结果。
易错点:不同源、不同端口下的 localStorage 不共享;调试时确认当前页面地址没有切错。不要把密码、令牌等敏感信息随意长期放在 localStorage 中。
课堂画面





代码示例
示例一:用 useRef 单独模拟 componentDidUpdate
jsx
import React, { useState, useRef, useEffect } from 'react';
function App() {
const [count, setCount] = useState(88);
// 初始值给 true,这个值会挂到 ref.current 上
const flagRef = useRef(true);
useEffect(() => {
// 第一次挂载进来:current 为 true
if (flagRef.current) {
flagRef.current = false; // 改成 false 并存进缓存区
return; // 直接 return,挂载阶段什么都不做
}
// 之后每次更新进来:current 已经是 false,进不来上面的 if
console.log('componentDidUpdate', count);
});
return (
<div>
<h3>{count}</h3>
<button onClick={() => setCount(count + 1)}>加一</button>
</div>
);
}
export default App;要点:
useRef(true)的初始值会挂到ref.current上,且 ref 的值存在 React 的缓存区间里,修改 current 不会触发页面更新,这正是它能当"标识变量"的原因。- 第一次挂载时
current为true,进入 if → 改成false并return,所以挂载阶段不执行任何逻辑;第二次及以后进来current已为false,于是只执行 update 里的代码。 - 易错点 1:用普通变量
let flag = true不行。每次setCount都会重新调用 App 函数、产生新的执行栈,新栈里的flag永远是true,flag = false白改,update 永远出不来。 - 易错点 2:用 useState 存 flag 也不行。
setFlag(false)本身会触发一次组件重新调用,挂载阶段就会多跑一轮 update,你看到的效果是"挂载时也打印了"。 - 易错点 3:
useEffect不写依赖数组时,本身就是componentDidMount + componentDidUpdate,所以必须靠 ref 这个判断把第一次"挡掉"。
示例二:受控组件 change 函数封装(参数方式 + 高阶函数方式)
jsx
import React, { useState } from 'react';
function Controlled() {
const [userName, setUserName] = useState('艾硅谷');
const [password, setPassword] = useState('123123');
// 方式一:函数名作为参数传入
const change = (e, fn) => {
fn(e.target.value);
};
// 方式二:高阶函数(函数内部返回函数),分阶段接收参数
// const change = (fn) => (e) => fn(e.target.value);
const save = (e) => {
e.preventDefault(); // 阻止表单默认提交行为
console.log(userName, password);
};
return (
<form onSubmit={save}>
<input
type="text"
value={userName}
onChange={(e) => change(e, setUserName)}
/>
<input
type="password"
value={password}
onChange={(e) => change(e, setPassword)}
/>
<button>保存</button>
</form>
);
}
export default Controlled;要点:
- 受控组件三件套:
value={状态}+onChange+ 回调里set状态(e.target.value)。只写value不写onChange,输入框会变成"吃字"的不可输入状态,并给出警告。 - 用参数方式封装时,
change有两个参数,React 只会自动帮你传事件对象,第二个参数必须自己写,所以必须写成onChange={(e) => change(e, setUserName)}—— 外层要包一个箭头函数,不能写onChange={change(e, setUserName)}(那是立即调用)。 - 换成高阶函数写法后,
change(setUserName)的返回值才是真正的事件回调,所以可以直接写onChange={change(setUserName)},不需要再包箭头函数。 - 易错点:
setUserName是函数,直接作为参数传是安全的(不会立刻执行),但绝不能写成change(setUserName()),那会在渲染时就调用 setState 造成死循环。 - 非受控组件的对照写法:
defaultValue+useRef取ref.current.value,与受控组件的value+onChange是两套完全不同的套路,别混用。
示例三:受控组件练习——调色板
jsx
import React, { useState } from 'react';
function ColorPicker() {
const [r, setR] = useState(0);
const [g, setG] = useState(255);
const [b, setB] = useState(0);
// 高阶函数封装 change,供三个滑块复用
const change = (fn) => (e) => fn(e.target.value);
return (
<div>
<div
style={{
width: 200,
height: 200,
border: '1px solid red',
backgroundColor: `rgb(${r},${g},${b})`,
}}
/>
<div>
R: <input type="range" min="0" max="255" value={r} onChange={change(setR)} />
</div>
<div>
G: <input type="range" min="0" max="255" value={g} onChange={change(setG)} />
</div>
<div>
B: <input type="range" min="0" max="255" value={b} onChange={change(setB)} />
</div>
</div>
);
}
export default ColorPicker;要点:
backgroundColor必须用模板字符串`rgb(${r},${g},${b})`,用普通引号'rgb(r,g,b)'时r/g/b只是字面量,颜色不会跟着变(转写稿里正是踩了这个坑,报 "RGB is not defined")。- 三个
input type="range"都加了min="0"max="255",滑块范围被限制在 0–255,正好对应 RGB 取值范围。 - 受控的关键:
value={r}让滑块的位置由状态决定,onChange里setR(e.target.value)把用户拖动后的值写回状态;状态变 → 重新渲染 →backgroundColor重算 → 背景色实时变化。 - 易错点:
e.target.value拿到的是字符串,用来拼rgb()没问题;但如果要参与数值运算,记得先Number()转换。 - 易错点:三个滑块共用同一个
change,靠的是高阶函数"分阶段接收参数"——先接收setR返回一个函数,再由 React 调用这个内部函数并把事件对象传进去。
常见错误
点击删除按钮,行没消失,控制台也没报错
- 现象:子组件里的数据看起来被改了,父组件列表纹丝不动。
- 原因:在子组件里直接操作自己的局部变量/拷贝,而列表数据在父组件身上。
- 修法:父组件定义
handleDel(id),子组件onClick={() => handleDel(item.id)},只负责把 id 传上去。
点删除按钮后整页刷新、地址栏多了个问号
- 现象:列表清空了又重新渲染回初始数据。
- 原因:按钮在
<form>内,默认type="submit",触发了表单默认提交。 - 修法:给按钮加
type="button",或在提交处理函数里e.preventDefault()。
复选框点不动,或者勾了没反应
- 现象:给
checked绑了状态,鼠标点了框还是原样;或勾选后状态不变。 - 原因:绑了
checked却没写onChange,React 判定为只读;或事件用成了onClick。 - 修法:
checked={item.done}配onChange={() => handleToggle(item.id)},在父组件里按 id 匹配取反。
- 现象:给
localStorage 存进去是
[object Object],刷新后JSON.parse直接崩- 现象:读取时抛
Unexpected token o in JSON at position 1,或列表变空。 - 原因:localStorage 只能存字符串;首次读取时 key 还不存在,
getItem返回null。 - 修法:存
JSON.stringify(list),读JSON.parse(localStorage.getItem(k) || '[]'),并用try/catch兜底。
- 现象:读取时抛
受控输入框打不进字
- 现象:
value绑了 state,键盘敲下去毫无反应。 - 原因:受控组件缺
onChange,React 强制把值锁回 state 的旧值。 - 修法:补
onChange={(e) => setValue(e.target.value)};若只想给初始值,改用defaultValue。
- 现象:
高阶函数封装 change 后,一刷新就疯狂 setState / 点击时
e是 undefined- 现象:控制台报 "Too many re-renders",或
e.target取不到值。 - 原因:把带括号的调用直接写在
onClick上(onClick={change(e, setX)}),在渲染阶段就执行了;或者用箭头函数包了一层,却仍按高阶函数的方式传参。 - 修法:参数方式写
onClick={(e) => change(e, setX)};高阶函数方式写onClick={change(setX)},让返回的那个函数自己接收e。
- 现象:控制台报 "Too many re-renders",或
用普通变量或 state 当"是否首次渲染"的标识,结果挂载时也执行了
- 现象:本想只模拟 componentDidUpdate,结果首次渲染也打印;换成 state 后打印两次。
- 原因:普通变量随每次渲染重建,永远是初始值;state 一改会触发新一轮渲染,等于多跑一次更新。
- 修法:用
useRef(true)存标识,判断后改ref.current = false再return,current跨渲染保留且不触发渲染。
练习
列表删除闭环:给 TodoList 每行加删除按钮,父组件持有数据并实现
handleDel(id),子组件只传 id。验收:点击任意一行,仅该行消失,且页面不刷新。完成状态切换:把复选框改成受控写法,勾选后该行文字加删除线,并在列表顶部显示"未完成 X 条 / 共 Y 条"。验收:连续勾选/取消,计数与样式实时同步,控制台无 checked 相关警告。
本地持久化:让 todos 在刷新后仍在。要求:写入放在
useEffect里,初始化用useState的惰性初始值读取,读不到时返回空数组。验收:增删改后刷新页面数据不丢;手动清空 localStorage 后刷新页面不报错。用 useRef 单独模拟 componentDidUpdate:实现"只在 todos 发生变化时打印最新长度,首次挂载不打印"。验收:刷新页面控制台无输出,每次增删改只打印一次。再用普通变量替换 ref 复现失败现象,说明原因。
受控表单 + 高阶函数封装 change:做一个含用户名、密码、邮箱三个输入框的表单,只写一个
change(fn)高阶函数统一处理,提交时打印三个值。验收:三个框都能正常输入,change只定义一次;把onClick里的写法改成onClick={change(setX)}与onClick={(e) => change(e, setX)}两种版本,均能工作。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | useRef单独模拟componentDidUpdate[了解] | 12:29 |
| 2 | 非受控组件 | 04:59 |
| 3 | 受控组件-参数方式封装change | 07:40 |
| 4 | 受控组件-高阶函数封装change函数 | 05:23 |
| 5 | 受控组件调色板练习 | 05:22 |
小结
- 列表类增删改的落点永远在"数据持有者"那一层,子组件只负责把 id 和意图往上递。
- 事件选型看语义:点击行为用
onClick,值发生变化用onChange;受控的 checkbox 只认onChange。 - 受控与非受控的取舍标准是"要不要实时拿到值":要联动、要校验就用
value + onChange,只在提交时取值就用defaultValue + useRef。 - 多字段共用一套 change 逻辑时,优先用高阶函数返回函数,而不是在 JSX 里写箭头函数包一层——前者每次渲染只创建一次,后者每次渲染都是新函数。
useRef.current是唯一"能跨渲染保存、改了又不触发渲染"的位置,标识位、定时器 id、DOM 引用都该放这里;普通变量和 state 都替代不了它。- localStorage 只是字符串仓库,数组和对象进出必须过
JSON.stringify/JSON.parse,且读取时要对null和脏数据兜底。 - 渲染期间只读数据,写本地存储属于副作用,必须放进
useEffect,否则会出现写入时序错乱或重复写入。