Dark Dwarf Blog background

React 数据获取

React 数据获取

1. 基础: 使用 Fetch 获取数据

a.a. 组件加载时获取数据

最常见的场景是在组件首次加载(mount)时获取数据。我们可以通过向 useEffect 传递一个空依赖数组 [] 来实现这一点。空数组告诉 React,这个 effect 只应该运行一次,即在组件挂载后。

import { useEffect, useState } from "react";

function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // 发起 fetch 请求
    fetch("https://api.github.com/users/octocat")
      .then(response => response.json())
      .then(data => setUser(data))
      .catch(error => console.error("Error fetching data:", error));
  }, []); // 空依赖数组,确保只在挂载时运行一次

  if (!user) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

b.b. 使用 async/await 语法

async/await 是处理 Promise 的现代语法糖,它可以让异步代码看起来更像同步代码,从而提高可读性。由于 useEffect 的回调函数本身不能是 async 的(因为这会返回一个 Promise,而 React 期望它返回一个清理函数或 undefined),我们需要在内部定义一个 async 函数并立即调用它。

useEffect(() => {
  // 在 effect 内部定义一个 async 函数
  const fetchData = async () => {
    try {
      const response = await fetch("https://api.github.com/users/octocat");
      const data = await response.json();
      setUser(data);
    } catch (error) {
      console.error("Error fetching data:", error);
    }
  };

  fetchData(); // 调用该函数
}, []);

2. 管理请求状态

a.a. 状态类型

对于任何数据请求,通常存在三种关键状态:

  1. loading: 请求正在进行中。此时应向用户显示加载指示(如加载动画)。
  2. error: 请求失败。应向用户显示错误信息。
  3. data: 请求成功,数据已返回。此时可以渲染数据。

我们可以使用 useState 来管理这三种状态:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

b.b. 实现完整的状态管理流程

结合 async/await,一个包含完整状态管理的 useEffect 如下所示:

useEffect(() => {
  const fetchData = async () => {
    setLoading(true); // 开始请求,设置 loading 为 true
    setError(null);   // 重置错误状态
    try {
      const response = await fetch("https://api.github.com/users/octocat");
      
      // fetch 不会因 4xx/5xx 响应而抛出错误,需要手动检查
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const result = await response.json();
      setData(result);
    } catch (e) {
      setError(e); // 捕获网络错误或我们手动抛出的错误
    } finally {
      setLoading(false); // 请求结束(无论成功或失败),设置 loading 为 false
    }
  };

  fetchData();
}, []);

// 在 JSX 中根据状态进行条件渲染
if (loading) return <p>Loading...</p>;
if (error) return <p>A network error was encountered: {error.message}</p>;

// ... 渲染 data ...

为什么需要 if (!response.ok)?因为fetch API 的一个特性是,它只在发生网络层面的失败(如无法连接服务器)时才会 reject Promise。对于服务器返回的错误状态码(如 404 Not Found, 500 Internal Server Error),fetch 仍然会 resolve Promise。因此,我们必须手动检查 response.ok 属性(当状态码在 200-299 范围内时为 true)并抛出错误,才能被 catch 块捕获。

3. 进阶模式

a.a. 抽象为自定义 Hook

当多个组件都需要类似的数据获取逻辑时,重复编写 useEffect 会变得非常繁琐。我们可以将这个逻辑抽象成一个可复用的自定义 Hook。自定义 Hook 是一个以 use 开头的函数,它可以在内部调用其他 Hook。

// useData.js
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]); // 依赖数组包含 url,当 url 变化时重新获取数据

  return { data, loading, error };
}

export default useData;

// 在组件中使用
// MyComponent.js
import useData from './useData';

function MyComponent() {
  const { data, loading, error } = useData('https://api.example.com/data');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return <div>{JSON.stringify(data)}</div>;
}

这种模式极大地简化了组件代码,并实现了逻辑的复用。

b.b. 处理组件卸载

一个常见的 Bug 是:当一个请求正在进行时,如果组件被卸载(unmounted),请求完成后仍然会尝试调用 setState,这会导致 React 报错:“Can’t perform a React state update on an unmounted component.”

为了解决这个问题,我们需要在 useEffect清理函数中取消请求。AbortController 是现代浏览器中用于中止 fetch 请求的标准 API。

useEffect(() => {
  const controller = new AbortController(); // 创建 AbortController
  const signal = controller.signal; // 获取其 signal

  const fetchData = async () => {
    setLoading(true);
    try {
      const response = await fetch(url, { signal }); // 将 signal 传递给 fetch
      // ...
    } catch (e) {
      if (e.name === 'AbortError') {
        console.log('Fetch aborted'); // 请求被中止,是正常行为
      } else {
        setError(e);
      }
    } finally {
      setLoading(false);
    }
  };

  fetchData();

  // 返回一个清理函数
  return () => {
    controller.abort(); // 当组件卸载或 effect 重新运行时,中止请求
  };
}, [url]);

c.c. 避免请求瀑布

一个组件的请求依赖于另一个父组件的请求结果时,就会产生请求瀑布 (Request Waterfalls)。子组件必须等待父组件完成请求、渲染后,才能开始自己的请求,导致加载时间变长。

  • 不良实践(瀑布):
