MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

React useEffect 钩子的工作原理

2024-06-107.2k 阅读

什么是 React useEffect 钩子

在 React 中,useEffect 是一个极为重要的 Hook,它为函数式组件引入了副作用操作的能力。副作用操作涵盖了诸如数据获取、订阅或手动修改 DOM 等操作,这些操作在 React 纯函数式的渲染模型之外执行。

在类组件中,副作用操作通常在 componentDidMountcomponentDidUpdatecomponentWillUnmount 这些生命周期方法中实现。而 useEffect 钩子则提供了一种更简洁且统一的方式来处理这些副作用,无论组件是挂载、更新还是卸载,都可以在一个地方进行管理。

useEffect 的基本用法

useEffect 接收两个参数:一个是包含副作用操作的函数,另一个是可选的依赖数组。其基本语法如下:

import React, { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    // 副作用操作代码
    console.log('Component has mounted or updated');

    return () => {
      // 清理函数,在组件卸载或依赖更新时执行
      console.log('Component is about to unmount or dependencies have changed');
    };
  }, []);

  return <div>My Component</div>;
}

在上述代码中,useEffect 回调函数内部的代码会在组件挂载后以及每次更新后执行。返回的函数是一个清理函数,它会在组件卸载时执行,或者在依赖数组中的依赖发生变化时执行(如果提供了依赖数组)。

useEffect 的执行时机

  1. 组件挂载时:当组件首次渲染到 DOM 中时,useEffect 中的副作用函数会被调用。这类似于类组件中的 componentDidMount 生命周期方法。
  2. 组件更新时:每当组件的 props 或 state 发生变化,导致组件重新渲染时,useEffect 中的副作用函数也会被调用。这类似于类组件中的 componentDidUpdate 生命周期方法。
  3. 组件卸载时:当组件从 DOM 中移除时,useEffect 返回的清理函数会被调用。这类似于类组件中的 componentWillUnmount 生命周期方法。

依赖数组的作用

依赖数组是 useEffect 的第二个参数,它是一个数组,用于指定 useEffect 依赖的变量。只有当依赖数组中的变量发生变化时,useEffect 才会再次执行。如果依赖数组为空 [],则 useEffect 只会在组件挂载和卸载时执行,相当于只模拟了 componentDidMountcomponentWillUnmount

例如,假设我们有一个组件,它依赖于一个名为 count 的 state 变量:

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`Count has changed to: ${count}`);
    return () => {
      console.log('Cleaning up after count change');
    };
  }, [count]);

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

在上述代码中,useEffect 的依赖数组为 [count]。因此,只有当 count 发生变化时,useEffect 中的副作用函数才会执行。每次点击按钮,count 增加,useEffect 就会打印新的 count 值,并在下次更新前执行清理函数。

useEffect 的工作原理剖析

  1. 渲染阶段与提交阶段:在 React 的渲染过程中,分为渲染阶段(render phase)和提交阶段(commit phase)。渲染阶段是 React 计算需要更新的部分,而提交阶段是 React 将这些更新应用到 DOM 上的阶段。useEffect 中的副作用函数不会在渲染阶段执行,而是在提交阶段之后执行。这是因为渲染阶段应该是纯函数式的,不应该包含副作用操作,以确保可预测性和性能优化。
  2. 依赖数组的检查:React 会在每次组件更新时,对比当前渲染的依赖数组和上一次渲染的依赖数组。如果两个数组的内容相同(使用 Object.is 进行比较),则认为依赖没有变化,useEffect 不会执行。如果数组内容不同,则会执行 useEffect 中的副作用函数。这种机制使得 useEffect 能够精确控制副作用的执行时机,避免不必要的重复执行。
  3. 清理函数的执行:当 useEffect 需要再次执行(由于依赖变化)或者组件即将卸载时,之前 useEffect 返回的清理函数会被执行。这确保了在进行新的副作用操作之前,清理上一次副作用操作可能产生的资源,如取消网络请求、解绑事件监听器等。

