React 从入门到生产(三):副作用与数据获取

创作者: Yardon | GitHub: github.com/YardonYan | 版本: v1.0


什么是副作用

在讨论 useEffect 之前,我们需要先理解「副作用」这个词在编程里的含义。

一个函数如果只根据输入计算输出,不做任何其他事情------比如读取参数、做计算、返回结果------我们就说它是纯函数useState 返回的 UI 描述就是纯函数:你给同样的 props,返回同样的 JSX。

但现实世界不是那么干净的。网页需要:

  • 调用 API 获取数据
  • 订阅浏览器事件(滚动、窗口大小变化)
  • 设置定时器(setTimeout、setInterval)
  • 操作 DOM(聚焦输入框、滚动页面)
  • 记录日志到控制台

这些动作有一个共同特点:它们超出了"描述 UI"的范围,触达了外部世界。在 React 里,这类行为统一叫做「副作用(Side Effects)」。

副作用 = 任何影响组件外事物的行为


useEffect 的基本用法

useEffect 的签名大概是 React 里最容易被误解的 API。

jsx 复制代码
import { useEffect } from 'react';

useEffect(() => {
  // 这里是副作用逻辑
  // ...
}, [dependencies]);  // 依赖数组

这行代码翻译成人话是:

"每次当 dependencies 数组里的任何一个值变了,就运行这个副作用。组件第一次渲染时也要跑一次。"

三种执行时机

时机 1:只跑一次(组件挂载时)

jsx 复制代码
useEffect(() => {
  console.log('组件挂载了,只跑这一次');
}, []);  // 空数组 = 永远不重复

空数组的意思是"没有任何依赖项",所以只在首次渲染时运行一次。这常用于:发送初始请求、设置一次性事件监听、操作 DOM(比如聚焦输入框)。

时机 2:依赖某变量,每次变化都跑

jsx 复制代码
useEffect(() => {
  document.title = `你看了 ${count} 次`;
}, [count]);  // count 变了就重新运行

时机 3:没有任何依赖,每次渲染都跑

jsx 复制代码
useEffect(() => {
  console.log('每次渲染都跑');
  // 几乎不用,除非你知道你在干什么
});

useEffect 不是 componentDidMount / componentWillUnmount

很多从类组件转过来的开发者会把 useEffect 当成 componentDidMount 来用,这是个思维陷阱。

类组件时代:

jsx 复制代码
componentDidMount() {
  this.fetchData();
}

componentWillUnmount() {
  this.timer && clearInterval(this.timer);
}

函数组件时代:

jsx 复制代码
useEffect(() => {
  fetchData();
  return () => clearInterval(this.timer);
}, []);

关键是 return 出去的函数就是清理函数------它会在下次 effect 重新运行前和组件卸载时自动调用。把它想象成「拆帐篷」:搭帐篷之前先想好怎么拆,你就不会留下烂摊子。


依赖数组:控制何时运行

依赖数组是 useEffect 最容易出错的地方。规则只有一条,但很多人理解歪了:

声明你用到的所有变量,一个都不能少,一个也不能多。

jsx 复制代码
function SearchBar({ query }) {
  useEffect(() => {
    const timeout = setTimeout(() => {
      console.log('搜索:', query);
      // 发请求...
    }, 300);
    return () => clearTimeout(timeout);  // 清理上次的定时器
  }, [query]);  // 依赖 query
}

这个例子里的防抖逻辑:

  • 用户输入时,query 变了
  • 旧的 effect 清理(清除旧定时器)
  • 新的 effect 执行(设新定时器)
  • 如果用户在 300ms 内继续输入,只会触发最后一次的搜索

常见错误:遗漏依赖

jsx 复制代码
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, []);  // ❌ 少了 userId!第一次后永远不会再请求
}

eslint 会警告你这个错误,但很多人会习惯性加 // eslint-disable-next-line。别这样做------遗漏依赖意味着 effect 的逻辑已经和它实际使用的数据不同步了。

常见错误:把函数放进依赖数组

