React useEffect 详解:执行时机、依赖数组与副作用清理

React useEffect 详解:从执行时机、依赖数组到副作用清理

在 React 函数组件中,我们经常会遇到这样的需求:

  • 组件显示出来以后,再异步加载数据;
  • 某个状态变化以后,把最新数据保存到浏览器;
  • 组件进入页面后启动定时器,离开页面时关闭定时器;
  • 观察组件何时挂载、何时更新、何时卸载。

这些事情都不是在"计算这次界面应该长什么样",而是在渲染之外附带完成的工作,也就是常说的副作用useEffect 正是用来处理这类工作的 Hook。

本文只围绕 useEffect 展开,通过几个连续的例子,讲清楚它的执行时机、依赖数组和清理函数。

一、什么是副作用

先看一段最简单的组件代码:

tsx 复制代码
import { useEffect, useState } from "react";

function App() {
  const [status, setStatus] = useState<"loading" | "ready" | null>(null);

  useEffect(() => {
    console.log("组件挂载完成");

    setTimeout(() => {
      setStatus("ready");
    }, 2000);
  }, []);

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

  return <div>当前状态:{status}</div>;
}

export default App;

这段代码做了两类不同的事情:

  1. 组件函数根据当前的 status 计算并返回 JSX;
  2. 页面完成本次渲染以后,useEffect 启动一个定时任务,2 秒后修改状态。

第二类工作就是副作用。它不是本次 JSX 计算的一部分,却会在渲染完成以后产生后续影响。

将日志放在组件函数和 Effect 中,可以直观看到它们的先后关系:

text 复制代码
组件函数执行,准备渲染
组件挂载完成

也就是说,组件函数会先执行并返回界面,Effect 随后再执行。它适合"组件先尽快显示,再附带完成其他工作"的场景。

二、为什么异步加载适合放进 useEffect

假设用户名需要通过异步操作加载,可以这样写:

tsx 复制代码
import * as React from "react";

const App = () => {
  const [name, setName] = React.useState<string>("defaultUserName");
  const [editingName, setEditingName] =
    React.useState<string>("defaultUserName");

  const loadUsername = () => {
    setTimeout(() => {
      setName("name from async call");
      setEditingName("name from async call");
    }, 3000);
  };

  React.useEffect(() => {
    loadUsername();
  }, []);

  return (
    <>
      <p>名字:{name}</p>
      <p>正在编辑:{editingName}</p>
    </>
  );
};

export default App;

整个过程可以拆成两步。

第一步:先使用初始状态完成渲染

组件第一次执行时,两个状态都是:

text 复制代码
defaultUserName

React 不需要等待异步操作结束,就能先把组件显示出来。

第二步:挂载后开始异步加载

本次渲染完成以后,useEffect 调用 loadUsername。3 秒后,定时器中的代码更新两个状态:

tsx 复制代码
setName("name from async call");
setEditingName("name from async call");

状态更新会触发组件重新执行,于是界面显示异步得到的新名字。

这里最重要的不是 setTimeout 本身,而是 useEffect 带来的执行顺序:

text 复制代码
使用初始状态渲染组件
        ↓
组件挂载后执行 Effect
        ↓
异步操作结束并更新状态
        ↓
组件使用新状态重新渲染

因此,"挂载后请求数据,再通过状态更新界面"是 useEffect 的典型使用方式。

三、useEffect 的基本结构

useEffect 最常见的结构如下:

tsx 复制代码
useEffect(() => {
  // Effect 要执行的副作用

  return () => {
    // 可选:清理副作用
  };
}, [依赖项]);

这里包含三个部分:

  • 第一个参数是 Effect 函数,用来执行副作用;
  • Effect 函数可以返回一个清理函数;
  • 第二个参数是依赖数组,它决定 Effect 需要在哪些更新之后重新执行。

真正决定执行频率的关键,就是第二个参数。

四、依赖数组的三种写法

下面用 counttodos 两个状态,分别观察三种写法。

jsx 复制代码
const [count, setCount] = useState(0);
const [todos, setTodos] = useState([]);

1. 空依赖数组:[]

jsx 复制代码
useEffect(() => {
  console.log("组件挂载后执行");
}, []);

空数组表示这个 Effect 不依赖组件中的任何响应式状态。因此,它用于只需要在组件挂载后启动一次的工作,例如开始一次初始数据加载:

