主题
ref与todolist类组件实现练习(Day5 3/3)
本页对应 React + TypeScript + 医通项目,共 5 段视频,约 33 分钟。
学习目标
- 能够为单条待办复选框绑定受控
change事件,通过子传父传递id,并在父组件中使用map取反对应项的isDone后验证视图更新。 - 能够实现全选/全不选功能:根据已完成数量与总数计算复选框
checked状态,处理0/0边界,并通过change事件获取最新checked值批量更新所有待办状态。 - 能够实现“删除已完成”功能:使用
filter保留未完成项,并在触发删除前通过window.confirm完成二次确认。 - 能够使用
localStorage的setItem/getItem/removeItem/clear及JSON.stringify/JSON.parse持久化对象数组,并验证刷新页面后数据仍可恢复使用。 - 能够定位并修复本地存储数据格式错误导致的
toDo.map is not a function等数组方法报错,通过检查 Application/localStorage、清理错误键、初始化空数组恢复页面。 - 能够在类组件 Todolist 练习中说明并使用
ref获取所需 DOM 或组件引用,完成与待办操作相关的事件处理验证。
知识点
ref 的作用:获取真实 DOM 元素
为什么需要:在 React 中,我们通常通过 state 和事件对象来驱动界面,例如转写稿里复选框通过 e.target.checked 获取选中状态,并不需要直接碰 DOM。但有些操作必须拿到真实 DOM:让输入框聚焦、读取元素尺寸、控制滚动位置、播放媒体等。此时就需要 ref。笔记明确写了:ref 的作用是“在 react 项目中,获取真实 dom 元素”。
怎么想:先不要一上来就操作 DOM。先判断:这个值能不能用 state 或事件对象拿到?如果能,优先用 React 的数据流。只有“必须操作真实 DOM 节点”时,才创建 ref。创建 ref 后,它像一个“盒子”,React 在渲染并把元素挂到页面后,会把真实 DOM 放进这个盒子的 current 属性里。
怎么做:按笔记三步走。第一步创建 ref 对象:
js
this.inputRef = React.createRef();第二步给 JSX 元素绑定 ref 属性,保留笔记写法:
jsx
<input ref={ref对象} />实际类组件中写成:
jsx
<input ref={this.inputRef} />第三步通过 current 获取真实 DOM:
js
ref对象.current例如:
js
this.inputRef.current.focus();易错点:不要以为 React.createRef() 一执行,current 里就有 DOM。创建时它只是空盒子,元素挂载后才有值;在渲染前或元素卸载后访问,可能是 null。另外,ref对象 和 ref对象.current 是两回事:前者是 ref 容器,后者才是真实 DOM。
绑定 ref 后如何验证与调试
为什么需要:ref 绑定是否成功,不能靠猜。转写稿里调试复选框时,会先刷新页面、观察事件是否收到 ID、再确认状态是否变化;ref 调试也应先验证 current 到底是什么。
怎么想:先最小化验证:只放一个 input 和一个按钮,点击按钮时打印 this.inputRef.current。如果打印出真实 DOM 节点,说明绑定成功;如果打印 null,先检查 ref 是否绑到了 JSX 元素上,以及访问时机是否在挂载之后。
怎么做:可以写一个完整类组件:
jsx
class Demo extends React.Component {
constructor(props) {
super(props);
this.inputRef = React.createRef();
}
handleFocus = () => {
console.log(this.inputRef.current);
this.inputRef.current.focus();
};
render() {
return (
<div>
<input ref={this.inputRef} />
<button onClick={this.handleFocus}>聚焦输入框</button>
</div>
);
}
}点击按钮后,控制台应能看到 input 的真实 DOM,并且输入框获得焦点。
易错点:调试时不要只打印 this.inputRef,那看到的是 ref 对象;要打印 this.inputRef.current。如果点击按钮报“Cannot read properties of null”,通常是 ref 没绑上,或元素还没挂载就访问了。
与受控组件取值的区别
为什么需要:转写稿中复选框通过 onChange 和 e.target.checked 获取最新选中状态,再传给父组件修改数组。这说明很多场景不需要 ref。若把 ref 当成“万能取值工具”,会破坏 React 的数据流。
怎么想:表单值优先走受控组件:value / checked 配 onChange,从事件对象或 state 取。只有事件对象给不了、state 也不适合表达的操作,才用 ref 直接操作 DOM。
怎么做:像转写稿里的复选框,继续用:
jsx
<input
type="checkbox"
checked={todo.isDone}
onChange={(e) => this.checkOne(todo.id, e.target.checked)}
/>而“让输入框聚焦”这类行为,用:
js
this.inputRef.current.focus();易错点:不要为了拿输入值而滥用 ref;受控组件更符合 React 思路。也不要混淆“获取真实 DOM”和“修改 React 状态”:ref 改 DOM 不会自动改 state,state 改完也要通过渲染反映到界面。
使用 ref 的关键检查清单
为什么需要:ref 的三个步骤很短,但出错点集中在创建、绑定、访问时机。
怎么想:按顺序检查:ref 是否用 React.createRef() 创建?是否绑定到目标 JSX 元素?访问是否在挂载后?是否访问了 .current?
怎么做:保留本节核心代码:
js
// 1. 创建 ref 对象
this.xxxRef = React.createRef();
// 2. 绑定 ref 属性
<input ref={this.xxxRef} />
// 3. 获取真实 DOM
this.xxxRef.current;易错点:ref 名不要和普通变量混淆;一个 ref 只绑定一个目标更清晰;元素卸载后不要再访问 current。记住:ref 是获取真实 DOM 的通道,不是替代 state 的捷径。
本节目标与组件拆分
这一节的核心目标不是单纯把 TodoList 写出来,而是练习组件化开发思路:先拆页面,再定数据,最后按“描述行为—拆分步骤—匹配技术点”的方式实现交互。

