Skip to content

Suspense

如果直接使用 use 获取未直接创建的 Promise 中的值,会抛出一个异常

tsx
function _api3() {
  return new Promise((resolve) => {
    resolve({ value: '_api3' });
  });
}

// bad: get an error
const result = use(_api3());

但是实际上在开发过程中,大多数情况都是这种并没有直接得到 Promise resolve 的结果状态,那我们应该怎么办呢?

这个时候我们可以利用 Suspense 来解决这个问题。

Suspense 可以捕获这种异常。我们来看一下这段代码

tsx
import { Suspense, use } from 'react';
import Message from './Message';

function _api3() {
  return new Promise((resolve) => {
    resolve({ value: 'React does not preserve any state for renders that got suspended before they were able to mount for the first time. When the component has loaded, React will retry rendering the suspended tree from scratch.' });
  });
}

export default function Demo01() {
  const promise = _api3();
  return (
    <Suspense fallback="">
      <Content promise={promise} />
    </Suspense>
  );
}

function Content(props) {
  const { value } = use(props.promise);
  return (
    <Message message={value} />
  );
}

在这段代码中,为了让 Suspense 捕获更小范围的组件,我们单独定义了一个子组件 Content 来使用 use 获取 promise 中的数据。

这也是未来使用的比较常规的思路和手段。

当然,在开发中更常见的效果是使用 use 读取异步 promise,主要的场景就是接口请求。

Suspense 能够捕获到子组件首次渲染的异常。

因此我们常常将 Suspense 当成一种组件错误边界来处理。

但是需要注意的是,传递给 Suspense 的异步组件必须在报错时返回一个 Promise 对象,它才能正常工作。

在 React 19 中,use(promise)被设计成完全符合 Suspense 规范的 hook,因此我们可以轻松的结合他们两者来完成页面开发。

我们定义一个子组件,该子组件接受一个 promise 作为参数。然后在子组件内部,我们使用 use 读取该 promise 中的值。

有了这个子组件之后,我们使用 Suspense 包裹捕获该组件的错误,防止错误溢出到更高层级的组件。

当 Message 组件首次渲染时,由于直接读取 promise 导致报错,Suspense 捕获到该异常后,会渲染 fallback 中设置的组件。此时我们设置了一个骨架屏 Skeleton 组件

因此,这个案例的视觉表现应该为:1. 首先渲染 Skeleton 组件。然后请求成功之后,use 渲染 Message 组件。

Suspense 工作原理

Suspense 提供了一个加载数据的标准。在源码中,Suspense 的子组件被称为 primary

当 react 在 beginWork 的过程中(diff 过程),遇到 Suspense 时,首先会尝试加载 primary 组件。如果 primary 组件只是一个普通组件,那么就顺利渲染完成。

如果 primary 组件是一个包含了 use 读取异步 promise 的组件,它会在首次渲染时,抛出一个异常。

react 捕获到该异常之后,发现是一个我们在语法中约定好的 promise,那么就会将其 then 的回调函数保存下来,并将下一个 next beginWork 的组件重新指定为 Suspense。

此时 promise 在请求阶段,因此再次 beginWork Suspense 组件时,会跳过 primary 的执行而直接渲染 fallback

当 primary 中的 promise 执行完成时「resolve」,会执行刚才保存的 then 方法,此时会触发 Suspense 再次执行「调度一个更新任务」。

由于此时 primary 中的 promise 已经 resolve,因此此时就可以拿到数据直接渲染 primary 组件。

整个流程可以简单表示为:

text
Suspense ->
primary ->
Suspense ->
fallback ->
waiting -> resolve() ->
Suspense ->
primary ->

primary 为普通组件时

当 primary 为普通组件时,会直接渲染普通组件,如下案例所示,Message是一个普通的非异步组件。

tsx
import React, { Suspense } from 'react';
import Message from './Message';
import Skeleton from './Skeleton';

export default function Index() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Message
        message="这是一个普通的 UI 组件,Skeleton 组件不会有任何渲染机会,直接渲染 Message 组件"
        title="Primary"
      />
    </Suspense>
  );
}

新旧实现对比

在前面我们 结合 use 与 Suspense 实现了一个初始化加载的案例。

该案例的视觉表现是在初始化时,首先显示 Skeleton 组件,请求成功之后,显示 Message 组件。

刷新页面时重新请求数据渲染,请求过程中显示骨架屏组件 Skeleton

这里我们需要关注的是,对比以前必须要借助 state useEffect 的实现方式,体会一下差别。核心逻辑如下