function Profile() {
  const { data: user, loading } = useData('/api/user');
  
  if (loading) return <p>Loading profile...</p>;

  // Bio 组件只有在 user 加载后才渲染和获取数据
  return (
    <>
      <h1>{user.name}</h1>
      <Bio userId={user.id} /> 
    </>
  );
}

解决的方案是将多个独立的请求尽可能地提升到组件树的更高层级,并使用 Promise.all 来并行触发它们。

function ProfilePage() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);
  // ... loading, error states

  useEffect(() => {
    const fetchAllData = async () => {
      setLoading(true);
      try {
        const [userResponse, postsResponse] = await Promise.all([
          fetch('/api/user'),
          fetch('/api/posts')
        ]);
        const userData = await userResponse.json();
        const postsData = await postsResponse.json();
        setUser(userData);
        setPosts(postsData);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    };
    fetchAllData();
  }, []);
  
  // ...
}

4. 数据获取库

虽然手动管理 fetch 是可行的,但随着应用变得复杂,缓存、重新验证、请求去重等需求会使代码变得异常复杂。这时,专门的数据获取库就派上了用场。

a.a. Axios:更强大的 HTTP 客户端

Axios 是一个流行的、基于 Promise 的 HTTP 客户端。它不是一个数据同步库,而是一个 fetch 的替代品,提供了更强大和便捷的 API。它有如下的特性:

  • 自动转换 JSON: 无需手动调用 response.json()
  • 更好的错误处理: 对 4xx/5xx 状态码自动 reject Promise,可以直接在 .catch 中捕获。
  • 请求和响应拦截器 (Interceptors): 这是 Axios 的王牌功能。可以在请求发送前或响应返回后进行全局处理。
  • 创建实例: 可以创建具有预设配置(如 baseURL)的 Axios 实例。
  • 内置 CSRF 保护

下面是一个创建实例与拦截器的例子。

// api/client.js
import axios from 'axios';

// 1. 创建一个 Axios 实例
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000, // 请求超时时间
});

// 2. 添加请求拦截器
apiClient.interceptors.request.use(
  (config) => {
    // 在发送请求之前做些什么,例如添加认证 Token
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    // 对请求错误做些什么
    return Promise.reject(error);
  }
);

// 3. 添加响应拦截器
apiClient.interceptors.response.use(
  (response) => {
    // 对响应数据做点什么,例如直接返回 response.data
    return response.data;
  },
  (error) => {
    // 处理常见的错误,例如 401 未授权
    if (error.response && error.response.status === 401) {
      // 跳转到登录页
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default apiClient;

之后,在组件中就可以使用这个配置好的实例了:

import apiClient from './api/client';

// TanStack Query 结合 apiClient
useQuery({
  queryKey: ['user', userId],
  queryFn: () => apiClient.get(`/users/${userId}`)
});

b.b. TanStack Query:现代数据同步方案

TanStack Query 是一个强大的服务端状态管理库,它将数据获取、缓存、同步和更新的复杂逻辑封装得非常好。它有如下的核心概念扩展:

  • queryKey (查询键): 这是 TanStack Query 的核心。它是一个数组,不仅是缓存的唯一标识,还能包含动态参数。当键中的任何部分变化时,查询都会自动重新执行。

    • ['todos'] - 获取待办事项列表
    • ['todos', 5] - 获取 ID 为 5 的单个待办事项
    • ['todos', { status: 'done', page: 2 }] - 获取状态为 “done” 的第二页待办事项
  • queryFn (查询函数): 一个返回 Promise 的函数,负责实际的数据获取。通常在这里使用 fetch 或 Axios。

  • staleTimecacheTime:

    • staleTime: 数据保持“新鲜”的时间。在此期间,组件重新挂载不会触发新的网络请求。默认为 0
    • cacheTime: 数据在没有观察者(即没有组件正在使用此查询)后,在缓存中保留的时间。默认为 5 分钟。到期后,数据会被垃圾回收。

对于创建、更新或删除数据的操作,可以使用 useMutation Hook。

import { useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from './api/client';

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newTodo) => {
      // 这里的 newTodo 是从 mutate() 调用时传来的变量
      return apiClient.post('/todos', newTodo);
    },
    // 当 mutation 成功时...
    onSuccess: () => {
      // ...让所有以 ['todos'] 开头的查询失效,从而触发自动重新获取
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  const handleAddTodo = () => {
    mutation.mutate({ title: '新待办事项', completed: false });
  };

  return (
    <div>
      <button onClick={handleAddTodo} disabled={mutation.isPending}>
        {mutation.isPending ? '正在添加...' : '添加待办'}
      </button>
      {mutation.isError && <p>错误: {mutation.error.message}</p>}
    </div>
  );
}

查询失效 (Query Invalidation) 是 TanStack Query 的一个关键特性。如上例所示,当一个 mutation 成功后,我们不再需要手动去更新 todos 列表的状态。通过调用 queryClient.invalidateQueries,我们告诉 TanStack Query “这个数据已经过时了”,它会自动找到所有相关的 useQuery 并重新获取最新数据,确保 UI 与服务端状态同步。这种模式极大地简化了状态管理和数据同步的逻辑。