tsx 复制代码
useEffect(() => {
  loadUsername();
}, []);

需要注意,[] 表达的是"没有需要跟踪的依赖项",不是"Effect 完全脱离组件生命周期"。组件卸载时,如果 Effect 返回了清理函数,清理函数仍然会执行。

2. 指定依赖项:[count]

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

这个 Effect 会在以下时机执行:

  • 组件挂载后的第一次渲染完成时;
  • count 相比上一次发生变化并完成渲染后。

例如点击按钮修改 count

jsx 复制代码
const countBy = () => {
  setCount(count + 1);
};

return (
  <button onClick={countBy}>
    count++:{count}
  </button>
);

每次 count 改变,组件都会重新渲染,依赖 count 的 Effect 随后重新执行。

同理,如果副作用与 Todo 列表有关,就应该依赖 todos

jsx 复制代码
useEffect(() => {
  console.log("挂载后执行");
  console.log("todos 更新后执行");
}, [todos]);

count 的变化不会让这个 Effect 因依赖变化而重新执行;todos 的变化才是它关心的更新。

依赖数组可以理解为一句声明:

这个副作用使用并跟随哪些状态。

3. 不传第二个参数

jsx 复制代码
useEffect(() => {
  console.log("每次渲染完成后都执行");
});

没有依赖数组时,Effect 会在组件挂载及后续每次渲染完成后执行。

例如组件中既有 count,又有 todos,修改其中任何一个状态都会让组件重新渲染,这个 Effect 也会跟着执行。

三种写法可以放在一起对比:

写法 挂载后 后续更新后
useEffect(fn, []) 执行 不因状态更新而重新执行
useEffect(fn, [count]) 执行 count 改变后执行
useEffect(fn) 执行 每次渲染完成后都执行

五、实战:todos 变化后保存到 localStorage

Todo 列表不仅要在页面中展示,还希望刷新页面以后仍能恢复。可以用 localStorage 保存它。

先读取已经保存的数据,作为 todos 的初始值:

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

接着,用 useEffect 监听 todos

jsx 复制代码
useEffect(() => {
  localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);

这段 Effect 的执行过程是:

  1. 组件挂载并完成第一次渲染;
  2. Effect 将当前 todos 写入 localStorage
  3. 添加、切换、删除或者清理 Todo 时,todos 发生变化;
  4. 组件使用新列表重新渲染;
  5. Effect 再把新列表保存到 localStorage

例如添加 Todo 时,会生成一个全新的数组:

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

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

setTodos 触发更新,随后 [todos] 对应的 Effect 执行,完成持久化。

为什么要使用 JSON.stringify

localStorage 按字符串形式保存键和值,而 todos 是一个对象数组,所以写入前要转换成 JSON 字符串:

jsx 复制代码
localStorage.setItem("todos", JSON.stringify(todos));

读取时再用 JSON.parse 还原:

jsx 复制代码
JSON.parse(localStorage.getItem("todos"))

这里可以看到 useEffect 的职责非常明确:列表的增删改负责产生新状态,而"状态改变后顺便保存一下"交给 Effect。副作用与它所依赖的数据通过 [todos] 对应起来。

六、清理函数:组件卸载前收尾

副作用有时会持续占用资源。最直观的例子是定时器:

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

    return () => {
      console.log("组件卸载前执行,进行清理");
      clearInterval(interval);
    };
  }, []);

  return <div>demo</div>;
};

Effect 在 Demo 挂载后创建定时器,每隔 1 秒打印一次日志。Effect 返回的函数则负责清理:

jsx 复制代码
return () => {
  clearInterval(interval);
};

注意,这里返回的是一个函数,而不是直接执行 clearInterval。React 会在需要清理这个 Effect 时调用它。

用条件渲染观察组件卸载

父组件可以根据 count 的奇偶性决定是否显示 Demo

jsx 复制代码
const [count, setCount] = useState(0);

return (
  <div>
    <button onClick={() => setCount(count + 1)}>count++</button>
    {count % 2 === 0 && <Demo />}
  </div>
);

count 为偶数时,Demo 存在,定时器不断执行;当 count 变成奇数时,条件不成立,Demo 从组件树中卸载,清理函数随之执行并关闭定时器。

执行顺序可以概括为:

text 复制代码
Demo 挂载
    ↓
