FunTester useEffect 的阴暗面

FunTester · 2025年12月18日 · 161 次阅读

React 的 useEffect 钩子可以说是函数组件中执行副作用的瑞士军刀,既能获取数据、设置订阅,还能和浏览器 DOM 打交道。但就像一把双刃剑,用好了能让代码优雅高效,用不好就是性能杀手和 bug 制造机。今天咱们就来聊聊 useEffect 的那些坑,以及如何优雅地避开它们。

理解 useEffect

什么是 useEffect

useEffect 是 React 内置的一个钩子函数,专门用来处理函数组件中的副作用。什么是副作用?简单来说,就是那些会影响组件外部世界的操作,比如从服务器拉数据、订阅消息推送、直接操作 DOM 元素等等。

想象一下,你的组件就像一个小房间,正常的渲染逻辑是房间内部的装修,而副作用就是打开窗户、连接外部网络、安装监控摄像头这些会影响到房间外部环境的操作。

useEffect 的工作原理

useEffect 接受两个参数:第一个是包含副作用逻辑的函数,第二个是可选的依赖数组。这个依赖数组就像是一个触发器,决定了副作用什么时候执行。

  • 空数组 []:副作用只在组件首次挂载后执行一次,相当于告诉 React,这个副作用只需要在组件出生时做一次就行
  • 有值的数组 [dep1, dep2]:只要数组中的任何一个值发生变化,副作用就会重新执行
  • 不传第二个参数:副作用会在每次组件重新渲染后都执行,这通常不是我们想要的

React 会在浏览器完成 DOM 更新后,再执行 useEffect 中的副作用函数。这个时机很重要,因为此时 DOM 已经更新完毕,你可以安全地操作 DOM 或者基于最新的 DOM 状态做一些事情。

常见使用场景

在实际开发中,useEffect 最常见的几个用途包括:

  • 数据获取:组件挂载时从 API 拉取数据,或者当某个参数变化时重新拉取数据。比如用户列表页面,当筛选条件变化时重新请求数据
  • 订阅管理:比如订阅 WebSocket 消息、监听窗口大小变化、监听键盘事件等。就像订阅一份报纸,组件挂载时订阅,卸载时取消订阅
  • DOM 操作:虽然 React 不推荐直接操作 DOM,但有时候确实需要,比如集成第三方库、管理焦点、滚动到指定位置等
  • 资源清理:组件卸载时取消订阅、清除定时器、移除事件监听器等,防止内存泄漏。这就像离开房间时要关灯、关空调,不能浪费资源

下面是一个典型的数据获取示例:

function MyComponent() {
  // 使用 useState 创建状态,存储从 API 获取的数据
  const [data, setData] = useState(null);

  // useEffect 的第一个参数是副作用函数
  // 第二个参数是依赖数组,空数组表示只在组件挂载时执行一次
  useEffect(() => {
    // 定义一个异步函数来获取数据
    const fetchData = async () => {
      // 发起网络请求
      const response = await fetch('https://api.example.com/data');
      // 将响应解析为 JSON
      const data = await response.json();
      // 更新组件状态,触发重新渲染
      setData(data);
    };

    // 调用异步函数
    fetchData();
  }, []); // 空依赖数组,确保只在挂载时执行

  return (
    <div>
      {/* 条件渲染,只有当 data 存在时才显示 */}
      {data && <p>{data.message}</p>}
    </div>
  );
}

这个例子展示了 useEffect 的基本用法:在组件挂载时异步获取数据,然后更新状态。依赖数组为空,所以这个副作用只会在组件第一次渲染后执行一次,不会因为组件重新渲染而重复执行。

useEffect 的潜在陷阱

在实际项目中,很多开发者在使用 useEffect 时都踩过坑。这些坑轻则导致性能问题,重则让应用直接崩溃。下面咱们就来详细聊聊这些常见的陷阱,以及它们是怎么产生的。

无限循环:最让人头疼的问题

无限循环可以说是 useEffect 最常见的坑了。当你发现浏览器卡死、CPU 占用率飙升,十有八九就是遇到了无限循环。

典型场景:你在 useEffect 中更新了某个状态,而这个状态又恰好是依赖数组中的一员。状态更新 → 触发重新渲染 → useEffect 再次执行 → 状态再次更新 → 无限循环。

// 错误示例:会导致无限循环
function BadComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 更新 count
    setCount(count + 1);
  }, [count]); // count 在依赖数组中,但 useEffect 中又更新了 count

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

这个组件一渲染就会陷入死循环,因为每次 count 更新都会触发 useEffect,而 useEffect 又会更新 count

