React Todo 实战:useEffect 生命周期与 localStorage 持久化

React Todo 实战:useEffect 生命周期与 localStorage 持久化

  • 前言
  • [1. 项目功能与组件结构](#1. 项目功能与组件结构)
  • [2. 完整代码](#2. 完整代码)
    • [2.1 App.jsx](#2.1 App.jsx)
    • [2.2 TodoInput.jsx](#2.2 TodoInput.jsx)
    • [2.3 TodoList.jsx](#2.3 TodoList.jsx)
    • [2.4 TodoStats.jsx](#2.4 TodoStats.jsx)
  • [3. 函数组件生命周期:函数执行不等于副作用执行](#3. 函数组件生命周期:函数执行不等于副作用执行)
  • [4. `useEffect` 到底解决什么问题](#4. useEffect 到底解决什么问题)
  • [5. 四种依赖项写法](#5. 四种依赖项写法)
    • [5.1 `count`:挂载后执行,count 改变后再次执行](#5.1 [count]:挂载后执行,count 改变后再次执行)
    • [5.2 不传依赖数组:每次提交更新后执行](#5.2 不传依赖数组:每次提交更新后执行)
    • [5.3 `\[\]`:只在挂载后执行一次](#5.3 []:只在挂载后执行一次)
    • [5.4 `todos`:任务变化后持久化](#5.4 [todos]:任务变化后持久化)
  • [6. `localStorage` 懒初始化与数据持久化](#6. localStorage 懒初始化与数据持久化)
    • [6.1 为什么读取操作放在 `useState` 函数初始值中](#6.1 为什么读取操作放在 useState 函数初始值中)
    • [6.2 为什么不能在 `setTodos` 后立即保存旧数组](#6.2 为什么不能在 setTodos 后立即保存旧数组)
  • [7. Demo:挂载、定时器与卸载清理](#7. Demo:挂载、定时器与卸载清理)
    • [7.1 `count` 如何控制 Demo 的存在](#7.1 count 如何控制 Demo 的存在)
    • [7.2 为什么定时器必须清理](#7.2 为什么定时器必须清理)
  • [8. Todo 子组件如何参与生命周期更新](#8. Todo 子组件如何参与生命周期更新)
  • [9. 当前代码的改进版关键片段](#9. 当前代码的改进版关键片段)
  • [10. 常见误区](#10. 常见误区)
  • 总结

前言

本文基于一个之前讲过的Todo 项目作出更新,在这里贴出了完整代码,但只基于更新内容进行讲解,完整版看React 待办事项实战:从 Vite 初始化到组件化与单向数据流。

在本项目中useState 负责保存任务和计数器状态,组件负责根据状态返回界面;但有些工作并不属于"计算 JSX"本身,例如启动定时器、读取浏览器存储、把最新任务保存到 localStorage,这些都需要在渲染完成后与外部系统同步。

这正是 useEffect 要解决的问题。本文基于一个包含 TodoInputTodoListTodoStatsDemo 子组件的最新 Todo 示例,重点讲解 React 函数组件的生命周期过程:挂载、更新、卸载 ,以及不同依赖项数组对 useEffect 执行时机的影响。

文章会给出完整代码,同时指出当前实现中两个容易被忽略的问题:localStorage 没有数据时可能得到 null,以及在 setTodos 后立即保存 todos 时读取到的仍然是旧状态。

1. 项目功能与组件结构

当前项目的组件树如下:

text 复制代码
App
├── Demo       通过 useEffect 创建和清理定时器
├── TodoInput  管理输入框临时状态,并通过回调请求添加任务
├── TodoList   展示任务,报告勾选和删除操作
└── TodoStats  展示统计数据,报告清理操作

App 是状态所有者,负责保存 counttodos,并把数据和事件回调通过 props 传给子组件。子组件不能直接修改 App 的状态,只能调用父组件传下来的函数报告用户意图。

状态或函数 所属组件 作用
count App 控制计数器和 Demo 是否挂载
todos App 保存全部任务
inputValue TodoInput 保存输入框当前内容
addTodo App 创建并添加任务
toggleTodo App 切换任务完成状态
deleteTodo App 删除指定任务
clearCompleted App 清理所有已完成任务

2. 完整代码

2.1 App.jsx

下面是当前项目的核心 App.jsx。其中 Demo 组件用于演示卸载前的清理逻辑,四个 useEffect 分别演示不同依赖项数组的执行时机。

js 复制代码
import { useState, useEffect } from 'react';
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';
import TodoStats from './components/TodoStats';
import './App.css';

const Demo = () => {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval,is here');
    }, 1000);

    return () => {
      console.log('组件卸载前执行,做什么内存清理工作');
      clearInterval(interval);
    };
  }, []);

  return <>Demo</>;
};

const App = () => {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState(() => {
    return JSON.parse(localStorage.getItem('todos'));
  });

  useEffect(() => {
    console.log('挂载后执行');
    console.log('count改变也会执行');
  }, [count]);

  useEffect(() => {
    console.log('挂载后执行');
    console.log('每次更新都执行');
  });

  useEffect(() => {
    console.log('只会在挂载后执行');
  }, []);

  useEffect(() => {
    console.log('挂载后执行');
    console.log('todos 更新后执行');
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  console.log('组件函数运行,组件准备渲染');

  const countBy = () => {
    setCount(count + 1);
  };

  const addTodo = (text) => {
    if (text.trim() === '') return;

    setTodos([
      { id: +Date.now(), text, completed: false },
      ...todos,
    ]);

    // 这里读取到的 todos 仍然是更新前的旧快照
    localStorage.setItem('todos', JSON.stringify(todos));
  };

  const toggleTodo = (id) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id
          ? { ...todo, completed: !todo.completed }
          : todo,
      ),
    );
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  const clearCompleted = () => {
    setTodos(todos.filter((todo) => !todo.completed));
  };

  const activeCount = todos.filter((todo) => !todo.completed).length;
  const completedCount = todos.length - activeCount;

  return (
    <div>
      Count: {count}
      <button onClick={countBy}>count++</button>

      {count % 2 === 0 && <Demo />}

      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
};

export default App;

2.2 TodoInput.jsx

js 复制代码
import { useState } from 'react';

const TodoInput = ({ onAdd }) => {
  console.log(onAdd);
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = (event) => {
    event.preventDefault();
    onAdd(inputValue);
    setInputValue('');
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={(event) => setInputValue(event.target.value)}
        placeholder="What needs to be done?"
        autoFocus
      />
      <button type="submit">Add</button>
    </form>
  );
};

export default TodoInput;

2.3 TodoList.jsx

js 复制代码
const TodoList = ({ todos, onToggle, onDelete }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">No todos yet!</li>
      ) : (
        todos.map((todo) => (
          <li
            key={todo.id}
            className={todo.completed ? 'completed' : ''}
          >
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>删除</button>
          </li>
        ))
      )}
    </ul>
  );
};

export default TodoList;

2.4 TodoStats.jsx

js 复制代码
const TodoStats = ({
  total,
  active,
  completed,
  onClearCompleted,
}) => {
  return (
    <div className="todo-stats">
      <p>
        Total: {total} | Active: {active} | Completed: {completed}
      </p>

      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  );
};

export default TodoStats;

3. 函数组件生命周期:函数执行不等于副作用执行

React 函数组件的生命周期可以从三个阶段理解:

text 复制代码
挂载 Mount
  ↓
组件函数执行,React 生成并提交初始 UI
  ↓
更新 Update
  ↓
状态或 props 变化,组件函数重新执行
  ↓
卸载 Unmount
  ↓
组件从页面移除,执行 effect 返回的清理函数

需要区分两个概念:

  • 组件函数执行 :React 为了得到新的 JSX,会重新调用 AppDemo 函数。
  • 副作用执行useEffect 的回调会在渲染提交后执行,用于同步外部系统。

例如点击 count++ 时,App 会重新执行;但这并不意味着四个 useEffect 都按照同一种规则执行。每个 effect 的执行时机由它自己的依赖项数组决定。

4. useEffect 到底解决什么问题

渲染阶段应该尽量只做计算:读取状态、计算数据、返回 JSX。定时器、网络请求、浏览器存储和事件监听都属于与 React 外部世界交互的操作,称为副作用

useEffect 的基本结构是:

js 复制代码
useEffect(() => {
  // 渲染提交后执行的副作用

  return () => {
    // 下一次 effect 执行前,或组件卸载前执行的清理逻辑
  };
}, [依赖项]);

执行顺序不是"组件函数执行到 useEffect 就立刻执行回调",而是:

text 复制代码
组件函数执行
  ↓
React 得到 JSX
  ↓
React 提交 DOM 更新
  ↓
useEffect 回调执行

这样设计可以避免在渲染尚未完成时就修改外部系统。

5. 四种依赖项写法

5.1 [count]:挂载后执行,count 改变后再次执行

代码如下:

js 复制代码
useEffect(() => {
  console.log('挂载后执行');
  console.log('count改变也会执行');
}, [count]);

依赖数组中的 count 表示:这个 effect 依赖 count。因此它有两个特点:

  • 首次挂载并完成 DOM 提交后执行一次。
  • 后续渲染中,如果 count 与上一次不同,再执行一次。

点击按钮时,setCount(count + 1) 提交新状态,App 重新执行,count 变化,于是这个 effect 再次执行。修改 todos 不会触发它,因为依赖数组中没有 todos

操作 App 是否重新执行 [count] effect 是否执行
首次打开页面
点击 count++
添加 Todo 否,除非 count 同时变化

5.2 不传依赖数组:每次提交更新后执行

js 复制代码
useEffect(() => {
  console.log('挂载后执行');
  console.log('每次更新都执行');
});

没有第二个参数时,effect 会在每次渲染提交后执行:首次挂载执行,count 改变执行,todos 改变也执行。

但这里的"每次更新"指的是每次组件完成一次渲染提交,不是组件函数中每执行一行代码就执行一次。它适合确实需要响应所有提交的场景,不适合随意放置昂贵计算,否则每次交互都会重复执行。

5.3 []:只在挂载后执行一次

js 复制代码
useEffect(() => {
  console.log('只会在挂载后执行');
}, []);

空依赖数组表示这个 effect 没有需要跟踪的响应式依赖。在正常生产运行中,它会在组件首次挂载并提交 DOM 后执行一次;后续 counttodos 改变时不会重复执行。

常见用途包括:首次读取外部资源、注册一次性的订阅、初始化第三方库。但如果 effect 中使用了会变化的状态或 props,就不能机械地写空数组,需要认真判断依赖关系。

5.4 [todos]:任务变化后持久化

js 复制代码
useEffect(() => {
  console.log('挂载后执行');
  console.log('todos 更新后执行');
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

这个 effect 的目的,是把 React 内存中的 todos 同步到浏览器的 localStorage。它会在首次挂载后保存初始任务,也会在添加、勾选、删除和清理任务后重新保存。

这里必须使用 JSON.stringify,因为 localStorage 的值只能以字符串形式保存:

javascript 复制代码
localStorage.setItem('todos', JSON.stringify(todos));

const savedTodos = JSON.parse(localStorage.getItem('todos'));

保存流程是:数组先转换成 JSON 字符串,浏览器保存字符串;下次初始化时再把字符串解析回数组。

6. localStorage 懒初始化与数据持久化

6.1 为什么读取操作放在 useState 函数初始值中

当前代码使用:

js 复制代码
const [todos, setTodos] = useState(() => {
  return JSON.parse(localStorage.getItem('todos'));
});

这段代码的意图是:组件首次初始化时读取本地任务,后续重新渲染不重复读取。箭头函数是懒初始化函数,只有状态初始化阶段才会执行。

但当前写法有一个实际问题:第一次打开页面时,如果浏览器中还没有 todoslocalStorage.getItem('todos') 返回 nullJSON.parse(null) 的结果也是 null。后面执行 todos.filter(...) 时就会报错,因为 null 没有 filter 方法。

更安全的写法是给空值准备默认数组:

js 复制代码
const [todos, setTodos] = useState(() => {
  const savedTodos = localStorage.getItem('todos');
  return savedTodos ? JSON.parse(savedTodos) : [];
});

也可以使用空值合并:

js 复制代码
const [todos, setTodos] = useState(() =>
  JSON.parse(localStorage.getItem('todos')) ?? [],
);

6.2 为什么不能在 setTodos 后立即保存旧数组

当前 addTodo 中有这样的代码:

js 复制代码
const addTodo = (text) => {
  if (text.trim() === '') return;

  setTodos([
    { id: +Date.now(), text, completed: false },
    ...todos,
  ]);

  localStorage.setItem('todos', JSON.stringify(todos));
};

这里的 todos 仍然是当前这次渲染的状态快照。调用 setTodos 只是提交更新请求,不会立即把当前作用域中的 todos 改成新数组。因此紧接着保存的仍然是旧数据,新添加的任务可能不会被保存。

正确做法是删除 addTodo 中的直接保存,让 [todos] effect 统一负责持久化(函数式更新):

js 复制代码
const addTodo = (text) => {
  if (text.trim() === '') return;

  setTodos((currentTodos) => [
    { id: Date.now(), text: text.trim(), completed: false },
    ...currentTodos,
  ]);
};

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

这样,React 先完成状态更新,再在新的 todos 已经成为当前状态后运行 effect,保存的就是最新数组。

7. Demo:挂载、定时器与卸载清理

7.1 count 如何控制 Demo 的存在

App 中有一段条件渲染:

js 复制代码
{count % 2 === 0 && <Demo />}

它使用逻辑与表达式表示:当 count 是偶数时渲染 Demo,当 count 是奇数时不渲染 Demo

初始 count0,因此 Demo 首次挂载;点击一次按钮后 count 变成 1,条件变为假,React 会把 Demo 从页面和组件树中移除;再次点击变成 2Demo 重新挂载。

生命周期变化如下:

text 复制代码
count = 0  → Demo 挂载 → effect 创建 interval
count = 1  → Demo 卸载 → cleanup 清除 interval
count = 2  → Demo 再次挂载 → effect 创建新的 interval

7.2 为什么定时器必须清理

Demo 中的 effect:

js 复制代码
useEffect(() => {
  const interval = setInterval(() => {
    console.log('interval,is here');
  }, 1000);

  return () => {
    console.log('组件卸载前执行,做什么内存清理工作');
    clearInterval(interval);
  };
}, []);

effect 创建了一个每秒执行的定时器。定时器属于浏览器外部资源,即使 Demo 从页面移除,浏览器也不会因为组件消失而自动停止它。如果不调用 clearInterval

  • 组件已经看不见了,控制台仍然持续打印。
  • 每次重新挂载都会新增一个定时器。
  • 多次挂载和卸载后可能同时运行多个定时器。
  • 外部回调仍然引用组件相关数据,造成资源无法及时释放。

因此,useEffect 返回的函数就是清理函数。它会在组件卸载前执行;如果 effect 由于依赖变化需要重新建立,也会先清理旧 effect,再运行新的 effect。

凡是 effect 创建了定时器、事件监听、订阅或连接,就应该在返回函数中释放对应资源。

这就是函数组件中对应类组件 componentWillUnmount 的常见写法。

8. Todo 子组件如何参与生命周期更新

TodoInputTodoListTodoStats 本身没有保存完整任务数组,它们通过 props 接收父组件数据和回调:

js 复制代码
<TodoInput onAdd={addTodo} />

<TodoList
  todos={todos}
  onToggle={toggleTodo}
  onDelete={deleteTodo}
/>

<TodoStats
  total={todos.length}
  active={activeCount}
  completed={completedCount}
  onClearCompleted={clearCompleted}
/>

以勾选任务为例:

  1. TodoList 根据 todos 渲染受控复选框。
  2. 用户点击复选框,执行 onToggle(todo.id)
  3. 实际执行的是 App 中的 toggleTodo
  4. setTodos 提交新的任务数组。
  5. App 重新执行,重新计算 activeCountcompletedCount
  6. 新的 todos 和统计值通过 props 传给子组件。
  7. [todos] effect 在提交更新后把新数组保存到 localStorage

数据流可以表示为:

text 复制代码
App 持有 todos
  ↓ props
TodoList 展示任务
  ↓ 回调
用户勾选或删除
  ↓
App 修改 todos
  ↓
React 重新渲染并运行 [todos] effect
  ↓
子组件更新 + localStorage 持久化

9. 当前代码的改进版关键片段

为了避免空值、旧快照和连续更新问题,核心逻辑可以调整为:

js 复制代码
const [todos, setTodos] = useState(() => {
  const savedTodos = localStorage.getItem('todos');
  return savedTodos ? JSON.parse(savedTodos) : [];
});

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

const addTodo = (text) => {
  const normalizedText = text.trim();
  if (!normalizedText) return;

  setTodos((currentTodos) => [
    {
      id: Date.now(),
      text: normalizedText,
      completed: false,
    },
    ...currentTodos,
  ]);
};

const toggleTodo = (id) => {
  setTodos((currentTodos) =>
    currentTodos.map((todo) =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo,
    ),
  );
};

这里的改动并不是改变功能,而是让代码更符合 React 的状态更新模型:初始化时保证得到数组,持久化统一放在 effect 中,新状态依赖旧状态时使用函数式更新,输入内容保存前先去除首尾空格。

10. 常见误区

  • useEffect 当成普通函数使用。它的回调是在渲染提交之后运行,不是执行到这一行就立即运行。
  • 误以为 useEffect(..., []) 在开发环境控制台绝对只打印一次。StrictMode 可能为了检查副作用而进行额外的开发期调用,不能把开发日志直接等同于生产行为。
  • setTodos 后立即读取 todos,以为拿到的是新数组。当前渲染中的状态仍是旧快照。
  • localStorage 读取后不处理 null,导致首次运行时 todos.filter 报错。
  • 创建 setInterval 却没有 clearInterval,造成定时器不断累积。
  • filtered、统计数量等可由现有状态计算出的值再次存进 useState,增加同步成本。
  • 让子组件直接修改父组件传入的数组,破坏父组件统一管理状态的边界。

总结

这次 Todo 实战的重点从单纯的状态管理扩展到了副作用与生命周期。App 在首次挂载时通过懒初始化从 localStorage 读取任务,状态更新后通过 [todos] effect 持久化最新数据;[count]、无依赖数组和空依赖数组分别展示了 effect 的不同执行范围。条件渲染让 count 控制 Demo 的挂载与卸载,定时器则通过 effect 返回的清理函数释放资源。理解这些过程后,可以明确区分组件函数重新执行、DOM 提交、effect 执行和卸载清理,并能使用单向数据流组织 Todo 子组件。实际开发中还要处理本地存储为空、状态更新旧快照和副作用清理等问题,才能让组件行为稳定可靠。

相关推荐
卷福同学2 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
先吃饱再说2 小时前
编写一个颜色选择器之后,我理解了 React 工程化的三层架构
react.js·前端框架·前端工程化
先吃饱再说2 小时前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架
赵庆明老师3 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
先吃饱再说3 小时前
从“传事件”到“只传值”:React + TypeScript 组件 Props 设计的两次进化
react.js·前端框架·前端工程化
wc883 小时前
微软EDGE浏览器功能学习
前端·学习·edge
破z晓3 小时前
javascript 导出excel表
开发语言·javascript·excel
Csvn3 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端
Csvn3 小时前
🎨 CSS @layer:用「层叠优先级」终结样式打架的世纪难题
前端
小罗水4 小时前
第17章 后台管理端与演示支持
javascript·vue.js·ecmascript