第 6 章

useEffect:副作用的正确使用方式

第6章:useEffect:副作用的正确使用方式

useEffect 的核心语义是将 React 状态与外部系统同步。当你把它理解成'渲染后执行代码'时,各种 bug 就随之而来。

本章核心问题:useEffect 的核心语义是什么?依赖数组的契约意味着什么? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

"副作用"到底意味着什么

React 组件的职责是根据当前的 props 和 state 返回 UI 描述。一切与这个职责无关的操作,都叫副作用(side effect):网络请求、订阅事件、修改 DOM、读写 localStorage、设置定时器……

useEffect 的核心语义是:将 React 状态与外部系统同步。注意措辞——是"同步",不是"监听"、不是"在某个时机执行某段代码"。

当你把 useEffect 理解成"在渲染后执行代码"时,你就会把它用成事件处理器的替代品,产生各种 bug。当你把它理解成"让外部世界与当前状态保持同步"时,useEffect 的行为就会变得清晰可预测。

// 错误理解:"组件挂载时发一次请求"
useEffect(() => {
  fetchUser(userId);
}, []);

// 正确理解:"让用户数据与 userId 保持同步"
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]); // userId 变了就重新同步

依赖数组契约

useEffect(fn, deps) 的依赖数组不是"触发条件",而是一个正确性契约:Effect 内部使用了哪些来自组件作用域的响应式值(props、state、派生计算),就必须在依赖数组里列出。

这个契约是 ESLint 插件 eslint-plugin-react-hooksexhaustive-deps 规则强制的。很多人把这个 lint 报错当成烦人的警告来绕过,这是危险的。

为什么依赖必须完整?

function UserCard({ userId }) {
  const [user, setUser] = useState(null);

  // 错误:依赖数组漏掉了 userId
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // 只在挂载时运行,userId 改变时不重新同步

  // 正确:依赖完整
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);
}

漏掉依赖的后果:组件挂载时用 userId = 1 获取了数据,后来 userId 变成了 2,Effect 不会重新运行,你永远显示的是用户 1 的数据。这是一个经典的过期依赖 bug

依赖过多时怎么办?

有时依赖数组变得很长,暗示着 Effect 做的事太多。解决方案不是删减依赖,而是拆分 Effect重构逻辑

// Effect 做了两件不相关的事
useEffect(() => {
  document.title = `用户:${username}`;
  analytics.track('page_view', { page });
}, [username, page]);

// 拆分:两件事独立同步
useEffect(() => {
  document.title = `用户:${username}`;
}, [username]);

useEffect(() => {
  analytics.track('page_view', { page });
}, [page]);

Level 2 · 它是怎么运行的(3-5年经验)

cleanup 函数:副作用的生命周期

useEffect 返回的函数叫做 cleanup(清理函数)。它的执行时机有两个:

  1. 下一次 Effect 运行之前(依赖变化时)
  2. 组件卸载时

这个语义设计让 React 能正确地"清理旧的同步,建立新的同步":

useEffect(() => {
  // 建立同步:订阅 userId 的数据流
  const subscription = dataStream.subscribe(userId, setData);

  return () => {
    // 清理旧同步:取消订阅
    subscription.unsubscribe();
  };
}, [userId]);

userId1 变为 2

  1. React 先调用上一次 Effect 的 cleanup(取消对用户 1 的订阅)
  2. 再运行新的 Effect(订阅用户 2 的数据流)

这种"先清理,再同步"的模式确保了不会有悬空的订阅或泄露的资源。

网络请求的正确清理

不清理的网络请求会导致两个问题:组件卸载后更新状态(React 警告),以及竞态条件(旧请求覆盖新结果)。

useEffect(() => {
  let cancelled = false;

  fetchUser(userId).then(data => {
    if (!cancelled) {
      setUser(data);
    }
  });

  return () => {
    cancelled = true; // 标记取消,忽略旧请求结果
  };
}, [userId]);

// 更现代的方式:AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

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

AbortController 是浏览器原生 API,它不仅能避免状态更新问题,还能真正取消飞行中的 HTTP 请求,减少服务器负载。

严格模式的双重调用

React 18 的严格模式(<React.StrictMode>)在开发环境中会故意调用 Effect 两次

  1. 挂载 → 运行 Effect
  2. 卸载 → 运行 cleanup
  3. 再挂载 → 再运行 Effect

这个设计是为了暴露没有正确实现 cleanup 的 Effect。如果你的 Effect 被调用两次后行为异常(比如弹出两次弹窗,或者建立了两个 WebSocket 连接),说明你的 cleanup 不完整。

// 问题:没有 cleanup,双重调用会建立两个 WebSocket
useEffect(() => {
  const ws = new WebSocket('wss://example.com');
  ws.onmessage = handleMessage;
}, []);

