主题
useState与useEffect(Day7 1/3)
本页对应 React + TypeScript + 医通项目,共 7 段视频,约 24 分钟。
学习目标
- 能将 TodoList 相关类组件改造为函数组件,并完成静态布局渲染,刷新页面后布局与改造前一致且控制台无报错。
- 能使用
useState维护todos状态,独立实现添加 Todo、删除 Todo、修改单条完成状态、删除已完成项、全选/反选功能,并通过页面操作验证结果正确。 - 能按照“数据在父组件、方法定义在父组件”的原则,通过 props 完成父子组件间方法与数据的传递,实现 Header、Main、Footer、Item 与 TodoList 的联动。
- 能使用
useEffect从localStorage读取 Todo 数据并在todos更新时同步写回,刷新页面后数据仍能正确恢复。 - 能使用
useRef区分首次挂载与后续更新,避免初始化阶段重复写入本地存储,并通过刷新和增删改操作验证持久化逻辑正确。 - 能定位并修复组件改造和功能实现中的常见问题,如误自动导入依赖、事件对象取值错误、受控复选框状态处理错误、
map/filter使用不当等。
知识点
为什么函数组件需要 useState
为什么需要:函数组件本身没有类组件那样的实例,也没有 this.state,如果只写普通变量,变量变了页面不会自动更新。转写稿里把 Todolist 的 class 组件改成 function 组件后,状态数据仍然要驱动页面,所以需要 useState 让函数组件拥有状态。
怎么想:状态数据的变化要能触发函数组件重新调用、重新渲染,因此不能只改普通 let 变量,而要用 useState 提供的设置状态方法。
怎么做:先从 React 导入 useState,再在函数组件内部调用它创建状态。转写稿里也强调函数组件里没有 this,父组件把方法通过 props 传给子组件时,不需要考虑 this 绑定问题。
易错点:不要以为普通变量改了页面就会更新;函数组件里不要写 this.state;自动补全导入的包要检查,转写稿里就出现过误按回车自动导入 request 导致报错,发现不是自己写的组件或库要删掉。
useState 的基本语法与返回值
为什么需要:要知道 useState 返回什么,才能正确解构状态数据和设置状态的方法。
怎么想:useState(初始值) 返回一个数组,第一项是状态数据,第二项是设置状态的方法,所以用数组解构接收。
怎么做:
js
let [状态数据,设置状态的方法] = useState(初始值);
// 例如:
let [count, setCount] = useState(100)
setCount(要设置的count的最新的值)
// 例如:
setCount(count + 1)笔记里的完整示例还打印了返回值:
jsx
import React from 'react'
// 1. 导入useState函数
import { useState } from 'react'
export default function TestState() {
// 2. 使用useState创建状态数据
/**
* let [状态变量,设置状态的方法] = useState(初始值)
*/
// let res = useState(100);
// console.log('res: ', res); // [100, ƒ]
let [count, setCount] = useState(99);
return (
<div>
<p>state count : {count}</p>
<p><button onClick={()=>{
// setCount(要设置的count的最新的值)
setCount(count+1)
}}>count++</button></p>
</div>
)
}易错点:初始值只在首次渲染时使用;解构顺序不要写反,setCount 是函数,不是直接给 count 赋值;不要把 setCount 当成普通变量使用。
setCount 调用后发生什么
为什么需要:理解 setCount 的更新机制,才能知道为什么页面会变。
怎么想:setCount 不是普通赋值,它调用后会发生两件事:第一,count 状态数据的值改变;第二,触发函数组件的重新调用,完成重新渲染,页面更新。
怎么做:在按钮点击事件里写 setCount(count + 1),示例中 count 从 99 变成 100,页面显示随之更新。
易错点:不要直接写 count++ 后指望页面更新;更新数组状态时也不要直接改原数组。转写稿里添加 todo 时,是通过解构原来的 todos 再放入新 todo,再用 setTodos 设置回去;删除用 filter,修改单条状态用 map,都是把新数组交给设置状态的方法。转写稿里的“土度/土豆”就是 todo,“sda”就是 setTodos,“siler”就是 filter。
完整示例与调试过程
为什么需要:只看语法容易漏掉事件绑定和刷新验证。
怎么想:先写最小示例,点击按钮验证 setCount 是否触发重新渲染;再把同样思路放到 Todolist 中。
怎么做:笔记示例中按钮 onClick 调用 setCount(count+1),页面显示 state count : 99,点击后变 100。转写稿的调试过程是:先把类组件改成函数组件,去掉 constructor、render 等,刷新检查布局是否正常;然后在父组件 Todolist 中定义 addTodo、deleteTodo、checkOne 等方法,通过 props 传给子组件;子组件触发方法时传标题或 id,父组件用 setTodos 更新状态。本地存储部分,转写稿把初始值设为空数组,从 localStorage 读取后 JSON.parse,再用 setTodos 重置;写入时用 JSON.stringify。刷新后状态会回到初始值,因为代码重新运行会重新读取初始值。
易错点:刷新页面后普通状态会重置,需要持久化时要读写本地存储;JSON.parse 和 JSON.stringify 不要写反;自动导入的无关包要删掉;函数组件没有 this,父子传方法不需要绑定 this。
本节要解决的问题
函数组件没有 componentDidMount、componentDidUpdate、componentWillUnmount 这些类组件生命周期方法,但真实项目里仍然需要“挂载后请求数据”“更新后同步本地存储”“卸载前清理定时器”等副作用。useEffect 的作用就是在函数组件中模拟这些生命周期。
- 为什么需要:把副作用从渲染逻辑中分离出来,让函数组件也能处理挂载、更新、卸载时机。
- 怎么想:先判断这段代码应该在“每次渲染后”“只挂载后”“某些依赖变化后”“卸载前”执行,再选择
useEffect的第二个参数和返回值。 - 怎么做:从
react解构useEffect,在组件顶层调用;需要清理时返回一个函数。 - 易错点:
useEffect不是“生命周期本身”,而是副作用钩子;依赖数组是浅比较;不要在条件语句里调用 Hook。
用法一:不传第二个参数
useEffect(回调函数) 对应 componentDidMount + componentDidUpdate,即每次渲染后都执行。
- 为什么需要:有些副作用不关心具体是哪个状态变化,只要组件渲染后都要同步。
- 怎么想:如果第二个参数完全不写,React 会在每次渲染后执行回调。
- 怎么做:
useEffect(() => { console.log('没有第二个参数') })。 - 易错点:回调里如果无条件
setState,可能造成反复渲染;要明确自己是否真的需要“每次更新都执行”。
用法二:第二个参数是空数组
useEffect(回调函数, []) 对应 componentDidMount,只在挂载后执行一次。
- 为什么需要:初始化请求、读取本地存储、绑定一次性事件等,只应在挂载时做一次。
- 怎么想:空数组表示“没有依赖项”,React 不会因为更新而再次执行。
- 怎么做:
useEffect(() => { console.log('componentDidMount') }, [])。 - 易错点:空数组不是“什么都不监听所以每次执行”,而是“只执行一次”;不要把需要随状态更新的逻辑误放进去。
用法三:第二个参数写依赖项
useEffect(回调函数, [count]) 对应 componentDidMount + componentDidUpdate[count],即挂载后执行,并且 count 变化后再次执行。
- 为什么需要:只关心某些 state 或 props 的变化,避免无关更新触发副作用。
- 怎么想:把回调中用到的外部变量放进依赖数组;数组里写谁,就监听谁。
- 怎么做:
useEffect(() => { console.log('9999') }, [count])。 - 易错点:依赖数组不是“state 和 props 的统称”,要写具体变量;漏写依赖可能读到旧值,多写会增多执行次数。
用法四:返回清理函数
jsx
useEffect(() => {
return () => {
// componentWillUnmount
}
})- 为什么需要:组件卸载前要清理定时器、取消订阅、移除监听等。
- 怎么想:
useEffect的回调可以返回一个函数,React 会在合适时机调用它。 - 怎么做:在回调中
return () => { ... }。 - 易错点:清理函数不是“组件卸载时才一定执行一次”这么简单;依赖变化导致 effect 重新执行前,也会先执行上一次的清理函数。笔记中
Son.jsx用空数组的 effect 演示了挂载与卸载。
同一个组件可以有多个 useEffect
笔记特别提醒:同一个函数组件可以有多个 useEffect,并且都生效。
- 为什么需要:把不同副作用拆开,职责更清晰。
- 怎么想:读取数据、同步存储、绑定事件可以分别写在不同的
useEffect中。 - 怎么做:在组件顶层写多个
useEffect,每个有自己的依赖数组和清理逻辑。 - 易错点:不要为了“合并”把无关逻辑塞进一个 effect;多个 effect 会按书写顺序执行。
用转写稿补:本地存储的推导与调试
转写稿中把 useEffect 口误成“user effect”,实际就是 useEffect;把 componentDidMount 说成“componentde mount”,把 componentDidUpdate 说成“data update / deupate / DW”,把 useRef 说成“柚子REF”,把 JSON.stringify 说成“str five”,这些都要按真实 API 理解。
- 为什么需要:刷新页面后 todos 丢失,因为代码重新运行会读取初始值。
- 怎么想:挂载时从
localStorage读取 todos;todos 更新时再写回localStorage。这分别对应componentDidMount和针对 todos 的componentDidUpdate。 - 怎么做:挂载 effect 中
JSON.parse(localStorage.getItem('todos')) || []后setTodos;更新 effect 中localStorage.setItem('todos', JSON.stringify(todos))。转写稿还提到用useRef(true)排除首次挂载,避免挂载阶段立刻写入覆盖。 - 易错点:
JSON.parse前要处理空值;更新 effect 的依赖要写todos;排除首次挂载时注意 ref 的current判断。
App.jsx
jsx
// 1. 从react中解构 useEffect
import React, { useEffect, useState } from 'react'
import Son from './components/Son';
export default function App() {
console.log('App run');
let [count, setCount] = useState(100)
let [msg, setMsg] = useState('atguigu')
// 1. 组件挂载阶段生命周期
// useEffect(() => {
// console.log('componentDidMount');
// }, [])
// 2. // componentDidMount + componentDidUpdate[count]
// 一个函数组件可以有多个useEffect并且都生效
// useEffect(() => {
// console.log('9999');
// }, [count])
// 3. componentDidMount + componentDidUpdate[所有的state,和所有的props]
// useEffect(() => {
// console.log('没有第二个参数')
// });
return (
<div>
<p>count: {count}</p>
<p>msg: {msg}</p>
<p>
<button onClick={() => {
setCount(count + 1)
}}>count</button>
</p>
<p>
<button onClick={() => {
setMsg(msg + '!')
}}>msg</button>
</p>
<hr />
{count % 2 === 0 && <Son count={count} />}
</div>
)
}Son.jsx
jsx
import React from 'react'
import { useEffect } from 'react'
export default function Son({ count }) {
// useEffect(()=>{ // componentDidMount + componentDidUpdate[all state,all props]
// console.log('son 111')
// })
// 4. componentWillUnmount
useEffect(() => {
console.log('son componentDidMount')
return () => {
// componentWillUnMount
console.log('son will unmount')
}
}, [])
useEffect(()=>{
// componentDidMount + componentDidUpdate
console.log('son componentDidMount + son componentDidUpdate')
return ()=>{// componentWillUnMount
console.log('son will unmount2')
}
})
return (
<div>
<p>son props count: {count}</p>
</div>
)
}useRef 是什么:先建立一个“跨渲染的盒子”
函数组件每次渲染都会重新执行函数体,普通变量会重新创建;而 useRef 返回一个在多次渲染间保持稳定的 ref 对象,结构是 { current: 初始值 }。你可以把它理解成一个盒子:盒子本身不变,里面的 current 可以随时读写。它最常见的两个用途,就是本节笔记里的:获取真实 DOM、单独模拟 componentDidUpdate。
为什么需要它?类组件里可以用实例属性保存跨渲染数据,函数组件没有 this;同时,有些值变化不需要触发页面重新渲染,比如保存 DOM 节点、记录“是不是第一次执行 effect”。这时用 useRef 比用 useState 更合适。
怎么想?先判断这个值要不要参与渲染:要显示到页面上的,用 state;只是保存节点或做流程标记的,用 ref。怎么做?先创建 ref,再在需要的地方读写 .current。易错点是:useRef() 不传参时 current 是 undefined;修改 ref.current 不会触发组件重新渲染,所以不要拿它替代 state 去驱动页面。
作用一:获取真实 DOM
为什么需要?函数组件里想直接操作输入框、让输入框聚焦、读取真实节点时,需要拿到 DOM 引用。怎么想?React 不推荐到处直接查 DOM,而是用 ref 把节点“接”过来。怎么做?按笔记三步:
jsx
const inputRef = useRef();
<input ref={inputRef} />
// 在挂载后或事件回调中获取
inputRef.current完整写法可以理解为:
jsx
import { useRef } from 'react';
function Demo() {
const inputRef = useRef();
const focusInput = () => {
inputRef.current.focus();
};
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>聚焦</button>
</>
);
}易错点:第一,创建 ref 后必须绑定到 DOM 的 ref 属性上;第二,不要在组件渲染过程中直接读 inputRef.current 并假设它已经有值,首次渲染时节点还没挂载;第三,inputRef.current 拿到的是真实 DOM 节点,不是 React 元素。
作用二:单独模拟 componentDidUpdate
为什么需要?useEffect 默认会在挂载后和更新后都执行。但有些逻辑只想在更新时做,比如转写稿里的本地存储:首次挂载时要从 localStorage 读取数据,后续 todos 更新时才把新数据写回 localStorage。如果更新 effect 首次也执行,就可能把初始空数组写回去,逻辑就不对了。
怎么想?用一个 ref 记录“是不是第一次”。初始为 true,第一次执行 effect 时把它改成 false 并 return,后续再进来就说明不是首次了。怎么做?笔记代码是:
jsx
const flagRef = useRef(true);
useEffect(() => {
if (flagRef.current) {
flagRef.current = false;
return;
}
// 以下代码只会在 componentDidUpdate 时运行
});转写稿里的调试过程是:刷新页面后数据变回初始值,原因是刷新后代码重新运行,会重新读取初始值;于是把读取 localStorage 放在挂载阶段,把写入 localStorage 放在更新阶段。写入时用 JSON.stringify(todos),读取时用 JSON.parse(...),没有数据就给空数组。为了让“写入”跳过首次执行,就创建 flagRef = useRef(true),在 effect 里判断 flagRef.current,首次改成 false 并返回,后面的代码才执行。
易错点:第一,flagRef 的初始值要设成 true,否则第一次就被当成更新;第二,首次分支里要 return,不要把更新逻辑继续执行下去;第三,笔记里的 useEffect 没有写依赖数组,实际项目中要结合场景考虑依赖,但不要为了“看起来高级”乱加;第四,ref 改值不会触发渲染,这里只是流程标记,不要指望它更新页面。
课堂画面