tsx
// 之前的实现方式
export default function Index() {
  const [content, update] = useState({ value: '' });
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    getMessage().then((res) => {
      update(res);
      setLoading(false);
    });
  }, []);

  if (loading) {
    return <Skeleton />;
  }

  return (
    <Message message={content.value} />
  );
}

可以很明显的看出,新的方式使用 use + Suspense ,代码更加简洁。

除此之外,由于在严格模式下,组件首次加载会执行两次,因此我们还需要想额外的办法防止重复执行,代码会变得更加冗余。

一个很明显的差别就是 Suspense + use 的方式会自动帮助我们弃用第二次的请求数据。

从视觉上的效果就是,右侧使用 useEffect 实现的结果,UI 会更新两次。

总结

与老版本使用 state + useEffect 完成首页初始化的需求相比,新的开发方式更加的简洁,代码舒适度更高。

需要注意区分的是,在以前的开发方式中,我们可以通过自定义 hook 的方式,把状态与 useEffect 封装成自定义 hook.

tsx
function useFetch() {
  const [content, update] = useState({ value: '' });
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    api().then((res) => {
      setLoading(false);
      update(res);
    });
  }, []);

  return { content, loading };
}

最终在应用组件中也可以写出非常类似的非常简洁的代码。

tsx
function Index2() {
  const { content, loading } = useFetch();

  if (loading) {
    return <Skeleton />;
  }

  return (
    <Message message={content.value} />
  );
}

Vue3 也是这种类似自定义 hook 的方式。但是这两种开发方式是有本质区别的。

警告

使用 Suspense 会捕获子组件的异常,但是不是捕获所有异常,它只能识别 promise 的异常。

点击按钮更新数据

案例的视觉表现为:初始化时没有请求,所以组件显示为空数据样式。当我们点击按钮时请求一条数据,数据更新,请求成功之后显示更新之后的内容。

当我们要更新的数据时,我们不再需要设计一个 loading 状态去记录数据是否正在发生请求行为,因为 Suspense 帮助我们解决了 Loading 组件的显示问题。

与此同时,use() 又帮助我们解决了数据获取的问题。那么问题就来了,这个时候,好像我们也不需要设计一个状态去存储数据。

那么我们应该怎么办呢?

这里有一个非常巧妙的方式,就是把创建的 promise 作为状态值来触发组件的重新执行。每次点击,我们都需要创建新的 promise

tsx
// 记住这个初始值
const [promise, update] = useState(null);

这个时候,当我们点击事件执行时,则只需要执行如下代码去触发组件的更新即可。

tsx
function __handler() {
  // 每次点击,都会创建新的 promise 对象
  update(getMessage());
}

getApi() 执行,新的请求会发生。他的执行结果,又返回了一个新的 promise.

因此,点击之后会创建的新 promise 值,promise 此时就会作为状态更改触发组件的更新。

tsx
import { Suspense, use, useState } from 'react';
import Message from './Message';
import Skeleton from './Skeleton';
import Button from './Button';
import { getMessage } from './api';

export default function Demo01() {
  const [promise, update] = useState(null);

  function __handler() {
    update(getMessage());
  }

  return (
    <>
      <div className="text-right mb-4">
        <Button onClick={__handler}>更新数据</Button>
      </div>
      <Suspense fallback={<Skeleton />}>
        <Content promise={promise} />
      </Suspense>
    </>
  );
}

function Content(props) {
  if (!props.promise) {
    return <Message message="" />;
  }

  const { value } = use(props.promise);
  return (
    <Message message={value} />
  );
}

观察当前组件更新,更上层的父组件是否发生了变化

发现,当我们重新请求时,当前组件更新,但是上层组件并不会重新执行。

我们可以出得结论:更简洁的状态设计,有利于命中 React 默认的性能优化规则。

更简洁的状态设计,也是 React 19 所倡导的开发思路。我们需要尽可能少的使用 useState,例如,这里借助了 Suspense 减少了 loading 状态的维护。

我们要特别特别注意观察子组件 Content 的实现。

首先因为我们初始化时,给状态 promise 赋予的默认值是 null。

tsx
// 记住这个初始值
const [promise, update] = useState(null);

之后,我们就将状态 promise 传给了子组件 Content

tsx
<Suspense fallback={<Skeleton />}>
  <Content promise={promise} />
</Suspense>;

然后在 Content 组件的内部实现中,因为我们直接把 promise 传给了 use,那么此时直接执行肯定会报错

tsx
const { value } = use(props.promise);

