Перейти к содержанию

Suspense

<Suspense> позволяет отображать фалбэк до тех пор, пока его дочерние элементы не закончат загрузку.

1
2
3
<Suspense fallback={<Loading />}>
    <SomeComponent />
</Suspense>

Описание

<Suspense>

Свойства

  • children: Фактический пользовательский интерфейс, который вы собираетесь рендерить. Если children приостановится во время рендеринга, граница Suspense переключится на рендеринг fallback.
  • fallback: Альтернативный пользовательский интерфейс, который будет отображаться вместо реального пользовательского интерфейса, если он не закончил загрузку. Принимается любой допустимый узел React, хотя на практике запасной вариант - это легковесное представление-заполнитель, например, загрузочный спиннер или скелет. Приостановка будет автоматически переключаться на fallback, когда children приостанавливает работу, и обратно на children, когда данные будут готовы. Если fallback приостанавливает работу во время рендеринга, он активирует ближайшую родительскую границу Suspense.

Ограничения

  • React не сохраняет состояние для рендеров, которые были приостановлены до того, как они смогли смонтироваться в первый раз. Когда компонент загрузится, React повторит попытку рендеринга приостановленного дерева с нуля.
  • Если Suspense отображал содержимое для дерева, но затем снова приостановился, то откат будет показан снова, если только обновление, вызвавшее его, не было вызвано startTransition или useDeferredValue.
  • Если React необходимо скрыть уже видимый контент из-за повторного приостановления, он очистит layout Effects в дереве контента. Когда контент снова будет готов к показу, React снова запустит Эффекты компоновки. Это гарантирует, что Эффекты, измеряющие макет DOM, не попытаются сделать это, пока содержимое скрыто.
  • React включает в себя такие "подкапотные" оптимизации, как Streaming Server Rendering и Selective Hydration, которые интегрированы в Suspense. Чтобы узнать больше, прочитайте архитектурный обзор и посмотрите технический доклад.

Использование

Отображение фолбэка во время загрузки контента

Вы можете обернуть любую часть вашего приложения границей Suspense:

1
2
3
<Suspense fallback={<Loading />}>
    <Albums />
</Suspense>

React будет отображать ваш loading fallback до тех пор, пока весь код и данные, необходимые потомкам не будут загружены.

В приведенном ниже примере компонент Albums приостанавливается на время получения списка альбомов. Пока он не готов к рендерингу, React переключает ближайшую границу Suspense выше, чтобы показать отступающий компонент - ваш компонент Loading. Затем, когда данные загружаются, React скрывает компонент Loading и отображает компонент Albums с данными.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { Suspense } from 'react';
import Albums from './Albums.js';

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

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

Поддержка Suspense

Только источники данных с поддержкой Suspense активируют компонент Suspense. К ним относятся:

  • Получение данных с помощью фреймворков с поддержкой Suspense, таких как Relay и Next.js.
  • Ленивая загрузка кода компонента с помощью lazy.
  • Считывание значения промиса с использованием use

Suspense не обнаруживает, когда данные извлекаются внутри Effect или обработчика события.

Точный способ загрузки данных в компонент Albums, описанный выше, зависит от вашего фреймворка. Если вы используете фреймворк с поддержкой Suspense, вы найдете подробности в документации по получению данных.

Получение данных с поддержкой Suspense без использования мнений фреймворка пока не поддерживается. Требования к реализации источника данных с поддержкой Suspense нестабильны и не документированы. Официальный API для интеграции источников данных с Suspense будет выпущен в одной из будущих версий React.

Раскрытие содержимого сразу

По умолчанию все дерево внутри Suspense рассматривается как единое целое. Например, даже если только один из этих компонентов приостановится в ожидании каких-то данных, все они вместе будут заменены индикатором загрузки:

1
2
3
4
5
6
<Suspense fallback={<Loading />}>
    <Biography />
    <Panel>
        <Albums />
    </Panel>
</Suspense>

Затем, когда все они будут готовы к отображению, они появятся все вместе одновременно.

