Hook18.0+

useEffect

在函数组件中执行副作用操作

什么是副作用?

在 React 中,副作用 (Side Effect) 是指组件渲染之外的操作。副作用包括:

  • 数据获取 (API 请求)
  • 订阅外部数据源
  • 手动修改 DOM
  • 定时器 (setTimeout、setInterval)
  • 日志记录
为什么叫副作用?

在计算机科学中,纯函数是指给定相同输入总是返回相同输出, 并且不产生任何可观察的副作用的函数。 React 组件的渲染应该是纯的,而“副作用”是渲染之外的操作。

基本用法

导入 useEffect

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

简单的副作用

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

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

  useEffect(() => {
    // 更新文档标题
    document.title = `点击了 ${count} 次`;
  });

  return (
    <button onClick={() => setCount(count + 1)}>
      点击了 {count} 次
    </button>
  );
}

工作原理

useEffect 会在每次渲染后执行副作用函数。 React 会在 DOM 更新后调用副作用函数。

思维转换

如果你有 React 类组件的经验,useEffect 相当于 componentDidMountcomponentDidUpdatecomponentWillUnmount 的组合。

依赖数组

跳过不必要的重新执行

默认情况下,Effect 会在每次渲染后执行。使用依赖数组可以控制何时执行 Effect:

TypeScript
const [count, setCount] = useState(0);

// 每次渲染后都执行
useEffect(() => {
  console.log('每次渲染都执行');
});

// 只在组件挂载时执行一次
useEffect(() => {
  console.log('只执行一次');
}, []);

// 只在 count 改变时执行
useEffect(() => {
  console.log('count 改变了:', count);
}, [count]);

多个依赖

TypeScript
const [name, setName] = useState('');
const [age, setAge] = useState(0);

useEffect(() => {
  console.log('name 或 age 改变了');
}, [name, age]);
重要

永远不要对 Effect 依赖撒谎。 如果 Effect 使用了某个变量,必须将其添加到依赖数组中。 React 会使用 ESLint 规则来帮助你捕获遗漏的依赖。

清理函数

为什么需要清理?

某些副作用需要清理,例如:

  • 订阅外部数据源
  • 定时器
  • 手动添加的事件监听器

返回清理函数

TypeScript
useEffect(() => {
  // 副作用
  const timer = setInterval(() => {
    console.log('定时器运行中...');
  }, 1000);

  // 清理函数
  return () => {
    clearInterval(timer);
  };
}, []);

清理时机

TypeScript
useEffect(() => {
  // 1. 组件挂载时执行
  console.log('挂载');

  return () => {
    // 2. 组件卸载前执行
    // 或者在下次 effect 执行前执行
    console.log('清理');
  };
});

实际示例:订阅

TypeScript
function Chat({ userId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    // 创建订阅
    const subscription = chatAPI.subscribe(userId, (newMessages) => {
      setMessages(newMessages);
    });

    // 清理订阅
    return () => {
      subscription.unsubscribe();
    };
  }, [userId]); // 只在 userId 改变时重新订阅

  return (
    <ul>
      {messages.map(msg => (
        <li key={msg.id}>{msg.text}</li>
      ))}
    </ul>
  );
}
最佳实践

总是返回清理函数,即使 Effect 没有明显的清理需求。 这可以防止内存泄漏和意外行为。

常见模式

1. 数据获取

TypeScript
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    fetchUser(userId)
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error error={error} />;
  return <Profile user={user} />;
}

2. 修改 DOM

