Dark Dwarf Blog background

React Effect

React Effect

1. 副作用

a.a. 相关概念

React 组件的核心职责是根据 props 和 state 渲染 UI。理想情况下,渲染过程是“纯粹”的。但应用总需要与外部世界交互,例如从服务器获取数据、设置定时器或手动操作 DOM。这些与 React 渲染流程无关的操作,被称为副作用 (Side Effects)

一个纯函数对于相同的输入总是返回相同的结果,并且不影响任何外部状态。React 的渲染过程就应该像一个纯函数。但当组件需要与 React 控制范围之外的系统交互时,就产生了副作用。这些“外部系统”包括:

  • 网络请求: fetch() 数据。
  • 浏览器 API: document.titlelocalStorage、定时器 (setInterval)、DOM 事件监听。
  • 第三方库: 不使用 React 方式编写的动画库、地图库等。

直接在组件的顶层逻辑中执行副作用会导致不可预测的行为,因为这段代码会在每一次渲染时都执行。

function Clock() {
  const [counter, setCounter] = useState(0);

  // 错误!每次渲染都会创建一个新的定时器
  setInterval(() => {
    setCounter(count => count + 1);
  }, 1000);

  return <p>{counter} seconds have passed.</p>;
}

在上面的例子中,组件首次渲染时创建一个定时器。定时器每秒更新 state,触发组件重新渲染。每次重新渲染又会创建一个新的定时器,导致 state 更新频率失控,最终程序崩溃。useEffect 正是为了解决这类问题而生的。

2. useEffect Hook

a.a. useEffect 核心思想

useEffect 的核心作用是让我们能够根据组件的 props 和 state 同步 (synchronize) 外部系统。它提供了一个脱离渲染流、专门用于执行副作用的环境。

可以把 React 组件想象成一个“UI 配置师”,他的唯一工作就是:根据我们给他的“配置单”(props 和 state),精确地描绘出 UI 应该长什么样(返回 JSX)。他本身不处理任何“杂活”,比如去网上拿数据、修改浏览器标题或者设置一个秒表。而这些“杂活”就是副作用。它们之所以麻烦,是因为它们有自己的“生命周期”,和组件的渲染周期并不同步

而 React 的 useEffect Hook 可以看作是 React 组件雇佣的“杂活总管”。这位总管的作用就是接管所有“纯粹 UI 配置”之外的杂活。我们把要干的活、什么时候干、以及干完怎么“收尾”都告诉它,他会帮你处理得井井有条。

b.b. useEffect 使用方法

useEffect 接收两个参数:一个设置函数和一个可选的依赖数组。

useEffect(
  // 1. 设置函数 (Setup Function): 包含副作用逻辑。
  () => {
    // ... 执行副作用 ...

    // 3. 清理函数 (Cleanup Function): (可选) 返回一个函数用于清理副作用。
    return () => {
      // ... 清理工作 ...
    };
  },
  // 2. 依赖数组 (Dependency Array): (可选) 控制 Effect 的执行时机。
  [/* 依赖项 */]
);

依赖数组是 useEffect 的“开关”,它告诉 React 在什么情况下需要重新运行 Effect。可以传入如下的依赖数组:

  • 不提供依赖数组 (默认): useEffect(() => { ... }),effect 会在每一次渲染后都运行。这通常不是我们想要的,容易导致性能问题或无限循环。

  • 提供空数组 []: useEffect(() => { ... }, []),清理函数会在组件卸载时运行。这适用于只需要执行一次的设置,如全局事件监听、只获取一次的数据等:

