获取异步数据

在 React 18 及以前版本中,异步获取数据的操作一般都是通过 Axios 或者 fetch 功能来完成的。但是在 React 19 中,新提供了一个use函数来从 Promise 和 Context 中获取内容。

使用 fetch 搭配自定义 Hook 完成异步

fetch 和 Axios 都是用来完成对服务端进行数据异步访问的,它们之间仅有一些使用上的不同。由于 React 里很多 Hook 都要求同步操作,尤其是useEffect中,所以在使用 fetch 或者 Axios 访问服务端数据时,一般都需要自定义一个 Hook 来简化组件中对于异步数据的访问。

以下是一个基于 fetch 使用 GET 方式访问 RESTful API 服务的自定义 Hook 示例。

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error("Network response was not ok");
        }
        const result = await response.json();
        setData(result);
      } catch (error) {
        setError(error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

在这个示例中,需要注意的是,useEffect Hook 中只能使用同步函数,所以即便是使用async/await定义了异步函数,也需要使用其同步调用方式。

使用use获取异步数据

Tip

本章节内容需要在React 19版本中使用。

use不是一个 Hook,而是一个普通的函数,所以她可以在循环语句和条件判断语句中调用,但是调用use的函数依旧需要是一个组件或者 Hook。use函数是用来从Promise<T>或者Context<T>读取 内容使用的,这一点要尤其注意,它并不是用来直接调用产生 Promise 的异步函数使用的。

使用use读取 Context 中的内容时十分简单,基本上与useContext一样。参见以下示例。

import { use } from "react";

function ThemedPage() {
  const theme = use(ThemeContext);
  // ...
}

但是当use与 Promise 结合使用的时候,效果就不一样了。use可以在调用异步过程的时候暂停组件的渲染过程,这也就是说use可以与<Suspense>组件一起搭配使用。当调用use的组件被<Suspense>包裹的时候,组件被use挂起时,将展示fallback指定的内容,直到use参数中的 Promise 被解决。如果use中的 Promise 出现错误,那么最近的错误边界中的后备 UI 将被展示。

以下示例展示的是利用 Promise 从服务端获取数据的用法。

import { Suspense, use } from "react";

async function messageLoader(): Promise<string> {
  const response = await fetch("/message");
  return response.text();
}

function Message({ messagePromise }) {
  const messageContent = use(messagePromise);

  return <div>Message: {messageContent}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Message messagePromise={messageLoader()} />
    </Suspense>
  );
}

现在对比一下上面传统使用自定义 Hook 实现的异步操作,是不是已经简单了很多。

在有async/await加持的条件下,Promise 被 reject 时,往往可以使用try/catch来处理,但是在使用use捕获 Promise 返回的值时,是不可以使用try/catch的。作为替代方案,需要使用错误便边界。

错误边界的定义不在 React 中提供,函数式组件中目前也没有提供处理错误的错误边界功能。所以错误边界功能目前还是由传统的使用类编写组件的方式实现的,一般情况下,可以使用react-error-boundary库来支持错误边界,其中提供的<ErrorBoundary>组件的使用与 React 中提供的<Suspense>组件的使用基本上是一致的。

例如给上面的示例增加一个错误边界的支持就是下面的样子。

import { Suspense, use } from "react";
import { ErrorBoundary } from "react-error-boundary";

async function messageLoader(): Promise<string> {
  const response = await fetch("/message");
  return response.text();
}

function Message({ messagePromise }) {
  const messageContent = use(messagePromise);

  return <div>Message: {messageContent}</div>;
}

function App() {
  return (
    <ErrorBoundary fallback={<div>Something failed!</div>}>
      <Suspense fallback={<div>Loading...</div>}>
        <Message messagePromise={messageLoader()} />
      </Suspense>
    </ErrorBoundary>
  );
}

Caution

不要在使用use的组件中直接调用异步函数生成需要被读取的Promise<T>,这样会造成无限循环出现。因为在每一次渲染时,use读取的Promise都是全新的,这就会触发新一轮的渲染。

以下示例是不会报错,但是也不可用的,仔细观察会发现有无限循环渲染存在,使用的时候需要注意,不要觉得这种使用方法看起来跟前面的示例功能一样但是更简单就直接采用。

import { Suspense, use } from "react";

async function messageLoader(): Promise<string> {
  const response = await fetch("/message");
  return response.text();
}

function Message() {
  const messageContent = use(messageLoader());

  return <div>Message: {messageContent}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Message />
    </Suspense>
  );
}