TypeScript
function ScrollIndicator() {
  const [scrollTop, setScrollTop] = useState(0);

  useEffect(() => {
    const handleScroll = () => {
      setScrollTop(window.scrollY);
    };

    window.addEventListener('scroll', handleScroll);

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

  return (
    <div style={{ width: `${scrollTop}%` }}>
      滚动指示器
    </div>
  );
}

3. 定时器

TypeScript
function Countdown({ seconds }) {
  const [timeLeft, setTimeLeft] = useState(seconds);

  useEffect(() => {
    const timer = setInterval(() => {
      setTimeLeft(prev => prev - 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>剩余时间: {timeLeft} 秒</div>;
}

4. 条件执行

TypeScript
function Notification({ showMessage }) {
  useEffect(() => {
    if (showMessage) {
      // 只在 showMessage 为 true 时显示通知
      showNotification('Hello!');
    }
  }, [showMessage]);
}
注意

不要在 Effect 中使用条件包裹。 如果需要条件执行,应该在 Effect 函数内部添加条件:

TypeScript
// ❌ 错误
if (condition) {
  useEffect(() => {
    // ...
  });
}

// ✅ 正确
useEffect(() => {
  if (condition) {
    // ...
  }
});

数据获取

基本模式

推荐做法

使用 AbortController 来取消未完成的请求,这是处理竞态条件的最佳实践。 它不仅能防止状态更新,还能真正取消网络请求,节省资源。

使用 AbortController

TypeScript
useEffect(() => {
  const controller = new AbortController();

  fetchUser(userId, { signal: controller.signal })
    .then(data => setData(data))
    .catch(error => {
      if (error.name !== 'AbortError') {
        setError(error);
      }
    });

  return () => {
    controller.abort();
  };
}, [userId]);

封装自定义 Hook

TypeScript
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          setError(error);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// 使用
function UserList() {
  const { data: users, loading, error } = useFetch('/api/users');

  if (loading) return <Spinner />;
  if (error) return <Error error={error} />;
  return <UserList users={users} />;
}
use() Hook 的用途

React 提供了 use() Hook(React 19+),用于在 Suspense 边界中读取资源(如 Promise 和 Context)。 但 useEffect 仍然是处理副作用的标准方式。 use() 主要用于 Server Components 和 Suspense 场景,不是用来替代 useEffect 的数据获取。

Effect 的生命周期

执行顺序

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

  console.log('1. 组件渲染');

  useEffect(() => {
    console.log('2. Effect 执行(渲染后)');

    return () => {
      console.log('3. 清理函数(卸载前或下次 effect 前)');
    };
  }, [count]);

  console.log('4. 组件渲染完成');
}

类组件对比

类组件函数组件 + useEffect
componentDidMountuseEffect(() => { ... }, [])
componentDidUpdateuseEffect(() => { ... }, [deps])
componentWillUnmountuseEffect(() => { return cleanup; }, [])
思维模型

不要把 useEffect 看作“在特定生命周期调用函数”。 相反,应该将其视为“将 React 组件与外部系统同步”。

常见错误

1. 缺少依赖

TypeScript
// ❌ 错误:缺少依赖
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(id);
}, []); // 缺少 count 依赖

// ✅ 正确:添加依赖
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

2. 在条件中使用 useEffect

TypeScript
// ❌ 错误:在条件中调用
if (condition) {
  useEffect(() => {
    // ...
  });
}

// ✅ 正确:在 Effect 内部使用条件
useEffect(() => {
  if (condition) {
    // ...
  }
}, [condition]);

3. 在渲染中执行副作用

TypeScript
// ❌ 错误:在渲染中执行副作用
function Component() {
  fetchAPI(); // 不要在渲染中执行

  return <div />;
}

// ✅ 正确:在 useEffect 中执行
function Component() {
  useEffect(() => {
    fetchAPI();
  }, []);

  return <div />;
}

4. 使用 async 函数

TypeScript
// ❌ 错误:不能直接使用 async 函数
useEffect(async () => {
  const data = await fetchAPI();
}, []);

// ✅ 正确:在内部定义 async 函数
useEffect(() => {
  async function fetchData() {
    const data = await fetchAPI();
  }
  fetchData();
}, []);

// 或使用 IIFE
useEffect(() => {
  (async () => {
    const data = await fetchAPI();
  })();
}, []);

5. 无限循环

TypeScript
// ❌ 错误:无限循环
useEffect(() => {
  setCount(count + 1); // 触发重新渲染
}, [count]); // 依赖改变,再次执行 Effect

// 无限循环发生的过程:
// 1. count 改变为 count + 1
// 2. 状态改变触发组件重新渲染
// 3. Effect 检测到依赖 count 发生变化
// 4. 再次执行 Effect,再次调用 setCount(count + 1)
// 5. 回到步骤 1,无限循环...

// ✅ 正确:移除依赖或使用函数更新
useEffect(() => {
  setCount(c => c + 1);
}, []); // 只执行一次,因为没有依赖
ESLint 规则

React 提供了 ESLint 插件 react-hooks/exhaustive-deps, 它会帮助你捕获这些常见错误。 总是遵循 ESLint 的警告!

性能优化

避免不必要的 Effect

TypeScript
// ❌ 不必要的 Effect
function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  // ...
}

// ✅ 直接计算
function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  const fullName = `${firstName} ${lastName}`;

  // ...
}

使用 useMemo 缓存计算

TypeScript
import { useMemo } from 'react';

function ExpensiveComponent({ items, filter }) {
  // 只在 items 或 filter 改变时重新计算
  const filteredItems = useMemo(() => {
    return items.filter(item => item.type === filter);
  }, [items, filter]);

  return <List items={filteredItems} />;
}

拆分 Effect

TypeScript
// ❌ 一个 Effect 处理多个不相关的状态
useEffect(() => {
  const sub1 = subscribe1();
  const sub2 = subscribe2();
  return () => {
    sub1.unsubscribe();
    sub2.unsubscribe();
  };
}, []);

// ✅ 拆分为多个 Effect
useEffect(() => {
  const sub = subscribe1();
  return () => sub.unsubscribe();
}, []);

useEffect(() => {
  const sub = subscribe2();
  return () => sub.unsubscribe();
}, []);
优化原则

优先考虑直接计算而不是使用 Effect。 Effect 应该只用于与外部系统的交互。

最佳实践

1. 描述依赖关系

TypeScript
// ✅ 好:所有依赖都明确声明
useEffect(() => {
  fetchData(userId, options);
}, [userId, options]);

2. 使用 ESLint 规则

确保 react-hooks/exhaustive-deps 规则已启用。

3. 保持 Effect 简单

TypeScript
// ✅ 好:逻辑清晰的 Effect
useEffect(() => {
  const subscription = subscribe();
  return () => subscription.unsubscribe();
}, [source]);

4. 提取自定义 Hook

TypeScript
// 提取可复用的逻辑
function useChatSubscription(userId) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const sub = chatAPI.subscribe(userId, setMessages);
    return () => sub.unsubscribe();
  }, [userId]);

  return messages;
}

5. 只同步必要的部分

TypeScript
// ❌ 同步整个对象
useEffect(() => {
  setUser(user);
}, [user]);

// ✅ 只同步需要的属性
useEffect(() => {
  setUser({ name: user.name });
}, [user.name]);

常见问题

Effect 没有执行?

检查依赖数组是否为空数组 []。 空数组表示只在挂载时执行一次。

Effect 不断执行?

可能是依赖数组中包含了对象或数组, 它们在每次渲染时都是新的引用。 使用 useMemouseCallback 来稳定引用。

清理函数没有执行?

清理函数只在组件卸载或依赖变化时执行。 确保你正确返回了清理函数。

如何在 Effect 中使用 async?

TypeScript
useEffect(() => {
  async function fetchData() {
    const result = await fetchAPI();
    setData(result);
  }
  fetchData();
}, []);

相关 Hooks

这篇文章有帮助吗?