解决方案:仔细检查依赖数组,避免在 useEffect 中更新依赖数组中的状态。如果确实需要基于当前值更新,可以使用函数式更新:setCount(prev => prev + 1)

内存泄漏:悄无声息的性能杀手

内存泄漏不像无限循环那样明显,但它会在后台慢慢消耗你的应用性能。最常见的情况是忘记清理定时器、订阅或者事件监听器。

典型场景:组件中设置了一个定时器,但组件卸载时没有清除。即使组件已经不存在了,定时器还在后台运行,占用内存。

// 错误示例:会导致内存泄漏
function TimerComponent() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // 设置定时器,每秒更新一次
    const timer = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
    // 忘记清理定时器!
  }, []);

  return <div>已运行 {seconds} </div>;
}

当这个组件卸载后,定时器还在运行,但组件已经不存在了,这就是典型的内存泄漏。

解决方案:在 useEffect 中返回一个清理函数,React 会在组件卸载或副作用重新执行前调用它。

// 正确示例:正确清理资源
function TimerComponent() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    // 返回清理函数,组件卸载时会自动调用
    return () => {
      clearInterval(timer);
    };
  }, []);

  return <div>已运行 {seconds} </div>;
}

性能问题:不必要的重新渲染

有时候你的 useEffect 虽然没有 bug,但执行得太频繁了,导致性能问题。这通常是因为依赖数组包含了不必要的依赖,或者副作用中的操作太昂贵。

典型场景:依赖数组中包含了一个对象或数组,每次组件重新渲染时,即使对象内容没变,引用变了也会触发 useEffect

// 性能问题示例
function ExpensiveComponent({ user }) {
  useEffect(() => {
    // 执行昂贵的操作,比如复杂的计算或 API 调用
    doExpensiveOperation(user);
  }, [user]); // user 对象每次渲染都是新引用,即使内容相同

  return <div>...</div>;
}

解决方案:使用 useMemouseCallback 来稳定依赖项的引用,或者将依赖项细化到具体的值而不是整个对象。

意外依赖:难以追踪的 bug

有时候 useEffect 的行为和你预期的不一样,可能是因为依赖数组不完整,或者包含了不应该包含的依赖。

典型场景:你在 useEffect 中使用了一个变量,但忘记把它加到依赖数组中。ESLint 可能会警告你,但如果你忽略了警告,就会出现难以追踪的 bug。

// 意外依赖示例
function Component({ userId }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    // 使用了 userId,但忘记加到依赖数组
    fetchData(userId).then(setData);
  }, []); // 缺少 userId 依赖

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

userId 变化时,数据不会重新获取,因为 useEffect 只在挂载时执行了一次。

解决方案:使用 ESLint 的 eslint-plugin-react-hooks 插件,它会自动检测缺失的依赖。同时,仔细思考每个依赖是否真的需要触发副作用。

使用 useEffect 的最佳实践

了解了这些坑之后,咱们来看看如何优雅地使用 useEffect,避免踩坑。

最小化依赖项

依赖数组就像是一份购物清单,只列出真正需要的东西。不要把所有东西都扔进去,那样会导致不必要的重新执行。

实践建议:仔细分析副作用函数中用到的每个变量,只把那些变化时需要重新执行副作用的变量加入依赖数组。如果某个变量只是用来读取,但它的变化不应该触发副作用,可以考虑用 useRef 来存储。

// 好的实践:只包含必要的依赖
function UserProfile({ userId }) {
  const [profile, setProfile] = useState(null);

  useEffect(() => {
    // 只在 userId 变化时重新获取
    fetchUserProfile(userId).then(setProfile);
  }, [userId]); // 只依赖 userId

  return <div>{profile?.name}</div>;
}

使用清理函数

清理函数就像是离开房间前的检查清单,确保所有资源都被正确释放。这对于防止内存泄漏至关重要。

实践建议:只要在 useEffect 中创建了需要清理的资源(定时器、订阅、事件监听器等),就一定要返回清理函数。

// 好的实践:正确清理资源
function ChatRoom({ roomId }) {
  useEffect(() => {
    // 订阅聊天消息
    const subscription = subscribeToMessages(roomId, (message) => {
      console.log('新消息:', message);
    });

    // 返回清理函数,取消订阅
    return () => {
      subscription.unsubscribe();
    };
  }, [roomId]);

  return <div>聊天室 {roomId}</div>;
}

合理利用依赖数组

依赖数组是控制副作用执行时机的关键。理解不同配置的含义,可以帮助你写出更高效的代码。

