你不知道的React系列(十一)Suspense

338 阅读3分钟

「回顾 2022,展望 2023,我正在参与2022 年终总结征文大赛活动

React.lazy

const SomeComponent = lazy(load)
  • 初次渲染时未用到的组件延迟加载

  • lazy() 则可被放置于任何你想要做代码分割的地方

  • 组件第一次被渲染之前延迟加载组件的代码

  • 不要在其他组件 内部 声明 lazy 组件

  • load: 一个返回 Promise 或另一个 thenable(具有 then 方法的类 Promise 对象)的函数。

    • 第一次渲染返回的组件之前,React 是不会调用 load 函数的
    • 首次调用 load 后,它将等待其解析,然后将解析值渲染成 React 组件
    • 返回的 Promise 和 Promise 的解析值都将被缓存,因此 React 不会多次调用 load 函数
    • 如果 Promise 被拒绝,则 React 将抛出拒绝原因给最近的错误边界处理

Suspense

// 该组件是动态加载的
const OtherComponent = React.lazy(() => import('./OtherComponent')
<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

子组件没有加载完成使用最近的 fallback 组件(通常是 spinner 或者 skeleton)

使用 Suspense-enabled 才会激活 fallback

注意事项

  • 在已经挂起的渲染逻辑首次能够挂载之前, React 不会保留 state

  • 如果 Suspense 正在展示内容,但是又被挂起,fallback 组件就会重新出现,除非这个更新是被 startTransition or useDeferredValue

  • 组件挂起隐藏已经展示的内容,组件会执行 layout Effect 的 cleanup,当内容又被展示时,再次触发 Effect,这样 layout Effect 就不会执行这样的逻辑

  • 可以和 Streaming Server Rendering、Selective Hydration 使用

Suspense 会等待所有 children 加载完

import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<Loading />}>
        <Biography artistId={artist.id} />
        <Panel>
          <Albums artistId={artist.id} />
        </Panel>
      </Suspense>
    </>
  );
}

function Loading() {
  return <h2>🌀 Loading...</h2>;
}

Suspense 嵌套使用

import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<BigSpinner />}>
        <Biography artistId={artist.id} />
        <Suspense fallback={<AlbumsGlimmer />}>
          <Panel>
            <Albums artistId={artist.id} />
          </Panel>
        </Suspense>
      </Suspense>
    </>
  );
}

function BigSpinner() {
  return <h2>🌀 Loading...</h2>;
}

function AlbumsGlimmer() {
  return (
    <div className="glimmer-panel">
      <div className="glimmer-line" />
      <div className="glimmer-line" />
      <div className="glimmer-line" />
    </div>
  );
}

新内容加载时保留旧内容不展示 fallback

使用 useDeferredValue

export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <SearchResults query={deferredQuery} />
      </Suspense>
    </>
  );
}

保留已经展示的内容

使用 startTransition

import { Suspense, startTransition, useState } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

export default function App() {
  return (
    <Suspense fallback={<BigSpinner />}>
      <Router />
    </Suspense>
  );
}

function Router() {
  const [page, setPage] = useState('/');

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }

  let content;
  if (page === '/') {
    content = (
      <IndexPage navigate={navigate} />
    );
  } else if (page === '/the-beatles') {
    content = (
      <ArtistPage
        artist={{
          id: 'the-beatles',
          name: 'The Beatles',
        }}
      />
    );
  }
  return (
    <Layout>
      {content}
    </Layout>
  );
}

function BigSpinner() {
  return <h2>🌀 Loading...</h2>;
}

添加过渡效果

import { Suspense, useState, useTransition } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

export default function App() {
  return (
    <Suspense fallback={<BigSpinner />}>
      <Router />
    </Suspense>
  );
}

function Router() {
  const [page, setPage] = useState('/');
  const [isPending, startTransition] = useTransition();

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }

  let content;
  if (page === '/') {
    content = (
      <IndexPage navigate={navigate} />
    );
  } else if (page === '/the-beatles') {
    content = (
      <ArtistPage
        artist={{
          id: 'the-beatles',
          name: 'The Beatles',
        }}
      />
    );
  }
  return (
    <Layout isPending={isPending}>
      {content}
    </Layout>
  );
}

function BigSpinner() {
  return <h2>🌀 Loading...</h2>;
}

导航切换时候重置 Sespense 边界

  • 使用 transition, 会保留已经存在的内容。但是有这样的场景,如果根据路由不同参数展示不同的内容。

  • 使用 key 来进行重置

  • 路由设计的时候就会考虑

服务端报错提供只有服务端使用的 fallback

  • 当你使用 streaming server rendering APIs 时, 也会用到 Suspense 处理错误。

  • 如果服务端报错了不会阻止服务端渲染。它会找到最近的 Suspense 组件把 fallback 放在生成的 HTML里面。 用户首先会看到 fallback 组件展示的内容。

  • 客户端会再次渲染相同的组件。如果客户端出错了就会扔出错误并且展示最近的 error boundary,如果客户端没有报错就不会展示这个错误因为内容展示没有问题。

  • 你可以在服务端渲染时抽离一些组件。

    服务端上面扔出一个错误,使用 Suspense 包裹它们使用 fallback 替换 HTML

    <Suspense fallback={<Loading />}>
      <Chat />
    </Suspense>
    
    function Chat() {
      if (typeof window === 'undefined') {
        throw Error('Chat should only render on the client.');
      }
      // ...
    }