function ChatRoom() {
  useEffect(() => {
    // 组件挂载时,连接到聊天服务器
    const connection = createConnection();
    connection.connect();
    // 组件卸载时,断开连接,防止内存泄漏
    return () => connection.disconnect();
  }, []); // <-- 空数组,只执行一次

  return <h1>Welcome to the chat!</h1>;
}
  • 提供包含依赖项的数组 [dep1, dep2]: useEffect(() => { ... }, [dep1, dep2]),effect 会在首次渲染后运行,并且在后续的每一次渲染中,如果任何一个依赖项 (dep1dep2) 发生了变化,effect 就会重新运行(先执行上一次的清理函数,再执行本次的设置函数):
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // 1. 定义收尾逻辑:如果 userId 变了,旧的请求可能就没用了
    let ignore = false;

    // 2. 干活:根据当前的 userId 获取数据
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) {
          setUser(data);
        }
      });

    // 3. 收尾:如果 userId 变化导致 effect 重新执行,
    //    或者组件卸载,就执行这个 cleanup 函数。
    //    将 ignore 设为 true,这样过时的网络请求返回时,就不会去更新 state。
    return () => {
      ignore = true;
    };
  }, [userId]); // <-- 依赖项是 userId

  return <div>{user ? <p>{user.name}</p> : <p>Loading...</p>}</div>;
}

从服务器获取数据是 useEffect 最常见的用途之一。上面的例子就是在完成这个任务,它巧妙地通过设置 ignore 避免了竞态条件。

如果 effect 创建了需要手动关闭的订阅(如定时器、事件监听、WebSocket 连接),必须返回一个清理函数。React 会在以下两个时机执行它:

  1. 在下一次 effect 即将重新运行之前。
  2. 在组件被从屏幕上移除(卸载)时。

user 例子中,如果 userId 从 1 变成了 2,React 会:

  1. 执行 userId 为 1 时的 cleanup 函数 (ignore = true)。
  2. 执行 userId 为 2 时的 setup 函数 (发起新的 fetch)。

3. useEffect 生命周期

effect 的生命周期如下:

  1. 组件挂载 (Mount) -> 执行设置函数
  2. 依赖项变化,触发重渲染 -> 执行上一次的清理函数 -> 执行本次的设置函数
  3. 组件卸载 (Unmount) -> 执行最后一次的清理函数

在开发环境中,React 的 StrictMode 会故意地对组件执行一次额外的“挂载 -> 卸载 -> 挂载”周期。这意味着我们的 effect 会运行两次。这是一种压力测试,旨在帮助我们提前发现因缺少清理函数而导致的 bug。如果在 StrictMode 下看到 effect 运行了两次,这通常意味着我们的清理逻辑是必要的。

4. 不需要 useEffect 的一些情况

useEffect 是一个强大的工具,但也容易被滥用。在使用它之前,请先思考是否真的需要它。

  • 用于转换数据:在渲染期间计算:如果一个值可以根据现有的 props 或 state 直接计算出来,那就直接在组件主体中计算,不要用 effect 来把它同步到另一个 state 中。
// 不好
// const [fullName, setFullName] = useState('');
// useEffect(() => {
//   setFullName(firstName + ' ' + lastName);
// }, [firstName, lastName]);

// 好
const fullName = firstName + ' ' + lastName;
  • 用于响应事件:使用事件处理器:用户交互(如点击按钮)应该在事件处理器(如 onClick)中处理,而不是在 effect 中。effect 是为了响应渲染本身带来的变化,而事件处理器是为了响应特定的用户输入

  • 用于重置 State:使用 Key:如果需要在某个 prop 变化时重置整个组件的 state,使用 key 属性通常比写一个复杂的 effect 更简单、更清晰:<Profile key={userId} />

5. 常见的 useEffect 陷阱

a.a. 无限循环

最常见的无限循环发生在 Effect 内部更新了一个 state,而这个 state 又被包含在依赖数组中,并且在每次渲染时都被创建为新对象或数组。

// 错误:无限循环
function App() {
  const [options, setOptions] = useState({});

  useEffect(() => {
    // 每次渲染都会创建一个新对象,导致 options 变化,触发 Effect 重启
    setOptions({ a: 1 }); 
  }, [options]);
}

b.b. 遗漏依赖项与“陈旧的闭包”

useEffect 的函数体是一个闭包,它会“记住”其被创建时所在渲染作用域中的 props 和 state。如果我们在 effect 中使用了某个 prop 或 state,但没有将它加入依赖数组,那么当这个 prop 或 state 更新时,effect 不会重新运行,它内部的那个值就会变成“陈旧的”

React 的 Linter 插件 (eslint-plugin-react-hooks) 会对此发出警告。永远不要忽略这个警告