В приведенном ниже примере и Biography, и Albums получают некоторые данные. Однако, поскольку они сгруппированы под одной границей Suspense, эти компоненты всегда "всплывают" вместе в одно и то же время.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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>;
}
1
2
3
export default function Panel({ children }) {
    return <section className="panel">{children}</section>;
}

Компоненты, загружающие данные, не обязательно должны быть прямыми дочерними компонентами границы Suspense. Например, вы можете переместить Biography и Albums в новый компонент Details. Это не изменит поведение. Biography и Albums имеют одну и ту же ближайшую родительскую границу Suspense, поэтому их раскрытие координируется вместе.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<Suspense fallback={<Loading />}>
    <Details artistId={artist.id} />
</Suspense>;

function Details({ artistId }) {
    return (
        <>
            <Biography artistId={artistId} />
            <Panel>
                <Albums artistId={artistId} />
            </Panel>
        </>
    );
}

Раскрытие вложенного содержимого по мере загрузки

Когда компонент приостанавливается, ближайший родительский Suspense-компонент показывает запасной вариант. Это позволяет вложить несколько компонентов Suspense для создания последовательности загрузки. Падение каждой границы Suspense будет заполняться по мере того, как становится доступным содержимое следующего уровня. Например, вы можете дать списку альбомов свой собственный откат:

1
2
3
4
5
6
7
8
<Suspense fallback={<BigSpinner />}>
    <Biography />
    <Suspense fallback={<AlbumsGlimmer />}>
        <Panel>
            <Albums />
        </Panel>
    </Suspense>
</Suspense>

С этим изменением отображение Biography не должно "ждать" загрузки Albums.

Последовательность будет следующей:

  1. Если Biography еще не загрузилась, BigSpinner отображается вместо всей области содержимого.
  2. Как только Biography завершает загрузку, BigSpinner заменяется содержимым.
  3. Если Albums еще не загрузились, AlbumsGlimmer отображается вместо Albums и его родительской Panel.
  4. Наконец, когда Albums завершает загрузку, он заменяет AlbumsGlimmer.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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>
    );
}
1
2
3
export default function Panel({ children }) {
    return <section className="panel">{children}</section>;
}

Приостановочные границы позволяют вам координировать, какие части пользовательского интерфейса должны всегда "всплывать" одновременно, а какие - постепенно раскрывать больше содержимого в последовательности состояний загрузки. Вы можете добавлять, перемещать или удалять Suspense-границы в любом месте дерева, не влияя на поведение остального приложения.

Не ставьте приостанавливающую границу вокруг каждого компонента. Границы приостановки не должны быть более детализированными, чем последовательность загрузки, которую вы хотите, чтобы испытал пользователь. Если вы работаете с дизайнером, спросите его, где должны располагаться состояния загрузки - скорее всего, они уже включили их в свои эскизы.

Показ устаревшего контента во время загрузки свежего

В этом примере компонент SearchResults приостанавливается на время получения результатов поиска. Введите "a", дождитесь результатов, а затем измените его на "ab". Результаты для "a" будут заменены загрузочным фалбэком.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Suspense, useState } from 'react';
import SearchResults from './SearchResults.js';

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

Распространенным альтернативным шаблоном пользовательского интерфейса является отложенное обновление списка и отображение предыдущих результатов до тех пор, пока не будут готовы новые результаты. Хук useDeferredValue позволяет вам передать отложенную версию запроса вниз:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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>
        </>
    );
}

Запрос query будет обновлен немедленно, поэтому на входе будет отображаться новое значение. Однако deferredQuery сохранит свое предыдущее значение до тех пор, пока данные не загрузятся, поэтому SearchResults будет отображать устаревшие результаты некоторое время.

Чтобы сделать это более очевидным для пользователя, вы можете добавить визуальную индикацию, когда отображается список несвежих результатов:

1
2
3
4
5
6
7
<div
    style={{
        opacity: query !== deferredQuery ? 0.5 : 1,
    }}
>
    <SearchResults query={deferredQuery} />
</div>