为什么需要先拆组件?因为 TodoList 同时包含输入、列表、底部统计和操作区。若全部写在一个组件里,状态和事件会混在一起。怎么想?按页面区域拆:父组件管理数据,输入区负责收集内容,列表项负责展示与触发单条操作,底部组件负责统计、全选和清除已完成。怎么做?先搭静态页面,再渲染首屏数据,最后逐个补交互。易错点是先写交互再拆组件,导致数据不知道该放哪。
首屏数据与数据放在哪里
首屏渲染前要先分析数据结构。笔记给出的结构是:
js
let todos = [
{
id:xxx,
title:'吃饭',
isDone:true | false
}
]为什么要有 id?因为勾选、删除都要定位具体某一条。title 负责显示内容,isDone 表示完成状态。
数据应该定义在哪个组件身上?原则是:只在一个组件使用的数据放自己身上;多个组件都要用的数据放到共同父级身上,也就是“状态提升”。本节的 todos 被列表、底部统计、全选等共同使用,所以应放在父组件中。易错点是子组件各自维护一份列表,导致统计和列表不同步。
添加待办:从描述到技术点
转写稿强调交互实现要先描述结果:文本框输入内容,按下回车,把内容添加到列表中。再拆步骤:获取输入内容、监听回车、追加到列表。匹配技术点:输入框可用 ref、受控组件或 e.target.value;回车用 onKeyUp/onKeyDown;追加列表涉及子传父。
为什么需要子传父?因为输入区只负责收集内容,真正修改 todos 的方法应定义在持有数据的父组件中。怎么做?子组件把输入内容传给父组件,父组件再更新列表。易错点是输入为空也追加,或事件绑定后忘记在父组件真正 setState。
单条勾选:checkOne
点击复选框改变完成状态,行为可描述为:复选框状态变化,触发事件,改变该条 isDone。技术点仍是受控组件加 onChange,并通过子传父把当前条 id 传给父组件。
转写稿中调试时先确认父组件能否收到 id:点击不同条目,父组件分别收到对应 id。父组件拿到 id 后,用 map 生成新数组:找到对应 id 的那条,把 isDone 取反,其余原样返回,再 setState。为什么用 map?因为长度不变,只改其中一条。易错点是直接修改原数组,或忘记返回新对象。
全选与全不选:checkAll
底部复选框是否勾选,应由数据推导:已完成数量 doneNumber 等于总数 total 时勾选。转写稿中发现一个 bug:当 0 和 0 相等时也会勾选,所以条件还要加“总数大于 0”。即类似:doneNumber === total && total > 0。
点击全选框时,通过 onChange 获取 e.target.checked,再子传父给父组件。父组件用 map 把每一项的 isDone 统一改成传入的布尔值。易错点是只改底部状态,没有改 todos;或全选条件漏掉空列表判断。
删除已完成:deleteDone
“清除已完成”要删除 isDone 为 true 的项,保留未完成项,因此用 filter,返回 !todo.isDone。这个方法不需要传具体 id,因为“哪些已完成”从 todos 里就能判断。转写稿中还加了 window.confirm 提示,点击取消就不执行删除。易错点是把 filter 写成 map,或忘记用 window.confirm 导致找不到方法。
localStorage 持久化与常见报错
刷新后数据丢失,是因为数据写死在代码里。持久化可存到 localStorage。但本地存储只能存基本类型,不能直接存对象和数组;直接存数组会变成类似 [object Object] 的字符串,读出来无法使用。所以要存前 JSON.stringify,读后 JSON.parse。
转写稿还提到:读取不存在的 key 时得到 null,JSON.parse(null) 返回 null,不会直接报错,可据此在初始化时兜底为空数组。常见错误是忘记 JSON.stringify,刷新后出现 todo.map is not a function。遇到这类报错,先看 Application 里 localStorage 的值是否正确;练习时可清掉对应存储再刷新。易错点是存储值格式错误后仍继续用数组方法操作。
课堂画面





