Skip to content

useState与useEffect(Day7 1/3) ​

本页对应 React + TypeScript + 医通项目,共 7 段视频,约 24 分钟。

学习目标 ​

  1. 能将 TodoList 相关类组件改造为函数组件,并完成静态布局渲染,刷新页面后布局与改造前一致且控制台无报错。
  2. 能使用 useState 维护 todos 状态,独立实现添加 Todo、删除 Todo、修改单条完成状态、删除已完成项、全选/反选功能,并通过页面操作验证结果正确。
  3. 能按照“数据在父组件、方法定义在父组件”的原则,通过 props 完成父子组件间方法与数据的传递,实现 Header、Main、Footer、Item 与 TodoList 的联动。
  4. 能使用 useEffect 从 localStorage 读取 Todo 数据并在 todos 更新时同步写回,刷新页面后数据仍能正确恢复。
  5. 能使用 useRef 区分首次挂载与后续更新,避免初始化阶段重复写入本地存储,并通过刷新和增删改操作验证持久化逻辑正确。
  6. 能定位并修复组件改造和功能实现中的常见问题,如误自动导入依赖、事件对象取值错误、受控复选框状态处理错误、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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 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。

原视频对照 ​

#视频时长
1todolist-类组件改函数组件静态布局02:47
2添加todo06:19
3删除todo02:44
4checkOne修改单条数据的状态02:18
5删除已完成02:33
6全选反选02:02
7本地存储05:58

小结 ​

  1. 这一节真正的难点不在 useState/useEffect 的语法,而在“状态是不可变的”这一条纪律:所有增删改必须落到 filter / map / 展开运算符产出的新数组上,否则页面不更新、逻辑还查不出错。
  2. setXxx(value) 里的 value 来自本次渲染的闭包快照;凡是要基于“上一个值”计算,一律换成 setXxx(prev => ...),这一条同时解决了连点只加一次和定时器读到旧值两类问题。
  3. 子传父的固定套路:数据在父、方法定义在父、方法以 prop 形式下传、子组件只负责“触发 + 带参回传”,且回传参数时事件回调必须用箭头函数包裹,否则就是渲染期执行。
  4. useEffect 的第二参数决定它扮演哪个生命周期:不写 ≈ DidMount + DidUpdate(全部),[] ≈ DidMount,[x] ≈ DidMount + DidUpdate(x);返回函数 ≈ WillUnmount。同一个组件里多个 useEffect 各管一摊,不要试图写一个万能 effect。
  5. 想“只要 Update 不要 Mount”,React 没有现成钩子,标准解法就是 useRef 标记首帧后 return;同理,useRef 存 DOM 时要用 ref.current 访问,ref 属性本身拿不到元素。
  6. 持久化 = 读一次 + 写多次:读放在挂载阶段,写放在 toDos 变化的更新阶段;顺序、兜底(|| '[]')、序列化(JSON.parse / JSON.stringify)三件事少一件就会白屏或数据丢失。
  7. 全选、反选、清除已完成、单条勾选看起来是四个功能,本质上都是“把数组整体重映射成新数组”,写熟 map 和 filter 这两把工具,这一整块功能就只剩传参问题。
  8. 函数组件里没有 this,原来类组件里那些 bind、this.setState 的心智负担全部消失,但也意味着状态和方法的作用域全靠闭包和参数传递——出问题时优先怀疑“我拿到的是哪一次渲染的值”。