Введите "a" в примере ниже, дождитесь загрузки результатов, а затем измените ввод на "ab". Обратите внимание, что вместо отката на приостановку вы теперь видите затемненный список несвежих результатов, пока не загрузятся новые результаты:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {
    Suspense,
    useState,
    useDeferredValue,
} from 'react';
import SearchResults from './SearchResults.js';

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

И отложенные значения, и transitions позволяют вам избежать отображения Suspense fallback в пользу встроенных индикаторов. Переходы помечают все обновление как несрочное, поэтому они обычно используются фреймворками и библиотеками маршрутизаторов для навигации. Отложенные значения, с другой стороны, в основном полезны в коде приложений, где вы хотите пометить часть пользовательского интерфейса как несрочную и позволить ей "отстать" от остальной части пользовательского интерфейса.

Предотвращение скрытия уже раскрытого содержимого

Когда компонент приостанавливается, ближайшая родительская граница приостановки переключается на отображение резервного копирования. Это может привести к искажению пользовательского опыта, если уже отображалось какое-то содержимое. Попробуйте нажать эту кнопку:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { Suspense, 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) {
        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>;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export default function Layout({ children }) {
    return (
        <div className="layout">
            <section className="header">
                Music Browser
            </section>
            <main>{children}</main>
        </div>
    );
}
1
2
3
4
5
6
7
export default function IndexPage({ navigate }) {
    return (
        <button onClick={() => navigate('/the-beatles')}>
            Open The Beatles artist page
        </button>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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>
            <Biography artistId={artist.id} />
            <Suspense fallback={<AlbumsGlimmer />}>
                <Panel>
                    <Albums artistId={artist.id} />
                </Panel>
            </Suspense>
        </>
    );
}

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

При нажатии кнопки компонент Router отображал ArtistPage вместо IndexPage. Компонент внутри ArtistPage приостанавливался, поэтому ближайшая граница Suspense начинала показывать откат. Ближайшая Suspense-граница находилась рядом с корнем, поэтому весь макет сайта заменялся на BigSpinner.

Чтобы предотвратить это, вы можете пометить обновление состояния навигации как переход с помощью startTransition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function Router() {
    const [page, setPage] = useState('/');

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

Это говорит React, что переход состояния не является срочным, и лучше продолжать показывать предыдущую страницу вместо того, чтобы скрывать уже открытое содержимое. Теперь нажатие на кнопку "ждет" загрузки Биографии:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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>;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export default function Layout({ children }) {
    return (
        <div className="layout">
            <section className="header">
                Music Browser
            </section>
            <main>{children}</main>
        </div>
    );
}
1
2
3
4
5
6
7
export default function IndexPage({ navigate }) {
    return (
        <button onClick={() => navigate('/the-beatles')}>
            Open The Beatles artist page
        </button>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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>
            <Biography artistId={artist.id} />
            <Suspense fallback={<AlbumsGlimmer />}>
                <Panel>
                    <Albums artistId={artist.id} />
                </Panel>
            </Suspense>
        </>
    );
}

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

Переход не ждет, пока загрузится все содержимое. Он ждет только достаточно долго, чтобы не скрыть уже открытое содержимое. Например, сайт Layout уже был показан, поэтому было бы плохо скрывать его за загружающимся волчком. Однако вложенная граница Suspense вокруг Albums является новой, поэтому переход не ждет ее.

Ожидается, что маршрутизаторы с поддержкой Suspense по умолчанию будут оборачивать обновления навигации в переходы.

Индикация того, что переход происходит

В приведенном выше примере после нажатия на кнопку нет визуальной индикации того, что происходит переход. Чтобы добавить индикатор, вы можете заменить startTransition на useTransition, что даст вам булево значение isPending. В примере ниже это используется для изменения стиля заголовка сайта во время перехода:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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>;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export default function Layout({ children, isPending }) {
    return (
        <div className="layout">
            <section
                className="header"
                style={{
                    opacity: isPending ? 0.7 : 1,
                }}
            >
                Music Browser
            </section>
            <main>{children}</main>
        </div>
    );
}
1
2
3
4
5
6
7
export default function IndexPage({ navigate }) {
    return (
        <button onClick={() => navigate('/the-beatles')}>
            Open The Beatles artist page
        </button>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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>
            <Biography artistId={artist.id} />
            <Suspense fallback={<AlbumsGlimmer />}>
                <Panel>
                    <Albums artistId={artist.id} />
                </Panel>
            </Suspense>
        </>
    );
}

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

Сброс границ приостановки при навигации

Во время перехода React будет избегать скрытия уже показанного содержимого. Однако, если вы переходите на маршрут с другими параметрами, вы можете захотеть сказать React, что это другой контент. Вы можете выразить это с помощью key:

1
<ProfilePage key={queryParams.id} />

Представьте, что вы перемещаетесь по странице профиля пользователя, и что-то приостанавливается. Если это обновление завернуто в переход, оно не вызовет откат для уже видимого содержимого. Это ожидаемое поведение.

Однако теперь представьте, что вы перемещаетесь между двумя разными профилями пользователей. В этом случае имеет смысл показать откат. Например, временная шкала одного пользователя представляет собой различное содержимое, чем временная шкала другого пользователя. Указывая ключ, вы гарантируете, что React рассматривает профили разных пользователей как разные компоненты и сбрасывает границы Suspense во время навигации. Маршрутизаторы, интегрированные в Suspense, должны делать это автоматически.

Предоставление обратного хода для ошибок сервера и контента только для сервера

Если вы используете один из API потокового серверного рендеринга (или фреймворк, который полагается на них), React также будет использовать ваши границы <Suspense> для обработки ошибок на сервере. Если компонент выдает ошибку на сервере, React не будет прерывать серверный рендеринг. Вместо этого он найдет ближайший компонент <Suspense> над ним и включит его фалбэк (например, спиннер) в сгенерированный серверный HTML. Пользователь сначала увидит спиннер.

На клиенте React попытается отрисовать тот же компонент еще раз. Если и на клиенте произойдет ошибка, React выдаст ошибку и отобразит ближайшую границу ошибки. Однако, если ошибка не произойдет на клиенте, React не будет отображать ошибку пользователю, так как содержимое в итоге было отображено успешно.

Вы можете использовать это, чтобы исключить некоторые компоненты из рендеринга на сервере. Для этого бросьте ошибку в серверное окружение, а затем оберните их в границу <Suspense>, чтобы заменить их HTML фалбэками:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<Suspense fallback={<Loading />}>
    <Chat />
</Suspense>;

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

HTML сервера будет включать индикатор загрузки. На клиенте он будет заменен компонентом Chat.

Устранение неполадок

Как предотвратить замену пользовательского интерфейса на fallback во время обновления?

Замена видимого пользовательского интерфейса на фалбэк приводит к резким изменениям в работе пользователя. Это может произойти, когда обновление приводит к приостановке компонента, а ближайшая граница приостановки уже показывает пользователю содержимое.

Чтобы этого не произошло, пометьте обновление как несрочное с помощью startTransition. Во время перехода React будет ждать, пока загрузится достаточно данных, чтобы предотвратить появление нежелательного отката:

1
2
3
4
5
6
function handleNextPageClick() {
    // If this update suspends, don't hide the already displayed content
    startTransition(() => {
        setCurrentPage(currentPage + 1);
    });
}

Это позволит избежать скрытия существующего содержимого. Тем не менее, все новые границы Suspense будут немедленно отображать отступления, чтобы избежать блокировки пользовательского интерфейса и позволить пользователю видеть содержимое по мере его появления.

React будет предотвращать нежелательные отступления только во время несрочных обновлений. Он не будет задерживать рендеринг, если он является результатом срочного обновления. Вы должны выбрать API, например startTransition или useDeferredValue.

Если ваш маршрутизатор интегрирован с Suspense, он должен автоматически обернуть свои обновления в startTransition.

Источник — https://react.dev/reference/react/Suspense

Комментарии