代码示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>ref 获取真实 DOM</title>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel" data-presets="react">
class RefDemo extends React.Component {
constructor(props) {
super(props);
// 1. 创建 ref 对象
this.inputRef = React.createRef();
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
// 3. 通过 ref 对象的 current 属性获取真实 DOM
alert('输入框的值:' + this.inputRef.current.value);
this.inputRef.current.focus();
}
render() {
return (
<div>
{/* 2. 给 jsx 元素绑定 ref 属性 */}
<input ref={this.inputRef} placeholder="请输入内容" />
<button onClick={this.handleClick}>获取真实 DOM 值</button>
</div>
);
}
}
ReactDOM.render(<RefDemo />, document.getElementById('root'));
</script>
</body>
</html>要点解释:
React.createRef()创建 ref 对象。<input ref={this.inputRef} />把 ref 绑定到真实 DOM 元素上。this.inputRef.current才是真实 DOM,不能写成this.inputRef.value。current在组件挂载后才有值,事件回调里使用最稳妥。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>TodoList 类组件实现</title>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel" data-presets="react">
class TodoList extends React.Component {
constructor(props) {
super(props);
this.state = {
todos: [
{ id: 1, title: '吃饭', isDone: false },
{ id: 2, title: '睡觉', isDone: true }
]
};
this.addTodo = this.addTodo.bind(this);
this.checkOne = this.checkOne.bind(this);
this.checkAll = this.checkAll.bind(this);
this.deleteDone = this.deleteDone.bind(this);
}
componentDidMount() {
const raw = localStorage.getItem('todos');
if (!raw) return;
try {
const parsed = JSON.parse(raw);
// 防止本地存储数据被误存成非数组,导致 todos.map is not a function
if (Array.isArray(parsed)) {
this.setState({ todos: parsed });
} else {
this.setState({ todos: [] });
}
} catch (e) {
this.setState({ todos: [] });
}
}
componentDidUpdate() {
// localStorage 只能存字符串,所以要 JSON.stringify
localStorage.setItem('todos', JSON.stringify(this.state.todos));
}
addTodo(title) {
const text = title.trim();
if (!text) return;
const todo = {
id: Date.now(),
title: text,
isDone: false
};
this.setState({
todos: [...this.state.todos, todo]
});
}
checkOne(id) {
// 用 map 返回新数组,不能直接修改 state 里的数组
this.setState(prevState => ({
todos: prevState.todos.map(todo =>
todo.id === id ? { ...todo, isDone: !todo.isDone } : todo
)
}));
}
checkAll(isDone) {
// 把传入的选中状态赋值给每一条 todo
this.setState(prevState => ({
todos: prevState.todos.map(todo => ({
...todo,
isDone
}))
}));
}
deleteDone() {
if (!window.confirm('确定删除已完成?')) return;
// filter 留下未完成的,即删除已完成的
this.setState(prevState => ({
todos: prevState.todos.filter(todo => !todo.isDone)
}));
}
render() {
const { todos } = this.state;
const total = todos.length;
const done = todos.filter(todo => todo.isDone).length;
return (
<div style={{ padding: 20 }}>
<h2>TodoList</h2>
<TodoInput onAdd={this.addTodo} />
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
checkOne={this.checkOne}
/>
))}
</ul>
<TodoFooter
total={total}
done={done}
checkAll={this.checkAll}
deleteDone={this.deleteDone}
/>
</div>
);
}
}
class TodoInput extends React.Component {
constructor(props) {
super(props);
this.inputRef = React.createRef();
this.handleKeyDown = this.handleKeyDown.bind(this);
}
handleKeyDown(e) {
if (e.key === 'Enter' || e.keyCode === 13) {
this.props.onAdd(this.inputRef.current.value);
this.inputRef.current.value = '';
}
}
render() {
return (
<input
ref={this.inputRef}
onKeyDown={this.handleKeyDown}
placeholder="输入待办事项,按回车添加"
/>
);
}
}
class TodoItem extends React.Component {
render() {
const { todo, checkOne } = this.props;
return (
<li>
<input
type="checkbox"
checked={todo.isDone}
onChange={() => checkOne(todo.id)}
/>
<span
style={{
textDecoration: todo.isDone ? 'line-through' : 'none'
}}
>
{todo.title}
</span>
</li>
);
}
}
class TodoFooter extends React.Component {
render() {
const { total, done, checkAll, deleteDone } = this.props;
return (
<div>
<label>
<input
type="checkbox"
checked={total > 0 && done === total}
onChange={e => checkAll(e.target.checked)}
/>
全选/全不选
</label>
<span style={{ marginLeft: 12 }}>
已完成 {done} / 全部 {total}
</span>
<button onClick={deleteDone} style={{ marginLeft: 12 }}>
清除已完成
</button>
</div>
);
}
}
ReactDOM.render(<TodoList />, document.getElementById('root'));
</script>
</body>
</html>要点解释:
todos数据定义在父组件TodoList中,子组件通过 props 接收,属于“状态提升”。checkOne(id)用map找到对应 id 的 todo,并取反isDone。checkAll(isDone)用map把所有 todo 的isDone改成传入的布尔值。- 全选框
checked={total > 0 && done === total}必须加total > 0,否则0 === 0时会被误选中。 deleteDone()用filter(todo => !todo.isDone)留下未完成项,删除已完成项,并用window.confirm做提示。- 本地存储只能存字符串,写入用
JSON.stringify,读取用JSON.parse。 - 读取本地存储后要判断
Array.isArray(parsed),否则坏数据会导致todos.map is not a function。 - 复选框受控时
checked和onChange要配套使用,不能只写checked。
常见错误
点复选框没反应,或整行状态不跟着变 现象:勾选某一条,界面不动;控制台没有报错。 原因:
onChange={this.props.checkOne(item.id)}在渲染时就执行了,事件绑定的其实是返回值;或者方法从爷→父→子透传时中间层没接收、没bind(this)。 修法:写成onChange={() => this.props.checkOne(item.id)};顺着 props 链逐层检查“定义处 → 中间商 → 使用者”三处是否都对得上。复选框变成“只读”,怎么点都弹回原状 现象:点了会闪一下又恢复,控制台提示受控组件缺少 onChange。 原因:给了
checked={...}却没绑onChange(或把checked写成了value)。 修法:checkbox 的checked与onChange必须成对出现,回调里用e.target.checked取最新布尔值,再向上传。列表清空后“全选”仍然显示为勾选 现象:一条数据都没有时,全选框是打勾状态。 原因:判断条件只写了
doneCount === total,0 === 0为真。 修法:补上非空判断,如total > 0 && doneCount === total。刷新后白屏,报
todos.map is not a function现象:一刷新就报一堆“map / filter / reduce 不是函数”的错。 原因:写入时漏了JSON.stringify,存进去的是[object Object]之类的字符串,JSON.parse还原不回来,读出来不是数组。 修法:写入一律JSON.stringify,读取JSON.parse后用Array.isArray(x) ? x : []兜底;开发阶段先到 Application → Local Storage 删掉对应 key 再刷新,能立刻定位是不是脏数据。“清除已完成”删错对象或点了没反应 现象:要么把未完成的删了,要么怎么点都还在。 原因:
filter条件写反(留下了isDone === true的);或误用splice/push原地改 state,引用没变导致不重渲染。 修法:filter(todo => !todo.isDone)得到新数组再setState;确认框用window.confirm(直接写confirm在部分环境下会报未定义)。改了单条数据,页面偶发不更新或“回滚” 现象:
map里改了item.isDone再return item,有时生效有时不生效。 原因:返回的是原对象,新旧 state 引用相同,且污染了旧 state,后续做对比/回退时出错。 修法:return { ...todo, isDone: 新值 },始终保持“不可变更新”。this.refObj.current拿到 null 现象:在render里读current得到null,或报Cannot read property 'value' of null。 原因:DOM 还没挂载就去读了;或把 ref 挂到了函数组件上。 修法:只在事件回调 /componentDidMount之后读current;函数组件需要转发 ref。
练习
- 双击编辑标题:给单条待办加“双击变输入框”,用 ref 在进入编辑态时自动聚焦;回车保存、Esc 取消、失焦保存。要求改完的数据仍走“子传父”,由持有
todos的组件统一setState,并说明为什么不能由子组件自己改。 - footer 的显示策略:把已完成数量、全选、清除已完成放到 footer,实现“列表为空时整个 footer 不渲染”“全部完成时全选框自动勾上”“清空后不残留勾选状态”。写出你判断空列表、全选的两个表达式。
- 持久化封装:抽出
loadTodos()/saveTodos(list)两个函数,要求:首次访问返回空数组;本地值是null、非法 JSON、非数组时都能兜底;saveTodos在增/删/改/勾选后都要被调用。故意把一处JSON.stringify删掉复现报错,再说明排查路径。 - 数组方法辨析:用
map/filter/splice三种写法各实现一次“切换某条完成状态”,运行对比效果,写出splice版本会出现什么问题、为什么本项目统一用前两者。 - ref vs 受控组件:分别用 ref 取值和受控组件两种方式实现“输入框回车添加待办”,比较代码量、能否做输入校验、能否一键清空输入框,给出你的选型结论。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | checkOne改变完成状态 | 07:33 |
| 2 | checkAll | 07:45 |
| 3 | 删除已完成 | 03:58 |
| 4 | localStorage-JSON.stringify-parse复习 | 10:58 |
| 5 | 本地存储数据错误导致的数组方法报错及解决方案 | 02:58 |
小结
- 本组件的“根”只有一个:
todos数组放在共同父级,其余组件只负责展示和“向上报告”,数据在哪,改数据的方法就定义在哪。 - 所有交互都是同一套模板:描述结果 → 拆步骤 → 匹配技术点 → 落到“子传父 + setState”。复选框、全选、清除已完成没有本质区别。
- 子传父传什么由父组件需要什么决定:切换状态只传
id就够(父自己能查、能取反);全选要传最新布尔值;清除已完成什么都不用传。 - 事件绑定的参数一定要用箭头函数包一层,否则渲染期就执行;
checked和onChange必须成对,这是受控组件的硬约束。 - 更新 state 一律产出新数组/新对象(
map里展开、filter取子集),不要原地push/splice,也不要直接改 item 后返回原引用。 - 判断条件要考虑边界:
0 === 0会让空列表误显示为全选,凡是“相等即真”的逻辑都要问一句“空的时候对吗”。 - localStorage 只能存字符串,对象数组必须
JSON.stringify/JSON.parse成对使用;脏数据是“数组方法报错”的头号嫌疑,先清 key 再刷新是最快的定位手段。 - 写这类列表页的熟练度,最终取决于两件事:数组方法用得顺不顺手,以及 props 逐层透传时链路清不清楚。