要注意的是,我们刚才说,使用 Suspense 会捕获子组件的异常,但是不是捕获所有异常,它只能识别 promise 的异常。因此,这里的报错会直接影响到整个页面。

所以,为了处理好初始化时传入 api 值为 null,我在内部实现代码逻辑中,使用了 if 判断该条件,然后执行了一次 return。我试图让 use(null) 得不到执行的时机。

tsx
function Content(props) {
  if (!props.promise) {
    return <Message message="" />;
  }

  const { value } = use(props.promise);
  return (
    <Message message={value} />
  );
}

当然,我们还可以给状态 promise 一个默认的,自带 resolve 值的 Promise 对象作为初始值,这样可以在子组件中避免这个异常判断。

这种写法有一个很小的瑕疵,就是在初始化时,也不可避免的显示了 Skeleton 组件,实际上是不需要的。因此具体采用哪种写法,要依据实践中的需求而定。

tsx
const [promise, update] = useState(Promise.resolve({ value: '' }));

function Content(props) {
  const { value } = use(props.promise);
  return (
    <Message message={value} />
  );
}

需要在初始化时请求数据

给状态 promise 赋值一个 Promise 对象作为初始值

tsx
import { Suspense, use, useState } from 'react';
import Message from './Message';
import Skeleton from './Skeleton';
import Button from './Button';
import { getMessage } from './api';

export default function Demo02() {
  const [promise, update] = useState(getMessage());

  function __handler() {
    update(getMessage());
  }

  return (
    <>
      <div className="text-right mb-4">
        <Button onClick={__handler}>更新数据</Button>
      </div>
      <Suspense fallback={<Skeleton />}>
        <Content promise={promise} />
      </Suspense>
    </>
  );
}

function Content(props) {
  const { value } = use(props.promise);
  return (
    <Message message={value} />
  );
}

需要注意的是一个小的细节,如果不考虑 Compiler 编译之后的代码去缓存初始化时的 getMessage(),那么每次更新组件时,该方法都会执行一次,因此,会导致冗余的接口请求。

使用 Compiler 编译之后,这段代码会被缓存下来而不会重复执行

因此,最好的方式是进一步调整一下,利用 useState 的初始化机制修改如下

text
const [promise, update] = useState(getMessage())
const [promise, update] = useState(getMessage)

这样,即使不用 Compiler 编译缓存,也不会出现冗余请求的情况

取消上一次请求

在 JavaScript 中,有一个特殊的内建对象 AbortController 可以终止异步任务。我们可以利用该对象实例来终止 fetch 请求。

ts
const controller = new AbortController();

controller 具有单个属性 signal,我们可以在这个属性上设置事件监听。

ts
const signal = controller.signal;
signal.addEventListener('abort', () => alert('abort!'));

controller 具有单个方法:abort(),当 abort() 调用时,signal 的事件监听就会执行。

ts
controller.abort();

// 事件触发,signal.aborted 变为 true
alert(signal.aborted); // true

fetch 中封装了 signal 的事件监听,因此它可以很好的与 AbortController 对象一起工作。

fetch 的第二个参数 option 可以接收 signal

ts
fetch(url, {
  signal: controller.signal
});

当我们在任意地方调用 abort 时,对应的请求就会被取消

ts
controller.abort();

封装一个可以被取消的 promise api 函数

ts
export function fetchListWithCancel(number) {
  const controller = new AbortController();
  const signal = controller.signal;
  const promise = new Promise((resolve) => {
    fetch(
      `https://randomuser.me/api/?results=${number}&inc=name,gender,email,nat,picture&noinfo`,
      { signal }
    ).then((res) => {
      resolve(res.json());
    }).catch(() => {
      console.log('接口成功取消!');
    });
  });

  promise.cancel = () => {
    if (controller) {
      controller.abort();
    }
  };
  return promise;
}

使用了一个返回结果是一个列表的案例接口。然后将 abort 函数挂载到返回的 promise 中

使用时,只需要调用 promise.cancel() 就可以取消对应的请求了

Suspense嵌套

有的时候,我们需要这种瀑布流式的接口请求交互方式。也就是上一个模块请求成功之后,再请求下一个模块。

我们可以利用 Suspense 的嵌套来轻松做到这个事情。

这里需要注意的是,当你决定这样用时,往往后请求的接口都会依赖先请求的结果,如果并没有明确的先后依赖关系,我们并不建议采用这种交互方案

Contributors

作者:Long Mo
字数统计:3.1k 字
阅读时长:11 分钟
Long Mo
文章作者:Long Mo
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Longmo Docs