jsx 复制代码
function App() {
  function handleClick() {
    console.log('clicked');
  }

  useEffect(() => {
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [handleClick]);  // ❌ handleClick 在每次渲染时是新函数
}

函数依赖的问题在于:每次父组件渲染,子组件都会得到一个新的 handleClick 函数引用(即使内容完全相同)。这会触发 effect 反复运行。

解决方案:把函数移到 effect 内部 (如果它只用到了 props/hooks 的数据)或者用 useCallback(如果需要在 effect 外复用同一个引用)。

jsx 复制代码
useEffect(() => {
  function handleClick() {
    console.log('clicked');
  }
  document.addEventListener('click', handleClick);
  return () => document.removeEventListener('click', handleClick);
}, []);  // ✅ 没有函数依赖了

数据获取:最常见的副作用

最简单的方式

jsx 复制代码
function BlogList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/posts')
      .then(res => {
        if (!res.ok) throw new Error('请求失败');
        return res.json();
      })
      .then(data => {
        setPosts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了: {error}</div>;
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

这个例子展示了数据获取的标准模式:loading → success/error 三种状态。每一个真实项目里的数据请求都会遇到这三种情况。

更现代的方式:async/await

jsx 复制代码
useEffect(() => {
  async function fetchData() {
    try {
      setLoading(true);
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      setUser(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }
  fetchData();
}, [userId]);

async/await 让代码看起来更像同步的线性流程,比嵌套的 .then().then().catch() 更好读。唯一的注意点:async 函数不能直接传给 useEffect(因为 useEffect 希望返回值是 undefined 或一个清理函数),所以要在里面再套一个 async 函数。


useEffect 的清理函数

清理函数是避免内存泄漏和 bug 的关键。看看几个典型场景:

场景 1:订阅/取消订阅

jsx 复制代码
useEffect(() => {
  const handler = (e) => console.log('窗口滚动了', e);
  window.addEventListener('scroll', handler);

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

如果不清理,组件卸载后事件处理器还在运行------用户滚动页面时,React 试图更新一个已经不存在的组件,报错。内存泄漏只是时间问题。

场景 2:定时器

jsx 复制代码
useEffect(() => {
  const interval = setInterval(() => {
    setSeconds(s => s + 1);
  }, 1000);

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

场景 3:撤销副作用对 DOM 的修改

如果你的 effect 在 DOM 上做了修改(比如给元素加了类、修改了样式),卸载时应该恢复原状:

jsx 复制代码
useEffect(() => {
  const sidebar = document.querySelector('.sidebar');
  sidebar.classList.add('open');  // 打开

  return () => sidebar.classList.remove('open');  // 关闭
}, []);

常见错误:死循环与竞态

死循环

jsx 复制代码
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1);  // ❌ 每次渲染都触发,又触发渲染,死循环!
}, []);

另一个常见的死循环是把 state 放进依赖数组但同时在 effect 里更新它:

jsx 复制代码
useEffect(() => {
  if (data && data.length > 0) {
    setTotal(data.length);  // 更新 total
  }
}, [data]);

useEffect(() => {
  fetchData();  // 每次 total 变都重新请求
}, [total]);  // ❌ 总在变化

经验:effect 里更新 state 时,一定要注意依赖数组里有没有被更新的那个 state。

竞态:最容易被忽视的 bug

当你在 effect 里发请求,但数据回来之前组件已经卸载或者又触发了一次新的请求------就可能出现竞态。

想象你搜索"北京",然后立刻搜索"上海":

  • 请求 A("北京")发出去
  • 请求 B("上海")发出去
  • 请求 A 慢,请求 B 先回来 → 页面显示北京
  • 请求 A 终于回来了 → 页面又变成北京,覆盖了上海!

这就是经典的竞态条件。

解决方案:用 AbortController 取消旧请求

jsx 复制代码
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setResults(data))
    .catch(err => {
      if (err.name !== 'AbortError') throw err;  // 忽略主动取消的错误
    });

  return () => controller.abort();  // 组件卸载或 query 变时取消
}, [query]);

跳过 Effect 的正确方式

有时候你不想让 effect 在某些情况下运行。React 提供的写法:

jsx 复制代码
useEffect(() => {
  if (userId) {  // 只有 userId 存在时才发请求
    fetchUser(userId);
  }
}, [userId]);

不要试图用空依赖数组来"跳过"条件逻辑------useEffect 的设计要求你把条件放在 effect 内部,而非条件性地决定要不要调用 useEffect


实战:从 API 获取数据

把这一章的概念综合起来,写一个带加载状态的真实数据请求:

jsx 复制代码
function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // 获取用户信息
  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        const [userRes, postsRes] = await Promise.all([
          fetch(`/api/users/${userId}`, { signal: controller.signal }),
          fetch(`/api/users/${userId}/posts`, { signal: controller.signal })
        ]);

        if (!userRes.ok || !postsRes.ok) throw new Error('加载失败');

        const [userData, postsData] = await Promise.all([
          userRes.json(),
          postsRes.json()
        ]);

        setUser(userData);
        setPosts(postsData);
      } catch (err) {
        if (err.name !== 'AbortError') setError(err.message);
      } finally {
        setLoading(false);
      }
    }

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

  if (loading) return <Skeleton />;
  if (error) return <ErrorMessage msg={error} />;
  return (
    <div>
      <UserCard user={user} />
      <PostList posts={posts} />
    </div>
  );
}