避免异步操作产生的 UI 卡死和更新迟滞

使用异步操作获取数据来更新组件中的 State 在 React 中是一件非常平常的事情,但是在发生一些耗时比较久的异步操作时,UI 中的组件可能会出现失去交互或者数据更新不及时的情况。这些情况的出现都破坏了 UI 的友好性,也一直是前端开发人员致力于优化的事情。

不过这种情况的出现一般都是由于异步操作阻塞了组件的渲染造成的,所以解决的思路一般是将异步操作延后或者想办法让异步操作与组件渲染平行进行。

为了优化这个问题,React 18 中引入了startTransition函数和useTransition Hook。这两个的功能是一致的,只不过useTransition Hook 多了一个可以指示 Pending 状态的功能;另外就是startTransition可以在组件外使用。

Info

useTransition在调用后会返回一个startTransition函数,其使用方法与独立的startTransition函数是一样的。

startTransition函数接受一个函数作为参数,它的主要功能是允许在后台完成一部分 UI 的渲染。换句话说就是在后台预备下一次渲染所需要的 State,所以在startTransition的参数中,一般都需要包含更新 State 的操作。例如用来切换 Tab 选项卡页。

function TabContainer() {
  const [activeTab, setActiveTab] = useState("home");

  const selectTab = (tab: string) => {
    startTransition(() => {
      setActiveTab(tab);
    });
  };

  // 以下省略渲染TabContainer组件实际内容部分
}

startTransition更多的还是在项目中存在比较耗时的异步请求方法的时候,能够保持 UI 的响应和可交互性。例如现在有这样一个非常耗时的 API。

export async function updateQuantity(newValue) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(newValue);
    }, 5000);
  });
}

如果在用户更改数据的时候直接调用这个 API 接口,那么可能用户体验会很不好,例如以下没有使用useTransition的示例。

function Total({ quantity, isPending }) {
  return (
    <div>
      <span>Total: </span>
      {isPending ? <span>Calculating...</span> : <span>{quantity * 9999}</span>}
    </div>
  );
}

function Item({ action }) {
  const handleChange = async (event) => {
    await action(event.target.value);
  };

  return (
    <div>
      <span>Purchases: </span>
      <input type="number" defaultValue={1} min={1} onChange={handleChange} />
    </div>
  );
}

function App() {
  const [quantity, setQuantity] = useState(1);
  const [isPending, setPending] = useState(false);
  const updateQuantityAction = async (newValue) => {
    setPending(true);
    const savedQuantity = await updateQuantity(newValue);
    setPending(true);
    setQuantity(savedQuantity);
  };

  return (
    <div>
      <Item action={updateQuantityAction} />
      <hr />
      <Total quantity={quantity} isPending={isPending} />
    </div>
  );
}

在这个示例中,如果快速连续更改Item组件中的input元素的值,那么Total组件中展示的内容需要等待好一会儿才会发生变化,而且是在等待一会儿以后连续的多次发生变化。这种展示效果就十分容易带给用户存在歧义的提示。

但是同样还是这个示例,如果加入useTransition的优化,那么在一段等待时间以后的展示效果就会非常简练了。

function Total({ quantity, isPending }) {
  return (
    <div>
      <span>Total: </span>
      {isPending ? <span>Calculating...</span> : <span>{quantity * 9999}</span>}
    </div>
  );
}

function Item({ action }) {
  const handleChange = async (event) => {
    await action(event.target.value);
  };

  return (
    <div>
      <span>Purchases: </span>
      <input type="number" defaultValue={1} min={1} onChange={handleChange} />
    </div>
  );
}

function App() {
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();
  const updateQuantityAction = async (newValue) => {
    // 这里可以用来处理启动Transition之前的操作
    startTransition(async () => {
      const savedQuantity = await updateQuantity(newValue);
      setQuantity(savedQuantity);
    });
  };

  return (
    <div>
      <Item action={updateQuantityAction} />
      <hr />
      <Total quantity={quantity} isPending={isPending} />
    </div>
  );
}

除此之外,startTransition还可以用来处理路由页面转换,搭配react-error-boundary展示错误信息等操作。

Caution

startTransition函数中所包裹的处理函数,其中应该包括触发组件渲染的内容,也就是更新组件的State。如果startTransition中没有触发组建的渲染,那么使用startTransition来延后组建的渲染也就没有意义了。

另外,startTransition并不是实现Debounce防抖处理用的,如果需要实现Debounce防抖处理,可以结合setTimeout来控制startTransition的激活时机实现。