// 正确:cleanup 关闭连接
useEffect(() => {
  const ws = new WebSocket('wss://example.com');
  ws.onmessage = handleMessage;
  return () => ws.close();
}, []);

严格模式只在开发环境生效,生产环境 Effect 只运行一次。但如果你的代码在开发环境的双重调用下工作不正确,很可能在生产环境的某些边缘情况下也会出问题(比如组件被 Suspense 挂起后重新挂载)。

useEffect 的替代品

React 提供了三个 Effect Hook,适用于不同场景:

useLayoutEffect

useLayoutEffect 在 DOM 更新之后、浏览器绘制之前同步运行。适合需要读取 DOM 布局并立即修改的场景:

useLayoutEffect(() => {
  // DOM 已更新,但浏览器还没有绘制
  const height = ref.current.getBoundingClientRect().height;
  setTooltipHeight(height); // 在绘制前同步更新,避免闪烁
}, []);

如果用 useEffect 做同样的事,因为 Effect 在绘制之后运行,你会看到一帧的闪烁。useLayoutEffect 牺牲了响应性(它是同步的,会阻塞绘制),换来了视觉上的一致性。

SSR 注意useLayoutEffect 在服务端渲染时会报警告,因为服务端没有 DOM。如果必须在 SSR 场景中使用,用条件判断包裹,或者用 useEffect + 状态来规避闪烁问题。

useInsertionEffect

useInsertionEffect 是为 CSS-in-JS 库设计的,在 DOM 变更之前运行,比 useLayoutEffect 还早。你在应用代码中几乎不会直接用它,它是给 styled-components、emotion 这类库内部使用的。


Level 3 · 规范怎么定义的(资深)

React 19:use() Hook 与数据获取

React 19 引入了 use() Hook,它可以直接在渲染期间消费 Promise 和 Context,配合 Suspense 使用:

import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  // use() 会挂起组件,直到 Promise resolve
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

// 父组件负责创建 Promise(注意:不能在渲染中创建,否则每次都是新 Promise)
function App() {
  const [userPromise] = useState(() => fetchUser(1));

  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

use() 并非完全替代 useEffect 的数据获取,但它代表了 React 团队推荐的方向:数据获取应该由框架层(Next.js、Remix)或专门的数据获取库(React Query、SWR)处理,而不是散落在组件的 useEffect 中。

为什么 useEffect 做数据获取有局限?

use() + Suspense + 服务端组件的组合解决了这些问题的根源,而不是在症状层面打补丁。

一个完整的数据同步示例

把本章的知识整合起来,实现一个生产级别的数据获取 Effect:

function useUserData(userId: string | null) {
  const [state, setState] = useState<{
    data: User | null;
    loading: boolean;
    error: Error | null;
  }>({ data: null, loading: false, error: null });

  useEffect(() => {
    if (!userId) return; // 条件判断不能在 Effect 外部

    const controller = new AbortController();
    setState(prev => ({ ...prev, loading: true, error: null }));

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState({ data: null, loading: false, error: err });
        }
      });

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

  return state;
}

这个自定义 Hook 正确处理了:依赖追踪、loading 状态、错误状态、请求取消(防止组件卸载后更新状态和竞态条件)。


Level 4 · 边界与陷阱(所有人)

useEffect 的常见错误模式

模式一:在 Effect 内部响应事件

// 错误:Effect 用来响应按钮点击
useEffect(() => {
  if (submitted) {
    sendForm(formData);
  }
}, [submitted]);

// 正确:事件直接在事件处理器里处理
function handleSubmit() {
  sendForm(formData);
  setSubmitted(true);
}

用 Effect 来响应用户事件是一种反模式。事件是一次性的离散动作,不需要"同步"语义。

模式二:链式 Effect 更新状态

// 错误:用 Effect 链来派生状态
const [data, setData] = useState(null);
const [filtered, setFiltered] = useState([]);

useEffect(() => {
  if (data) {
    setFiltered(data.filter(item => item.active));
  }
}, [data]);

// 正确:在渲染时直接派生
const filtered = data ? data.filter(item => item.active) : [];

每多一个"更新状态的 Effect",就多一次不必要的渲染。能在渲染期间计算的值,就应该在渲染期间计算。

模式三:对象和函数作为依赖

function Component({ config }) {
  useEffect(() => {
    doSomething(config);
  }, [config]); // 如果 config 是每次渲染新创建的对象,Effect 每次都会运行
}

// 父组件
<Component config={{ timeout: 3000 }} /> // 每次渲染都创建新对象

对象字面量在每次渲染时都是新的引用,即使内容相同。解决方案是在父组件 memoize 对象,或者在 Effect 内部只解构需要的原始值:

useEffect(() => {
  doSomething(config.timeout);
}, [config.timeout]); // 只依赖原始值
本章评分
4.7  / 5  (56 评分)

💬 留言讨论