Promise.all 同时发两个请求节省时间;AbortController 处理竞态。50 行代码里有真实的工程考量。


本章小结

概念 一句话总结
副作用 触达组件外部的行为(API 调用、事件监听、DOC 操作)
useEffect 依赖变化时运行副作用,支持清理函数
依赖数组 漏依赖 = bug,函数依赖 = 死循环,尽量把函数放 effect 内部
清理函数 防止内存泄漏、事件残留、定时器堆积
竞态 多请求并发时旧请求覆盖新结果,用 AbortController 取消
Promise.all 同时发多个请求,节省等待时间

到了这里,你应该对 React 的核心概念有了完整理解。组件、状态、副作用------这三样东西构成了 99% 的 React 应用基础。下一章我们聊 自定义 Hook------如何把逻辑抽出来复用。


📌 创作者: Yardon | 🏠 个人网站: GlimmerAI.top

📖 本章是「React 从入门到生产 」系列的第 3 章。上一章:状态与事件处理 | 下一章:自定义 Hook

🌟 如果你觉得有帮助,欢迎访问 GlimmerAI.top 查看我的更多作品。欢迎大家来观看!

相关推荐
微祎_11 小时前
写给前端的 CANN-ops-transformer:昇腾Transformer进阶算子库到底是啥?
前端·深度学习·transformer
Cobyte11 小时前
12.响应式系统演进:揭秘多级脏检查机制的设计哲学与实现原理(Vue3.4)
前端·javascript·vue.js
XMAIPC_Robot11 小时前
RK3588 PLC AMP 核隔离配置 + RT‑Thread 实时优化 + FPGA 接口定义 + CODESYS 工程
人工智能·嵌入式硬件·深度学习·fpga开发
前端若水11 小时前
技术选型:React 19 + TypeScript + TailwindCSS
前端·react.js·typescript
木斯佳11 小时前
前端八股文面经大全:携程前端暑期实习一面(2026-05-14)·面经深度解析
前端
卸任11 小时前
为Tiptap富文本编辑器增加导出PDF功能
前端·react.js
ZC跨境爬虫11 小时前
跟着 MDN 学CSS day_1:(CSS 基石与色彩的艺术)
前端·javascript·css·ui·html
灰灰勇闯IT11 小时前
hixl 单边通信:昇腾推理的高效互联通道
人工智能·深度学习·机器学习
NiceCloud喜云11 小时前
Claude API 流式输出(SSE)实战:从打字机效果到工具调用全流程
java·前端·ide·人工智能·chrome·intellij-idea·状态模式