Effect 创建 interval
    ↓
interval 每秒执行
    ↓
Demo 卸载
    ↓
清理函数调用 clearInterval

如果组件已经卸载,定时器却仍在运行,它所占用的资源就不能及时释放,也可能继续执行已经没有意义的工作。为持续性的副作用提供对应的清理逻辑,是 useEffect 非常重要的一部分。

七、把执行、重新执行和清理连起来理解

到这里,可以从组件生命周期的角度总结 useEffect

挂载后

组件第一次渲染完成后执行 Effect。

jsx 复制代码
useEffect(() => {
  console.log("mounted");
}, []);

更新后

组件重新渲染完成后,React 根据依赖数组判断是否需要再次执行 Effect。

jsx 复制代码
useEffect(() => {
  console.log("todos updated");
}, [todos]);

卸载时

如果 Effect 返回清理函数,组件卸载时会执行清理。

jsx 复制代码
useEffect(() => {
  const interval = setInterval(() => {
    console.log("working");
  }, 1000);

  return () => {
    clearInterval(interval);
  };
}, []);

因此,useEffect 并不只是"组件挂载时执行一个函数"。更完整的理解应该是:

在渲染完成后执行副作用,根据依赖决定是否重新执行,并在副作用结束前完成必要的清理。

八、一个完整示例

下面把本文涉及的几种用法组合到一个 Todo 应用中:

jsx 复制代码
import { useEffect, useState } from "react";

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

    return () => {
      console.log("组件卸载前执行,清理定时器");
      clearInterval(interval);
    };
  }, []);

  return <div>demo</div>;
};

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

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

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

  useEffect(() => {
    console.log("count 挂载或更新后执行");
  }, [count]);

  useEffect(() => {
    console.log("每次渲染完成后执行");
  });

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

  return (
    <div>
      <p>Count:{count}</p>
      <button onClick={() => setCount(count + 1)}>count++</button>

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

      <button onClick={() => addTodo("学习 useEffect")}>
        添加 Todo
      </button>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
};

export default App;

这个例子里同时存在四类 Effect 行为:

  • Demo 挂载后创建定时器,卸载时清理定时器;
  • [count] 只跟随 count 的变化;
  • 不传依赖数组的 Effect 在每次渲染后执行;
  • [todos] 在列表变化后把数据写入 localStorage

只要先判断副作用与哪些状态有关,再选择正确的依赖数组,代码的执行时机就会变得清晰。

九、总结

学习 useEffect,可以牢牢记住下面四点:

  1. useEffect 用来处理渲染之外的副作用,例如异步加载、本地存储和定时器。
  2. Effect 在渲染完成后执行,所以组件可以先展示,再通过异步结果更新状态。
  3. 依赖数组决定 Effect 的执行频率:[] 用于挂载后的初始化工作,[state] 跟随指定状态,不传则每次渲染后都执行。
  4. 持续占用资源的副作用需要返回清理函数,例如使用 clearInterval 移除定时器。

最后,可以用一句话串起它的完整逻辑:

组件先根据状态完成渲染,useEffect 随后执行副作用;依赖变化时重新执行,组件卸载时完成清理。

相关推荐
tntxia2 小时前
React 的常用 Hook
react.js
半夜里咳嗽的狼8 小时前
用 React 19.2 Activity 保留页面状态前,先弄清 hidden 模式做了什么
react.js
小林ixn8 小时前
React + TypeScript 实战:从“类型体操”到“数据持久化”,一次讲透组件通信与副作用管理
前端·react.js·typescript
何时梦醒8 小时前
React + TypeScript 企业级开发实战:从零搭建到组件架构演进
前端·react.js·全栈
张元清8 小时前
React useUpdateEffect Hook:跳过首次渲染(2026)
javascript·react.js
不好听6138 小时前
从困惑到理解:React 父子组件通信的三种写法
react.js·typescript
无人生还8 小时前
从 Vue3 到 React · 快速上手系列第 7 篇:副作用与生命周期 —— watch/watchEffect 如何翻译为 useEffect
前端·vue.js·react.js
阿黎梨梨8 小时前
React + TypeScript 入门:从组件类型到数据流设计
react.js
烬羽8 小时前
React 状态归属:两个版本的用户名编辑器,告诉你 state 该放哪
react.js·typescript·前端框架