实践建议

  • 只在挂载时执行:使用空数组 []
  • 依赖特定值变化:将这些值加入数组 [value1, value2]
  • 每次渲染都执行:不传第二个参数(通常不推荐)
// 不同场景的依赖数组配置
function ExampleComponent({ id, filter }) {
  // 场景1:只在挂载时执行一次
  useEffect(() => {
    initializeComponent();
  }, []);

  // 场景2:当 id 变化时重新执行
  useEffect(() => {
    loadData(id);
  }, [id]);

  // 场景3:当 id 或 filter 任一变化时重新执行
  useEffect(() => {
    loadFilteredData(id, filter);
  }, [id, filter]);
}

考虑自定义钩子

useEffect 的逻辑变得复杂或者需要在多个组件中复用时,提取成自定义钩子是个不错的选择。这样既能提高代码复用性,又能让组件代码更清晰。

实践建议:将数据获取、订阅管理等逻辑封装成自定义钩子,比如 useFetchuseSubscription 等。

// 自定义钩子:封装数据获取逻辑
function useFetchData(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 重置状态
    setLoading(true);
    setError(null);

    // 获取数据
    fetch(url)
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [url]); // url 变化时重新获取

  return { data, loading, error };
}

// 在组件中使用
function UserList() {
  const { data, loading, error } = useFetchData('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;

  return (
    <ul>
      {data?.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

useEffect 的替代方案

虽然 useEffect 很强大,但在某些场景下,其他方案可能更合适。了解这些替代方案,可以帮助你选择最合适的工具。

useCallback 和 useMemo:性能优化的利器

useCallbackuseMemo 主要用于性能优化,它们可以避免不必要的重新计算和重新渲染。

使用场景:当你有一个函数或值需要在 useEffect 的依赖数组中使用,但这个函数或值在每次渲染时都会重新创建,导致 useEffect 频繁执行。

function ParentComponent({ userId }) {
  // 使用 useCallback 记忆化回调函数
  const handleUserUpdate = useCallback((userData) => {
    updateUser(userId, userData);
  }, [userId]);

  // 使用 useMemo 记忆化计算结果
  const expensiveValue = useMemo(() => {
    return computeExpensiveValue(userId);
  }, [userId]);

  return <ChildComponent onUpdate={handleUserUpdate} value={expensiveValue} />;
}

Context API:全局状态管理

当需要在多个组件之间共享状态或副作用逻辑时,Context API 比通过 props 层层传递更优雅。

使用场景:主题切换、用户认证状态、全局配置等需要在多个组件中共享的状态。

// 创建 Context
const ThemeContext = React.createContext();

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

  useEffect(() => {
    // 应用主题到 document
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

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

// 在组件中使用
function ThemedButton() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      切换主题
    </button>
  );
}

useRef:存储不触发渲染的值

useRef 可以用来存储一个在组件生命周期内保持不变的值,而且更新它不会触发重新渲染。

使用场景:存储定时器 ID、保存上一次的值、直接操作 DOM 元素等。

function ComponentWithRef() {
  const inputRef = useRef(null);
  const previousValueRef = useRef(null);
  const [value, setValue] = useState('');

  useEffect(() => {
    // 保存上一次的值
    previousValueRef.current = value;
  }, [value]);

  const focusInput = () => {
    // 直接操作 DOM
    inputRef.current?.focus();
  };

  return (
    <div>
      <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} />
      <button onClick={focusInput}>聚焦输入框</button>
      <p>上一次的值: {previousValueRef.current}</p>
    </div>
  );
}

类组件:遗留代码的兼容方案

虽然现在函数组件是主流,但在维护遗留代码库时,了解类组件的生命周期方法还是有必要的。

使用场景:维护使用类组件的旧项目,或者需要更精细的生命周期控制。

class LegacyComponent extends React.Component {
  componentDidMount() {
    // 组件挂载后执行,相当于 useEffect(() => {}, [])
    this.fetchData();
    this.timer = setInterval(() => {
      this.updateTime();
    }, 1000);
  }

  componentDidUpdate(prevProps) {
    // 组件更新后执行,相当于 useEffect(() => {}, [prop])
    if (this.props.userId !== prevProps.userId) {
      this.fetchData();
    }
  }

  componentWillUnmount() {
    // 组件卸载前执行,相当于 useEffect 的清理函数
    clearInterval(this.timer);
  }

  fetchData() {
    // 获取数据
  }

  updateTime() {
    // 更新时间
  }

  render() {
    return <div>...</div>;
  }
}

FunTester 原创精华
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册