深入理解依赖数组

  1. 省略依赖数组:如果省略依赖数组,useEffect 会在每次组件渲染(挂载和更新)时都执行。虽然这种方式类似于类组件中 componentDidMountcomponentDidUpdate 的合并,但它可能会导致性能问题,因为不必要的副作用操作会被频繁执行。例如:
import React, { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    console.log('Component has mounted or updated');
  });

  return <div>My Component</div>;
}

在上述代码中,每次 MyComponent 重新渲染,useEffect 都会打印日志。 2. 空依赖数组:当依赖数组为空 [] 时,useEffect 只在组件挂载和卸载时执行。这常用于初始化一些只需要执行一次的操作,如设置页面标题、订阅全局事件等。例如:

import React, { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    document.title = 'My Page';
    return () => {
      // 这里可以进行清理操作,例如取消订阅
    };
  }, []);

  return <div>My Component</div>;
}

在上述代码中,document.title 只会在组件挂载时设置一次,并且在组件卸载时清理函数会被执行(虽然这里清理函数为空)。 3. 多个依赖项:依赖数组可以包含多个变量,只有当这些变量中的任何一个发生变化时,useEffect 才会执行。例如:

import React, { useState, useEffect } from 'react';

function MyComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  useEffect(() => {
    console.log(`Count: ${count}, Name: ${name}`);
    return () => {
      console.log('Cleaning up');
    };
  }, [count, name]);

  return (
    <div>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

在上述代码中,当 countname 发生变化时,useEffect 都会执行,打印最新的 countname 值,并在下次更新前执行清理函数。

useEffect 与性能优化

  1. 避免不必要的副作用执行:通过正确设置依赖数组,可以避免 useEffect 在不必要的时候执行副作用操作。这有助于提升应用的性能,特别是在处理复杂的副作用逻辑或频繁更新的组件时。例如,如果一个 useEffect 用于获取数据,而数据依赖的 props 或 state 没有变化,那么就不应该重新获取数据。
  2. 节流与防抖:在某些情况下,useEffect 中可能会包含一些频繁触发的操作,如监听窗口滚动事件。这时可以使用节流(throttle)或防抖(debounce)技术来优化性能。例如,使用 lodash 库的 throttledebounce 函数:
import React, { useEffect } from 'react';
import { throttle } from 'lodash';

function MyComponent() {
  useEffect(() => {
    const handleScroll = throttle(() => {
      console.log('Window has scrolled');
    }, 200);

    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
      handleScroll.cancel();
    };
  }, []);

  return <div>My Component</div>;
}

在上述代码中,throttle 函数确保 handleScroll 函数每 200 毫秒最多执行一次,避免了频繁触发导致的性能问题。在组件卸载时,需要移除事件监听器并取消节流函数。

useEffect 中的异步操作

  1. 数据获取useEffect 常用于在组件挂载或更新时获取数据。由于数据获取通常是异步操作,我们可以在 useEffect 中使用 async 函数。例如:
import React, { useState, useEffect } from 'react';

function MyComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('https://example.com/api/data');
      const result = await response.json();
      setData(result);
    };

    fetchData();
  }, []);

  return (
    <div>
      {data? (
        <p>{JSON.stringify(data)}</p>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
}

在上述代码中,useEffect 在组件挂载时调用 fetchData 函数,该函数通过 fetch API 异步获取数据,并将结果设置到 data state 中。 2. 处理异步操作的错误:在异步操作中,错误处理至关重要。我们可以使用 try...catch 块来捕获异步操作中的错误。例如:

import React, { useState, useEffect } from 'react';

function MyComponent() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const result = await response.json();
        setData(result);
      } catch (error) {
        setError(error);
      }
    };

    fetchData();
  }, []);

  return (
    <div>
      {error && <p>{error.message}</p>}
      {data? (
        <p>{JSON.stringify(data)}</p>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
}

在上述代码中,如果 fetch 操作失败,catch 块会捕获错误并将其设置到 error state 中,然后在组件中显示错误信息。

useEffect 与 React 上下文(Context)

  1. 订阅上下文变化:当组件使用 React 上下文时,useEffect 可以用于订阅上下文的变化。例如,假设有一个主题上下文:
import React, { createContext, useState, useEffect } from'react';

const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function MyComponent() {
  const { theme } = React.useContext(ThemeContext);

  useEffect(() => {
    document.body.className = theme;
  }, [theme]);

  return <div>My Component</div>;
}

function App() {
  return (
    <ThemeProvider>
      <MyComponent />
    </ThemeProvider>
  );
}

在上述代码中,MyComponent 使用 useContext 获取主题上下文,并通过 useEffect 监听 theme 的变化。当 theme 变化时,useEffect 会更新 document.body 的类名,从而改变页面主题。 2. 避免不必要的更新:与处理 props 和 state 类似,通过正确设置依赖数组,可以避免 useEffect 在上下文变化但实际依赖未变化时执行不必要的操作。例如,如果 MyComponent 只关心主题的颜色部分,而不关心主题切换的其他逻辑,可以将依赖数组设置为只包含颜色相关的变量。

useEffect 的常见问题与解决方法

  1. 无限循环问题:如果在 useEffect 中调用了导致组件重新渲染的函数,且没有正确设置依赖数组,可能会导致无限循环。例如:
import React, { useState, useEffect } from'react';

function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1);
  });

  return <div>{count}</div>;
}

在上述代码中,useEffect 每次执行都会调用 setCount,导致组件重新渲染,进而再次触发 useEffect,形成无限循环。解决方法是正确设置依赖数组:

import React, { useState, useEffect } from'react';

function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1);
  }, []);

  return <div>{count}</div>;
}

在修正后的代码中,依赖数组为空,useEffect 只会在组件挂载时执行一次。 2. 依赖数组的值不正确:有时依赖数组中的值可能不会按照预期更新。这可能是因为闭包的原因,在 useEffect 回调函数中捕获的是旧的值。例如:

import React, { useState, useEffect } from'react';

function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setTimeout(() => {
      console.log(count);
    }, 1000);

    return () => clearTimeout(id);
  }, [count]);

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

在上述代码中,setTimeout 回调函数中打印的 count 可能是旧的值。这是因为 setTimeout 回调函数形成了一个闭包,捕获的是 useEffect 执行时的 count 值。解决方法是使用 useRef 来保存最新的值:

import React, { useState, useEffect, useRef } from'react';

function MyComponent() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
    const id = setTimeout(() => {
      console.log(countRef.current);
    }, 1000);

    return () => clearTimeout(id);
  }, [count]);

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

在修正后的代码中,countRef.current 始终保存最新的 count 值,确保 setTimeout 回调函数中能获取到正确的值。

useEffect 在实际项目中的应用场景

  1. 数据预取:在页面加载时,提前获取用户可能需要的数据,以提高用户体验。例如,在一个博客应用中,在文章详情页组件挂载时,使用 useEffect 预取相关的评论数据。
  2. 实时数据更新:在实时应用中,如聊天应用,使用 useEffect 监听实时数据的变化,如收到新消息时更新聊天界面。
  3. DOM 操作:虽然 React 提倡通过 state 和 props 来更新 DOM,但在某些情况下,需要手动操作 DOM。例如,在一个图片画廊组件中,使用 useEffect 在组件挂载后初始化图片轮播插件。
  4. 第三方库集成:将第三方库集成到 React 应用中时,useEffect 可以用于初始化和清理第三方库的实例。例如,使用 useEffect 初始化地图库,并在组件卸载时销毁地图实例。

总结

useEffect 钩子是 React 中处理副作用操作的核心工具。通过理解其工作原理、依赖数组的使用以及常见问题的解决方法,开发者能够更有效地在函数式组件中处理各种副作用,包括数据获取、DOM 操作、订阅和清理等。在实际项目中,合理运用 useEffect 可以提升应用的性能、用户体验,并使代码结构更加清晰和易于维护。同时,随着 React 的不断发展,useEffect 可能会有更多的优化和改进,开发者需要持续关注并学习最新的特性和最佳实践。