代码示例
示例 1:useState + useEffect(函数组件的状态与生命周期模拟)
jsx
// App.jsx
import React, { useEffect, useState } from 'react'
function Son({ count }) {
// 只模拟 componentWillUnmount:依赖数组为 []
useEffect(() => {
console.log('son componentDidMount')
return () => {
// componentWillUnMount
console.log('son will unmount')
}
}, [])
// componentDidMount + componentDidUpdate(不写第二个参数)
useEffect(() => {
console.log('son componentDidMount + son componentDidUpdate')
return () => {
// componentWillUnMount
console.log('son will unmount2')
}
})
return (
<div>
<p>son props count: {count}</p>
</div>
)
}
export default function App() {
console.log('App run')
// 1. 使用 useState 创建状态数据
let [count, setCount] = useState(99)
let [msg, setMsg] = useState('atguigu')
// 1. componentDidMount
useEffect(() => {
console.log('componentDidMount')
}, [])
// 2. componentDidMount + componentDidUpdate[count]
useEffect(() => {
console.log('9999')
}, [count])
// 3. componentDidMount + componentDidUpdate[所有 state 和所有 props]
useEffect(() => {
console.log('没有第二个参数')
})
return (
<div>
<p>state count : {count}</p>
<p>msg: {msg}</p>
<p>
<button onClick={() => {
// setCount(要设置的 count 的最新的值)
setCount(count + 1)
}}>count++</button>
</p>
<p>
<button onClick={() => {
setMsg(msg + '!')
}}>msg</button>
</p>
<hr />
{count % 2 === 0 && <Son count={count} />}
</div>
)
}要点解释 / 易错点
let [状态数据, 设置状态的方法] = useState(初始值):useState 返回的是数组,直接解构即可;res打印出来是[100, ƒ]。setCount(count + 1)调用后做两件事:① 改变 count 的值;② 触发函数组件重新调用(重新渲染),页面更新。- 初始值只在第一次渲染时使用,组件重新渲染不会把 count 重置回 99。
- useEffect 第二个参数决定执行时机:
[]只在挂载时执行;[count]挂载时 + count 变化时执行;不写则每次渲染都执行。 - 同一个函数组件里可以写多个 useEffect,都生效,按书写顺序依次执行。
return () => {}是清理函数:依赖数组为[]时只在卸载时执行;不写第二个参数时,每次重新执行前都会先执行上一次的清理函数。- 易错点:
count % 2 === 0 && <Son />会让 Son 反复挂载 / 卸载,控制台能看到son will unmount与son componentDidMount交替出现,这是验证卸载生命周期的好办法。 - 易错点:不要只写
useState(99)却忘了接收返回值,let [count, setCount]少写中括号或漏写 setCount 都会直接报错。
示例 2:useRef(获取真实 DOM + 单独模拟 componentDidUpdate)
jsx
// TestRef.jsx
import React, { useEffect, useRef, useState } from 'react'
export default function TestRef() {
// 1. 创建 ref 对象
const inputRef = useRef()
const flagRef = useRef(true)
const [count, setCount] = useState(0)
// 2. 单独模拟 componentDidUpdate
useEffect(() => {
if (flagRef.current) {
flagRef.current = false
return
}
// 以下代码只会在 componentDidUpdate 时运行
console.log('componentDidUpdate, count =', count)
})
return (
<div>
<p>count: {count}</p>
{/* 2. 绑定 ref */}
<input ref={inputRef} placeholder="请输入内容" />
<p>
{/* 3. 获取:inputRef.current */}
<button onClick={() => console.log(inputRef.current.value)}>
获取输入框的值
</button>
<button onClick={() => inputRef.current.focus()}>
让输入框聚焦
</button>
<button onClick={() => setCount(count + 1)}>
count++
</button>
</p>
</div>
)
}要点解释 / 易错点
- 获取真实 DOM 的三步:①
const inputRef = useRef()创建对象;② 标签上ref={inputRef}绑定;③ 通过inputRef.current拿到真实 DOM。 - 模拟 componentDidUpdate 的两步:①
const flagRef = useRef(true)给初始值;② 在 effect 里先判断flagRef.current,第一次(挂载)进来就把它改成 false 并return,之后执行到下面代码的一定是更新阶段。 - 修改
ref.current不会触发重新渲染,所以用 flagRef 做“是否第一次”的标记是安全的,不会引起死循环。 - 易错点:这个 effect 一定不能写第二个参数
[],否则只会执行一次,就退化成 componentDidMount,永远进不了 componentDidUpdate 的逻辑。 - 易错点:不要在 render 阶段直接读
inputRef.current.value,首次渲染时 DOM 还没挂上,inputRef.current是 null;要放在事件回调或 useEffect 里读。 - 易错点:
useRef()不传参数时.current初始是 undefined,不是空字符串,拿它做判断时注意区分。
示例 3:TodoList 函数组件(增删改、全选反选、本地存储持久化)
jsx
// ToDoList.jsx (npm i nanoid)
import React, { useEffect, useRef, useState } from 'react'
import { nanoid } from 'nanoid'
function Header({ addTodo }) {
function handleKeyUp(e) {
// 1. 判断是否回车
if (e.keyCode !== 13) return
// 2. 获取用户输入内容并去掉左右空格
let title = e.target.value.trim()
if (!title) return
// 3. 调用父组件传过来的方法,把数据传给父组件
addTodo(title)
// 4. 清空文本框
e.target.value = ''
}
return (
<input
type="text"
placeholder="请输入你的任务名称,按回车键确认"
onKeyUp={handleKeyUp}
/>
)
}
function Item({ todo, deleteTodo, checkOne }) {
return (
<li>
<label>
<input
type="checkbox"
checked={todo.isDone}
onChange={() => checkOne(todo.id)}
/>
<span>{todo.title}</span>
</label>
<button onClick={() => deleteTodo(todo.id)}>删除</button>
</li>
)
}
function List({ todos, deleteTodo, checkOne }) {
return (
<ul>
{todos.map(todo => (
<Item
key={todo.id}
todo={todo}
deleteTodo={deleteTodo}
checkOne={checkOne}
/>
))}
</ul>
)
}
function Footer({ deleteDone, checkAll }) {
return (
<div>
<input
type="checkbox"
onChange={(e) => checkAll(e.target.checked)}
/>
<button onClick={deleteDone}>清除已完成</button>
</div>
)
}
export default function ToDoList() {
let [todos, setTodos] = useState([])
const flagRef = useRef(true)
// componentDidMount:从 localStorage 读取数据
useEffect(() => {
let todos = JSON.parse(localStorage.getItem('todos')) || []
setTodos(todos)
}, [])
// 只模拟 componentDidUpdate[todos]:重写本地存储
useEffect(() => {
if (flagRef.current) {
flagRef.current = false
return
}
localStorage.setItem('todos', JSON.stringify(todos))
})
// 添加 todo
function addTodo(title) {
let todo = {
id: nanoid(),
title: title,
isDone: false
}
setTodos([todo, ...todos])
}
// 删除 todo
function deleteTodo(id) {
if (!window.confirm('确定要删除吗?')) return
setTodos(todos.filter(todo => todo.id !== id))
}
// 修改单条 todo 的状态
function checkOne(id) {
setTodos(todos.map(todo => {
if (todo.id === id) {
todo.isDone = !todo.isDone
}
return todo
}))
}
// 删除已完成
function deleteDone() {
if (!window.confirm('确定要删除已完成的任务吗?')) return
setTodos(todos.filter(todo => !todo.isDone))
}
// 全选 / 反选
function checkAll(isDone) {
setTodos(todos.map(todo => {
todo.isDone = isDone
return todo
}))
}
return (
<div>
<Header addTodo={addTodo} />
<List todos={todos} deleteTodo={deleteTodo} checkOne={checkOne} />
<Footer deleteDone={deleteDone} checkAll={checkAll} />
</div>
)
}要点解释 / 易错点
- 类组件改函数组件:去掉
class Xxx extends React.Component、render()和this,写成function Xxx(props) {}直接 return JSX;函数组件里没有 this,所以传方法不用this.addTodo.bind(this)。 - 子传父的要点:数据在哪儿,方法就定义在谁身上。todos 数据在 ToDoList 里,所以 addTodo / deleteTodo / checkOne / deleteDone / checkAll 全部定义在 ToDoList,再通过 props 一层层往下传。
- 删除用的是
filter:todo.id !== id保留下来,等价于把传进来的这条删掉;清除已完成是filter(todo => !todo.isDone),即只保留未完成的。 - 全选反选和改单条用的是
map:生成的新数组长度不变,只改其中的数据;全选时的值来自e.target.checked,由子组件传给父组件。 - 本地存储的两个 effect:挂载时用
JSON.parse(localStorage.getItem('todos'))读,更新时用localStorage.setItem('todos', JSON.stringify(todos))写;写之前用flagRef把 componentDidMount 排除掉,否则挂载时会把空数组覆盖回去。 - 易错点:
JSON.parse(localStorage.getItem('todos'))在本地没有这个 key 时得到 null,setTodos(null)之后todos.map会直接崩,必须|| []兜底。 - 易错点:给事件传参必须包箭头函数
() => deleteTodo(todo.id),写成onClick={deleteTodo(todo.id)}会在渲染时就立刻执行。 - 易错点:checkbox 要写成受控
checked={todo.isDone}+onChange,只写 onClick 或漏掉 onChange 会有告警且状态不同步。 - 易错点:转写稿中
checkOne/checkAll直接改todo.isDone(改的是原对象引用),更稳妥的写法是返回新对象{ ...todo, isDone: !todo.isDone },避免新旧数组里的元素指向同一个对象。 - 易错点:自动补全导入要当心,误按回车可能引入
request之类的第三方包导致整个项目报 module not found,发现不是自己写的、不是自己用的库要立刻删掉。
常见错误
1. 增删改查之后页面不动
- 现象:调用
setToDos前后console.log(toDos)打印内容确实变了,但列表没重新渲染;或者勾选、删除后数据偶尔“回退”。 - 原因:直接
toDos.push(obj)、toDos[i].isDone = !toDos[i].isDone、toDos.splice(...)原地改了数组或元素对象。React 判断是否重新渲染是拿新旧状态做引用比较,引用没变就认为没变化。 - 修法:一律产出新数组:追加用
[...toDos, newTodo]或[newTodo, ...toDos];删除用toDos.filter(...);改单条用toDos.map(t => t.id === id ? {...t, isDone: !t.isDone} : t);改全部用map覆盖isDone。注意map里改字段也要返回新对象,别直接t.isDone = x再return t。
2. setCount(count + 1) 连写两次只加 1,或定时器里永远拿到初始值
- 现象:一次事件里连调两次自增,结果只 +1;
setInterval里读count一直是 0。 - 原因:
count是本次渲染闭包里的快照常量,同一轮事件处理中不会变;useEffect空依赖数组里的回调捕获的也是首次渲染的count。 - 修法:需要基于“最新值”计算时用函数式更新
setCount(c => c + 1);需要跨渲染读取最新值时用useRef存一份,或把依赖写全。
3. 从 localStorage 恢复数据后页面白屏 / 报 Cannot read properties of null (reading 'map')
- 现象:首次打开页面就崩,控制台指向
toDos.map或toDos.filter。 - 原因:本地还没有这个 key,
localStorage.getItem返回null,JSON.parse(null)得到null,直接setToDos(null)后把状态当数组用。 - 修法:读取时兜底:
JSON.parse(localStorage.getItem('todos') || '[]'),或者const data = JSON.parse(...) || [];同时useState初始值给[],不要给null。
4. 一刷新,本地存的列表就被空数组覆盖了
- 现象:明明昨天勾选的数据还在,重新打开页面全没了;或第一次进入就发现 localStorage 里的 key 被写成了
[]。 - 原因:用
useEffect(() => { localStorage.setItem(...) })(不带第二个参数)想模拟componentDidUpdate,但它在挂载后也会执行一次,此时状态还是初始的[];恢复数据的 effect 又排在它后面,等于先清空再读。 - 修法:用
useRef(true)做首渲染跳过:if (flagRef.current) { flagRef.current = false; return }之后再写存储;或者更简单,把写存储的 effect 依赖写成[toDos]并接受首帧写入(此时先同步初始化状态)。
5. 父组件传方法给子组件,子组件一渲染就报错或死循环
- 现象:
Maximum update depth exceeded,或者点击按钮没反应。 - 原因:写成
onClick={addTodo()}、onChange={checkOne(todo.id)},在 JSX 里就把函数执行了,执行的是返回值;addTodo()里又调setToDos,渲染 → setState → 渲染,无限循环。 - 修法:不传参写
onClick={addTodo};要传参写onClick={() => deleteTodo(todo.id)}或onClick={(e) => checkAll(e.target.checked)},用箭头函数把调用包起来。
6. 全选/反选后 checkbox 状态和列表对不上
- 现象:点了全选,列表数据都变成 true 了,但全选框自己勾不上;或者单个取消后全选框还打着勾。
- 原因:取错了值——用
e.target.value拿 checkbox 的勾选状态(拿到的是字符串"on");或者只更新了数据,没有把全选框做成受控(checked由toDos.every(t => t.isDone)推导)。 - 修法:用
e.target.checked取布尔值再传给父组件;全选框写成受控:checked={toDos.length > 0 && toDos.every(t => t.isDone)},让数据成为唯一真相源。
7. 敲代码时项目突然整页报错,满屏 Module not found
- 现象:只是随手敲了个单词按了回车,项目直接编译失败,报某个陌生的包找不到。
- 原因:编辑器的自动导入把同名/相似名的第三方包(如
request、express里的东西)自动import进来了。 - 修法:报错先看第一条 import 语句,把不是自己写的、不是项目依赖的导入删掉;关掉或收紧编辑器的自动导入提示。
练习
练习 1(改错) 下面这段“删除已完成”的代码跑起来列表纹丝不动,找出问题并改对:
js
function deleteDone() {
toDos.forEach((t, i) => { if (t.isDone) toDos.splice(i, 1) })
setToDos(toDos)
}练习 2(函数式更新 + 前置插入) 用 addTodo(title) 实现新增,要求:新任务插到列表最前面、id 用 nanoid()、isDone 默认 false,并且在同一事件里连续调用两次 addTodo 也必须两条都进去。写出实现并说明为什么不能用 setToDos([newTodo, ...toDos]) 连写两次。
练习 3(useRef 模拟 componentDidUpdate) 把持久化改成:挂载时从 localStorage 读、只有 toDos 变化时才写回,且首帧绝不能写。用 useRef 实现,并说明如果把 flagRef.current 改成普通变量 let isFirst = true 会发生什么。
练习 4(受控全选)Footer 里的全选框目前只把 e.target.checked 传给了 checkAll,但勾选状态显示不对。补全:让全选框的勾选状态由 toDos 推导,做到“手动一个个勾满 → 全选框自动勾上;取消任意一条 → 全选框自动取消”。
练习 5(清理副作用) 给 TodoList 加一个“当前时间”显示,每秒更新一次。要求组件卸载后定时器必须停掉、控制台不再打印,用 useEffect + useRef 完成,并说明为什么清理函数里必须用 clearInterval 的返回值而不能重新 setInterval。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | todolist-类组件改函数组件静态布局 | 02:47 |
| 2 | 添加todo | 06:19 |
| 3 | 删除todo | 02:44 |
| 4 | checkOne修改单条数据的状态 | 02:18 |
| 5 | 删除已完成 | 02:33 |
| 6 | 全选反选 | 02:02 |
| 7 | 本地存储 | 05:58 |
小结
- 这一节真正的难点不在
useState/useEffect的语法,而在“状态是不可变的”这一条纪律:所有增删改必须落到filter/map/ 展开运算符产出的新数组上,否则页面不更新、逻辑还查不出错。 setXxx(value)里的value来自本次渲染的闭包快照;凡是要基于“上一个值”计算,一律换成setXxx(prev => ...),这一条同时解决了连点只加一次和定时器读到旧值两类问题。- 子传父的固定套路:数据在父、方法定义在父、方法以 prop 形式下传、子组件只负责“触发 + 带参回传”,且回传参数时事件回调必须用箭头函数包裹,否则就是渲染期执行。
useEffect的第二参数决定它扮演哪个生命周期:不写 ≈DidMount + DidUpdate(全部),[]≈DidMount,[x]≈DidMount + DidUpdate(x);返回函数 ≈WillUnmount。同一个组件里多个useEffect各管一摊,不要试图写一个万能 effect。- 想“只要 Update 不要 Mount”,React 没有现成钩子,标准解法就是
useRef标记首帧后return;同理,useRef存 DOM 时要用ref.current访问,ref属性本身拿不到元素。 - 持久化 = 读一次 + 写多次:读放在挂载阶段,写放在
toDos变化的更新阶段;顺序、兜底(|| '[]')、序列化(JSON.parse/JSON.stringify)三件事少一件就会白屏或数据丢失。 - 全选、反选、清除已完成、单条勾选看起来是四个功能,本质上都是“把数组整体重映射成新数组”,写熟
map和filter这两把工具,这一整块功能就只剩传参问题。 - 函数组件里没有
this,原来类组件里那些bind、this.setState的心智负担全部消失,但也意味着状态和方法的作用域全靠闭包和参数传递——出问题时优先怀疑“我拿到的是哪一次渲染的值”。